Migrate membership checkout to virtual payment and fix template builds
This commit is contained in:
parent
3560457e0b
commit
0831faa3a3
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run dev",
|
"start": "npm run dev",
|
||||||
"dev": "./node_modules/.bin/wepy build --watch",
|
"dev": "./node_modules/.bin/wepy build --watch",
|
||||||
"build": "cross-env NODE_ENV=production ./node_modules/.bin/wepy build --no-cache ",
|
"build": "node tools/build/build.cjs",
|
||||||
"clean": "rm -rf weapp",
|
"clean": "rm -rf weapp",
|
||||||
"lint": "eslint --ext .js,.wpy src/",
|
"lint": "eslint --ext .js,.wpy src/",
|
||||||
"lint:fix": "eslint --ext .js,.wpy src/ --fix",
|
"lint:fix": "eslint --ext .js,.wpy src/ --fix",
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"enhance": true
|
"enhance": true
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"appid": "wxe486777f4379507e",
|
"appid": "wxc41491431733671e",
|
||||||
"projectname": "myproj",
|
"projectname": "myproj",
|
||||||
"miniprogramRoot": "weapp/",
|
"miniprogramRoot": "weapp/",
|
||||||
"srcMiniprogramRoot": "weapp/",
|
"srcMiniprogramRoot": "weapp/",
|
||||||
|
|||||||
@ -97,7 +97,6 @@ export default {
|
|||||||
wx.removeStorageSync('message')
|
wx.removeStorageSync('message')
|
||||||
wx.stopPullDownRefresh()
|
wx.stopPullDownRefresh()
|
||||||
// 控制台调试日志
|
// 控制台调试日志
|
||||||
console.log('[SUCCESS]', statusCode, typeof data === 'object' ? data : data.toString().substring(0, 100))
|
|
||||||
// 状态码正常 & 确认有数据
|
// 状态码正常 & 确认有数据
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
// 成功回调
|
// 成功回调
|
||||||
@ -122,14 +121,13 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
let errorMessage = {message: '错误,请寻找开发人员协助'}
|
let errorMessage = {message: '错误,请寻找开发人员协助'}
|
||||||
throw errorMessage
|
wx.hideLoading()
|
||||||
|
return
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
} else if (data.code === 2) {
|
} else if (data.code === 2) {
|
||||||
console.error(data.message)
|
|
||||||
vm.errorTwo += 1
|
vm.errorTwo += 1
|
||||||
if (vm.errorTwo <= 2) {
|
if (vm.errorTwo <= 2) {
|
||||||
utils.wx_login().then((data) => {
|
utils.wx_login().then((data) => {
|
||||||
console.log(data, '444')
|
|
||||||
let route = `/${currentPage.route}?${url}`
|
let route = `/${currentPage.route}?${url}`
|
||||||
if (!data.data.token) {
|
if (!data.data.token) {
|
||||||
wx.removeStorageSync('information')
|
wx.removeStorageSync('information')
|
||||||
@ -142,7 +140,6 @@ export default {
|
|||||||
route = route.split('?')[0]
|
route = route.split('?')[0]
|
||||||
wx.switchTab({url: route})
|
wx.switchTab({url: route})
|
||||||
} else {
|
} else {
|
||||||
console.log('222')
|
|
||||||
wx.redirectTo({url: route})
|
wx.redirectTo({url: route})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,7 +152,6 @@ export default {
|
|||||||
throw errorMessage
|
throw errorMessage
|
||||||
} else if (code === 3) {
|
} else if (code === 3) {
|
||||||
utils.wx_login().then((data) => {
|
utils.wx_login().then((data) => {
|
||||||
console.log(data, '444')
|
|
||||||
let route = `/${currentPage.route}?${url}`
|
let route = `/${currentPage.route}?${url}`
|
||||||
if (!data.data.token) {
|
if (!data.data.token) {
|
||||||
wx.removeStorageSync('information')
|
wx.removeStorageSync('information')
|
||||||
@ -168,7 +164,6 @@ export default {
|
|||||||
route = route.split('?')[0]
|
route = route.split('?')[0]
|
||||||
wx.switchTab({url: route})
|
wx.switchTab({url: route})
|
||||||
} else {
|
} else {
|
||||||
console.log('222')
|
|
||||||
wx.redirectTo({url: route})
|
wx.redirectTo({url: route})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,7 +186,6 @@ export default {
|
|||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
// eslint-disable-next-line prefer-promise-reject-errors
|
// eslint-disable-next-line prefer-promise-reject-errors
|
||||||
reject(err)
|
reject(err)
|
||||||
console.error('[ERROR]', err)
|
|
||||||
},
|
},
|
||||||
complete: (res) => {
|
complete: (res) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -221,7 +215,6 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log('当前网络不稳定!请重试...')
|
|
||||||
let errorMessage = {message: '当前网络不稳定!请重试'}
|
let errorMessage = {message: '当前网络不稳定!请重试'}
|
||||||
throw errorMessage
|
throw errorMessage
|
||||||
}
|
}
|
||||||
@ -231,8 +224,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
console.log(this.i18nL)
|
|
||||||
console.log('created in mixin', '插件打印')
|
|
||||||
let vm = this
|
let vm = this
|
||||||
let cacheBodyData = wx.getStorageSync('cacheBodyData')
|
let cacheBodyData = wx.getStorageSync('cacheBodyData')
|
||||||
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
|
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
|
||||||
@ -240,7 +231,6 @@ export default {
|
|||||||
data: cacheBodyData
|
data: cacheBodyData
|
||||||
}
|
}
|
||||||
vm.$app.$options.globalData.resubmit = 2
|
vm.$app.$options.globalData.resubmit = 2
|
||||||
console.log(data, '提交数组数据***********')
|
|
||||||
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
|
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
vm.app.globalData.bodyFatData = null
|
vm.app.globalData.bodyFatData = null
|
||||||
@ -249,7 +239,6 @@ export default {
|
|||||||
}
|
}
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.log(err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -397,10 +397,6 @@ page {
|
|||||||
</swiper-item>
|
</swiper-item>
|
||||||
</block>
|
</block>
|
||||||
</swiper>
|
</swiper>
|
||||||
<view :class="{'ui-fade-out': isScroll,'ui-fade-in': !isScroll}" class="ui-adv-box" v-if="!isClickClose" @tap="goToShop">
|
|
||||||
<!-- <view class="ui-adv-message ui--message-fade"></view>-->
|
|
||||||
<div class="ui-adv-close" @tap.stop="isClickClose = true"></div>
|
|
||||||
</view>
|
|
||||||
<view class="f-fbc ui-pl-30 ui-pt-40 ui-pr-30">
|
<view class="f-fbc ui-pl-30 ui-pt-40 ui-pr-30">
|
||||||
<view class="font_34 color333 bold">置顶推荐</view>
|
<view class="font_34 color333 bold">置顶推荐</view>
|
||||||
<image class="ui-top-up" src="https://image.fulllinkai.com/202409/24/9c5a93366fc9e24dec02ba62c87ecf15.png" mode="widthFix" @tap="applyTopUp" v-if="topUpState != 2"></image>
|
<image class="ui-top-up" src="https://image.fulllinkai.com/202409/24/9c5a93366fc9e24dec02ba62c87ecf15.png" mode="widthFix" @tap="applyTopUp" v-if="topUpState != 2"></image>
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
<swiper-item v-for="(item,index) in rankList" :key="index" class="{{cardCur==index?'cur':''}}"
|
<swiper-item v-for="(item,index) in rankList" :key="index" class="{{cardCur==index?'cur':''}}"
|
||||||
@tap="showFn(index)">
|
@tap="showFn(index)">
|
||||||
<view class="swiper-item radius shadow " style="border-radius: 0">
|
<view class="swiper-item radius shadow " style="border-radius: 0">
|
||||||
<image src="{{system === 'iOS' ? (item.ios_pic || item.pic) : item.pic}}" mode="aspectFix"></image>
|
<view style="padding:40rpx;background:#f7ead0;border-radius:24rpx;color:#333"><view>{{item.name}}</view><view>{{purchaseEnabled ? '购买前确认价格' : '会员购买暂不可用'}}</view></view>
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
@ -70,16 +70,17 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view style="height: 42px"></view>
|
<view style="height: 42px"></view>
|
||||||
|
<view class="recent-member-order" style="padding:24rpx;text-align:center" @tap="checkRecentOrder">查询最近会员订单</view>
|
||||||
<!-- 支付按钮:全端统一显示 -->
|
<!-- 支付按钮:全端统一显示 -->
|
||||||
<view class="payBtn" @tap="showFn()">
|
<view class="payBtn" @tap="showFn()">
|
||||||
<view class="l_text">
|
<view class="l_text">
|
||||||
<span>
|
<span>
|
||||||
合计: <span style="color: #FF2946">¥</span><span class="price">{{ payData.discount_price }}</span>
|
{{purchaseEnabled ? '点击查看当前套餐价格' : '会员购买暂不可用'}}
|
||||||
</span>
|
</span>
|
||||||
</view>
|
</view>
|
||||||
<view class="r_text">
|
<view class="r_text">
|
||||||
<span class="font_32">
|
<span class="font_32">
|
||||||
立即购买
|
{{purchasing ? '正在确认订单' : (purchaseEnabled ? '购买会员' : '暂不可购买')}}
|
||||||
</span>
|
</span>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -114,11 +115,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
<span class="value bold color333" style="font-size: 60rpx;font-weight: 700;"
|
<span class="value bold color333" style="font-size: 60rpx;font-weight: 700;"
|
||||||
v-else>{{ deductionPrice > 0 ? deductionPrice : '0.00' }}</span>
|
v-else>{{ deductionPrice > 0 ? deductionPrice : '0.00' }}</span>
|
||||||
</view>
|
</view>
|
||||||
<view class="text-left f-fcc">
|
|
||||||
<span class="font_24" style="color: #3C2913;text-decoration-line: line-through;">原价:¥{{
|
|
||||||
payData.price
|
|
||||||
}}</span>
|
|
||||||
</view>
|
|
||||||
<view class="item-xl text-left f-fcc">
|
<view class="item-xl text-left f-fcc">
|
||||||
<span class="font_24" style="color: #66676C;">{{ payData.title }}</span>
|
<span class="font_24" style="color: #66676C;">{{ payData.title }}</span>
|
||||||
</view>
|
</view>
|
||||||
@ -127,7 +124,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
<image class="u_payData_img"
|
<image class="u_payData_img"
|
||||||
src="https://images.health.ufutx.com/202512/25/5bcd5b54fc5325aa50e841e0cf9542d0.png"
|
src="https://images.health.ufutx.com/202512/25/5bcd5b54fc5325aa50e841e0cf9542d0.png"
|
||||||
mode="aspectFit" lazy-load="true"></image>
|
mode="aspectFit" lazy-load="true"></image>
|
||||||
<view class="font_28" style="color: #0E0E0E;">微信支付</view>
|
<view class="font_28" style="color: #0E0E0E;">{{system === 'iOS' ? 'Apple 支付' : '微信支付'}}</view>
|
||||||
</view>
|
</view>
|
||||||
<image src="https://images.health.ufutx.com/202512/25/911f75d508bc6c0de593fbb815a2c479.png" mode="aspectFit"
|
<image src="https://images.health.ufutx.com/202512/25/911f75d508bc6c0de593fbb815a2c479.png" mode="aspectFit"
|
||||||
class="iconV2"></image>
|
class="iconV2"></image>
|
||||||
@ -168,6 +165,7 @@ import wepy from '@wepy/core'
|
|||||||
import https from '../../mixins/https'
|
import https from '../../mixins/https'
|
||||||
import base from '../../mixins/base'
|
import base from '../../mixins/base'
|
||||||
import {service} from '../../config.js'
|
import {service} from '../../config.js'
|
||||||
|
import {buyMembership} from '../../utils/virtual-payment'
|
||||||
|
|
||||||
const app = getApp().$wepy.$options
|
const app = getApp().$wepy.$options
|
||||||
|
|
||||||
@ -198,6 +196,9 @@ wepy.page({
|
|||||||
modalName: '',
|
modalName: '',
|
||||||
chat_user_id: '',
|
chat_user_id: '',
|
||||||
trade_no: '',
|
trade_no: '',
|
||||||
|
purchaseEnabled: false,
|
||||||
|
purchasing: false,
|
||||||
|
virtualProducts: [],
|
||||||
system: '', // 系统类型 iOS/Android
|
system: '', // 系统类型 iOS/Android
|
||||||
share_type: '',
|
share_type: '',
|
||||||
shareId: '',
|
shareId: '',
|
||||||
@ -230,6 +231,7 @@ wepy.page({
|
|||||||
this.rankList = []
|
this.rankList = []
|
||||||
// 获取系统类型
|
// 获取系统类型
|
||||||
this.system = app.globalData.systemInfo.system.split(' ')[0]
|
this.system = app.globalData.systemInfo.system.split(' ')[0]
|
||||||
|
this.refreshPurchase()
|
||||||
this.getPageData()
|
this.getPageData()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -263,6 +265,24 @@ wepy.page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
pendingStorageKey() {const u = wx.getStorageSync('userInfo') || {}; return 'virtual-member-order:' + (u.id || u.user_id || 'anonymous')},
|
||||||
|
async checkRecentOrder() {
|
||||||
|
const orderNo = wx.getStorageSync(this.pendingStorageKey())
|
||||||
|
if (!orderNo) {wx.showToast({title:'暂无待查询订单',icon:'none'});return}
|
||||||
|
try {
|
||||||
|
const result = await this.paymentAPI().post('/order/virtual/' + encodeURIComponent(orderNo) + '/confirm', {})
|
||||||
|
if (result.state === 'FULFILLED') {this.getPageData();wx.removeStorageSync(this.pendingStorageKey());wx.showToast({title:'会员权益已到账',icon:'success'})}
|
||||||
|
else {wx.showModal({title:'会员订单',content:'支付结果仍在确认中,请勿重复购买;如已扣款请联系客服核对。',showCancel:false})}
|
||||||
|
} catch (e) {wx.showModal({title:'会员订单',content:e.message || '查询失败,请稍后重试',showCancel:false})}
|
||||||
|
},
|
||||||
|
paymentAPI() {
|
||||||
|
const unwrap = r => { if (r.code !== 0) throw new Error(r.message || '请求失败'); return r.data }
|
||||||
|
return {get: path => this.$get({url: service.host + path}).then(unwrap), post: (path, data) => this.$post({url: service.host + path, data}).then(unwrap)}
|
||||||
|
},
|
||||||
|
async refreshPurchase() {
|
||||||
|
this.purchaseEnabled = false
|
||||||
|
try {const status = await this.paymentAPI().get('/rank/purchase/status'); this.virtualProducts = status.products || []; this.purchaseEnabled = status.enabled === true} catch (e) {this.virtualProducts = []}
|
||||||
|
},
|
||||||
gotoApp(item) {
|
gotoApp(item) {
|
||||||
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
|
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
|
||||||
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
||||||
@ -278,6 +298,7 @@ wepy.page({
|
|||||||
data.ranks.sub_ranks.forEach((item) => {
|
data.ranks.sub_ranks.forEach((item) => {
|
||||||
vm.rankList.push(item)
|
vm.rankList.push(item)
|
||||||
})
|
})
|
||||||
|
if (!vm.rankList.length) {wx.hideLoading();return}
|
||||||
vm.payData.title = `超级会员(${vm.rankList[0].name})`
|
vm.payData.title = `超级会员(${vm.rankList[0].name})`
|
||||||
vm.payData.price = `${vm.rankList[0].price}`
|
vm.payData.price = `${vm.rankList[0].price}`
|
||||||
vm.payData.discount_price = `${vm.rankList[0].discount_price}`
|
vm.payData.discount_price = `${vm.rankList[0].discount_price}`
|
||||||
@ -312,28 +333,19 @@ wepy.page({
|
|||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
},
|
},
|
||||||
showFn(index) {
|
async showFn(index) {
|
||||||
if (!this.$signInVerify()) {
|
if (this.purchasing) return
|
||||||
this.loginShow = true
|
if (!this.$signInVerify()) {this.loginShow = true; return}
|
||||||
return
|
await this.refreshPurchase()
|
||||||
}
|
if (!this.purchaseEnabled) {wx.showToast({title:'会员购买暂不可用',icon:'none'});return}
|
||||||
// 移除原来的iOS禁止支付逻辑
|
const item = this.rankList[typeof index === 'number' ? index : this.cardCur]
|
||||||
|
const product = item && this.virtualProducts.find(p => p.sub_rank_id === item.id)
|
||||||
|
if (!product) {wx.showToast({title:'套餐暂不可购买',icon:'none'});return}
|
||||||
this.wxPayShow = 'cash'
|
this.wxPayShow = 'cash'
|
||||||
this.coinMoney = (this.payData.coin / 10).toFixed(2)
|
this.payData.payId = item.id
|
||||||
this.deductionPrice = this.payData.coin >= this.specialVIP.can_coin_amount ? (this.payData.discount_price - this.specialVIP.can_cash_amount).toFixed(2) : (this.payData.discount_price - this.coinMoney).toFixed(2)
|
this.payData.discount_price = (product.price_fen / 100).toFixed(2)
|
||||||
if (this.rankList[this.cardCur].id) {
|
this.payData.title = item.name
|
||||||
this.modalName = 'DialogModal'
|
this.modalName = 'DialogModal'
|
||||||
let monay = 0
|
|
||||||
if (this.payData.discount_price < this.payData.coin / 10) {
|
|
||||||
monay = 0
|
|
||||||
} else {
|
|
||||||
monay = (this.payData.discount_price - this.payData.coin / 10).toFixed(2)
|
|
||||||
}
|
|
||||||
this.payData.monay = Math.abs(monay).toFixed(2)
|
|
||||||
this.payData.payId = this.rankList[this.cardCur].id
|
|
||||||
} else {
|
|
||||||
this.modalName = 'Modal'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
hideModal() {
|
hideModal() {
|
||||||
this.modalName = ''
|
this.modalName = ''
|
||||||
@ -356,143 +368,17 @@ wepy.page({
|
|||||||
showTost() {
|
showTost() {
|
||||||
this.$showToast('请同意《福恋服务协议》')
|
this.$showToast('请同意《福恋服务协议》')
|
||||||
},
|
},
|
||||||
// ======================================
|
async conversion() {
|
||||||
// 核心支付方法(Android原接口,iOS新虚拟支付接口)
|
if (this.purchasing) return
|
||||||
// ======================================
|
this.purchasing = true
|
||||||
conversion(item) {
|
this.modalName = ''
|
||||||
let that = this
|
try {
|
||||||
that.modalName = ''
|
await buyMembership(wx, this.paymentAPI(), this.payData.payId, Math.round(Number(this.payData.discount_price) * 100), orderNo => { this.trade_no = orderNo; wx.setStorageSync(this.pendingStorageKey(), orderNo) })
|
||||||
that.$showLoading('支付中...')
|
this.getPageData()
|
||||||
|
wx.removeStorageSync(this.pendingStorageKey())
|
||||||
// 根据系统类型选择不同的接口和参数
|
wx.showToast({title:'会员权益已到账',icon:'success'})
|
||||||
if (that.system === 'iOS') {
|
} catch (e) {wx.showModal({title:'会员支付',content:e.message || '支付暂不可用,请稍后查询订单',showCancel:false})}
|
||||||
// 先获取微信登录 code
|
finally {this.purchasing = false;wx.hideLoading()}
|
||||||
that.$showLoading('获取支付信息...')
|
|
||||||
|
|
||||||
wx.login({
|
|
||||||
success: (loginRes) => {
|
|
||||||
const code = loginRes.code
|
|
||||||
console.log('获取到code:', code)
|
|
||||||
|
|
||||||
let url = `${service.host}/rank/buy/virtual`
|
|
||||||
let data = {
|
|
||||||
sub_rank_id: this.payData.payId, // 必须:会员套餐ID
|
|
||||||
pay_type: this.wxPayShow, // 必须:支付类型
|
|
||||||
share_type: this.share_type || '', // 必须:分享类型
|
|
||||||
code: code // 微信登录code,用于后端签名
|
|
||||||
// chat_user_id: this.chat_user_id || '', // 非必须:聊天用户ID
|
|
||||||
// from_user_id: wx.getStorageSync('from_user_id') || '' // 非必须:来源用户ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用iOS虚拟支付接口创建订单
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
that.$Toast_error(data?.msg || '订单创建失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
that.trade_no = data?.order_no
|
|
||||||
console.log('---', that.trade_no)
|
|
||||||
|
|
||||||
// 测试订单直接回调
|
|
||||||
if (data.length == 0 || data.config?.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS虚拟支付
|
|
||||||
const virtualPayData = data || {}
|
|
||||||
wx.requestVirtualPayment({
|
|
||||||
signData: virtualPayData.sign_data,
|
|
||||||
paySig: virtualPayData.paySig || '',
|
|
||||||
signature: virtualPayData.signature || '',
|
|
||||||
mode: virtualPayData.mode || 'short_series_goods',
|
|
||||||
success(res) {
|
|
||||||
console.log('requestVirtualPayment success', res)
|
|
||||||
// 虚拟支付成功后回调
|
|
||||||
that.$post({
|
|
||||||
url: `${service.host}/order/callback/virtual`,
|
|
||||||
data: {orderNo: that.trade_no}
|
|
||||||
}).then(() => {
|
|
||||||
that.getPageData()
|
|
||||||
wx.showModal({ // 使用模态框提示用户进行操作
|
|
||||||
title: '支付完成',
|
|
||||||
content: '会员开通成功,权益将在几分钟内到账,到账后即可生效。',
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: `知道了`,
|
|
||||||
success: function (res) {
|
|
||||||
if (res.confirm) {
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
} else if (res.cancel) {
|
|
||||||
console.log('用户点击取消')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail({errMsg, errCode}) {
|
|
||||||
console.error('虚拟支付失败:', errMsg, errCode)
|
|
||||||
that.$Toast_error('支付失败,请重试')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('订单创建失败:', err)
|
|
||||||
that.$Toast_error('订单创建失败,请重试')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('wx.login失败:', err)
|
|
||||||
that.$Toast_error('获取登录信息失败,请重试')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// ============== Android:原有普通支付接口 ==============
|
|
||||||
let url = `${service.host}/rank/buy`
|
|
||||||
|
|
||||||
let data = {
|
|
||||||
sub_rank_id: this.payData.payId,
|
|
||||||
pay_type: this.wxPayShow
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用原接口创建订单
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
that.trade_no = data.order.trade_no
|
|
||||||
|
|
||||||
// 测试订单直接回调
|
|
||||||
if (data.length == 0 || data.config?.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android微信支付
|
|
||||||
let wxconfig = data.config
|
|
||||||
wx.requestPayment({
|
|
||||||
timeStamp: wxconfig.timeStamp,
|
|
||||||
nonceStr: wxconfig.nonceStr,
|
|
||||||
package: wxconfig.package,
|
|
||||||
signType: wxconfig.signType || 'MD5',
|
|
||||||
paySign: wxconfig.paySign,
|
|
||||||
success(res) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(() => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail(res) {
|
|
||||||
wx.showToast({title: '已取消支付', icon: 'none'})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
payChoice(e) {
|
payChoice(e) {
|
||||||
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
||||||
|
|||||||
@ -53,19 +53,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<block v-if="type == 'single'">
|
<block v-if="type == 'single'">
|
||||||
<view class="payBtn" v-if="system != 'iOS' || active == '1'" @tap="showFn()">
|
<view class="payBtn" @tap="showFn()">单项服务暂不可购买</view>
|
||||||
<view class="l_text">
|
|
||||||
<span class="font_32 bold">
|
|
||||||
<span>¥</span>{{specialVIP.discount_price}}
|
|
||||||
</span>
|
|
||||||
<!-- <span style="text-decoration: line-through;" class="font_22">{{specialVIP.price}}</span>-->
|
|
||||||
</view>
|
|
||||||
<view class="r_text">
|
|
||||||
<span class="font_32">
|
|
||||||
立即购买
|
|
||||||
</span>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<button class="btn text-center font_30 btn-box white radius shadow bg-blue margin-top" open-type="share">分享给好友</button>
|
<button class="btn text-center font_30 btn-box white radius shadow bg-blue margin-top" open-type="share">分享给好友</button>
|
||||||
@ -116,7 +104,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="m_pay_bu f-fcc">
|
<view class="m_pay_bu f-fcc">
|
||||||
<view class="u_pay_bu f-fcc font_32 white bold" bindtap="conversion">立即支付</view>
|
<view class="u_pay_bu f-fcc font_32 white bold" bindtap="conversion">暂不可购买</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -196,15 +184,12 @@
|
|||||||
}
|
}
|
||||||
if (e.other_user_id) {
|
if (e.other_user_id) {
|
||||||
this.other_user_id = e.other_user_id
|
this.other_user_id = e.other_user_id
|
||||||
console.log(this.other_user_id, 'this.other_user_id')
|
|
||||||
}
|
}
|
||||||
if (e.chat_user_id) {
|
if (e.chat_user_id) {
|
||||||
this.chat_user_id = e.chat_user_id
|
this.chat_user_id = e.chat_user_id
|
||||||
}
|
}
|
||||||
console.log(this.chat_user_id, '99999')
|
|
||||||
this.rankList = []
|
this.rankList = []
|
||||||
this.system = wx.getStorageSync('system')
|
this.system = wx.getStorageSync('system')
|
||||||
console.log(this.system)
|
|
||||||
this.initPageData()
|
this.initPageData()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -215,7 +200,6 @@
|
|||||||
let openid = wx.getStorageSync('openid')
|
let openid = wx.getStorageSync('openid')
|
||||||
let fromUserID = wx.getStorageSync('user_id')
|
let fromUserID = wx.getStorageSync('user_id')
|
||||||
let url = `/pages/users/upgradeVIP2?from_openid=${openid}&share_user_id=${that.id}&share_type=share&from_user_id=${fromUserID}&index=${that.cardCur}`
|
let url = `/pages/users/upgradeVIP2?from_openid=${openid}&share_user_id=${that.id}&share_type=share&from_user_id=${fromUserID}&index=${that.cardCur}`
|
||||||
console.log(url)
|
|
||||||
return {
|
return {
|
||||||
title: that.shareText,
|
title: that.shareText,
|
||||||
path: url,
|
path: url,
|
||||||
@ -239,9 +223,7 @@
|
|||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
cardCur() {
|
cardCur() {
|
||||||
console.log('payData', this.cardCur)
|
|
||||||
// if (this.active == 1) {
|
// if (this.active == 1) {
|
||||||
console.log(this.rankList[this.cardCur])
|
|
||||||
this.payData.title = this.rankList[this.cardCur].title
|
this.payData.title = this.rankList[this.cardCur].title
|
||||||
this.payData.price = this.rankList[this.cardCur].price
|
this.payData.price = this.rankList[this.cardCur].price
|
||||||
this.payData.discount_price = this.rankList[this.cardCur].discount_price
|
this.payData.discount_price = this.rankList[this.cardCur].discount_price
|
||||||
@ -271,7 +253,6 @@
|
|||||||
// rank_id: data.rank_id
|
// rank_id: data.rank_id
|
||||||
// }
|
// }
|
||||||
// wx.setStorageSync('userInfo', userInfo)
|
// wx.setStorageSync('userInfo', userInfo)
|
||||||
console.log(data)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// 判断是否json
|
// 判断是否json
|
||||||
@ -296,12 +277,17 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
that.rankList = data.services.map(item => {
|
that.rankList = (Array.isArray(data.services) ? data.services : []).map(item => {
|
||||||
if (that.isValidJSON(item.feature)) {
|
if (that.isValidJSON(item.feature)) {
|
||||||
item.feature = JSON.parse(item.feature)
|
item.feature = JSON.parse(item.feature)
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
if (!that.rankList.length) {
|
||||||
|
that.specialVIP = {feature: [], discount_desc: '暂无可展示的服务套餐,单项服务暂不可购买'}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!that.rankList[that.cardCur_1]) that.cardCur_1 = 0
|
||||||
that.specialVIP = that.rankList[that.cardCur_1]
|
that.specialVIP = that.rankList[that.cardCur_1]
|
||||||
that.payData.title = `${that.rankList[that.cardCur_1].title}`
|
that.payData.title = `${that.rankList[that.cardCur_1].title}`
|
||||||
that.payData.price = `${that.rankList[that.cardCur_1].price}`
|
that.payData.price = `${that.rankList[that.cardCur_1].price}`
|
||||||
@ -312,10 +298,10 @@
|
|||||||
if (wx.getStorageSync('from_user_id')) {
|
if (wx.getStorageSync('from_user_id')) {
|
||||||
// that.worthShare()
|
// that.worthShare()
|
||||||
}
|
}
|
||||||
wx.hideLoading()
|
}).catch(() => {
|
||||||
console.log(that.payData, 'rankList')
|
that.rankList = []
|
||||||
console.log(that.rankList, 'rankList')
|
that.specialVIP = {feature: [], discount_desc: '服务套餐加载失败,请稍后重试;单项服务暂不可购买'}
|
||||||
})
|
}).finally(() => wx.hideLoading())
|
||||||
},
|
},
|
||||||
worthShare() {
|
worthShare() {
|
||||||
let vm = this,
|
let vm = this,
|
||||||
@ -333,48 +319,21 @@
|
|||||||
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
||||||
},
|
},
|
||||||
cardSwiper(e) {
|
cardSwiper(e) {
|
||||||
console.log(e, 'e===')
|
|
||||||
this.cardCur = e.$wx.detail.current
|
this.cardCur = e.$wx.detail.current
|
||||||
this.shareText = this.rankList[e.$wx.detail.current].title
|
this.shareText = this.rankList[e.$wx.detail.current].title
|
||||||
this.sharePic = this.rankList[e.$wx.detail.current].pic
|
this.sharePic = this.rankList[e.$wx.detail.current].pic
|
||||||
// if (this.active == 1) {
|
// if (this.active == 1) {
|
||||||
console.log(this.cardCur)
|
|
||||||
this.specialVIP = this.rankList[this.cardCur]
|
this.specialVIP = this.rankList[this.cardCur]
|
||||||
// }
|
// }
|
||||||
clearTimeout(this.timeID)
|
clearTimeout(this.timeID)
|
||||||
this.timeID = setTimeout(() => {
|
this.timeID = setTimeout(() => {
|
||||||
this.shareId = this.rankList[this.cardCur].id
|
this.shareId = this.rankList[this.cardCur].id
|
||||||
console.log(this.shareId)
|
|
||||||
if (wx.getStorageSync('from_user_id')) {
|
if (wx.getStorageSync('from_user_id')) {
|
||||||
this.worthShare()
|
this.worthShare()
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
},
|
},
|
||||||
showFn (index) { // 弹框支付
|
showFn() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
|
||||||
if (!this.$signInVerify()) {
|
|
||||||
this.loginShow = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.wxPayShow = 'cash'
|
|
||||||
this.coinMoney = (this.payData.coin / 10).toFixed(2)
|
|
||||||
// this.deductionPrice = this.payData.coin >= this.specialVIP.can_coin_amount ? (this.payData.discount_price - this.specialVIP.can_cash_amount).toFixed(2) : (this.payData.discount_price - this.coinMoney).toFixed(2)
|
|
||||||
if (this.rankList[this.cardCur].id) {
|
|
||||||
// let monay = 0
|
|
||||||
// if (this.payData.discount_price < this.payData.coin / 10) {
|
|
||||||
// monay = 0
|
|
||||||
// } else {
|
|
||||||
// monay = (this.payData.discount_price - this.payData.coin / 10).toFixed(2)
|
|
||||||
// }
|
|
||||||
// console.log(monay, 'monay=')
|
|
||||||
// this.payData.monay = Math.abs(monay).toFixed(2)
|
|
||||||
this.payData.payId = this.rankList[this.cardCur].id
|
|
||||||
this.modalName = 'DialogModal'
|
|
||||||
} else {
|
|
||||||
this.modalName = 'Modal'
|
|
||||||
}
|
|
||||||
// this.payData = JSON.stringify(this.payData)
|
|
||||||
console.log(this.payData, '===')
|
|
||||||
},
|
|
||||||
hideModal () {
|
hideModal () {
|
||||||
this.modalName = ''
|
this.modalName = ''
|
||||||
},
|
},
|
||||||
@ -397,75 +356,7 @@
|
|||||||
showTost() {
|
showTost() {
|
||||||
this.$showToast('请同意《福恋服务协议》')
|
this.$showToast('请同意《福恋服务协议》')
|
||||||
},
|
},
|
||||||
conversion(item) {
|
conversion() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
|
||||||
console.log(this.payData.payId)
|
|
||||||
let that = this,
|
|
||||||
url = `${service.host}/rank/service/${this.payData.payId}/buy`
|
|
||||||
that.modalName = ''
|
|
||||||
that.$showLoading('支付中...')
|
|
||||||
let data = {}
|
|
||||||
// if (that.active == 1) {
|
|
||||||
data = {
|
|
||||||
// chat_user_id: this.chat_user_id,
|
|
||||||
// other_user_id: this.other_user_id,
|
|
||||||
pay_type: this.wxPayShow
|
|
||||||
}
|
|
||||||
// } else {
|
|
||||||
// data = {
|
|
||||||
// sub_rank_id: this.payData.payId,
|
|
||||||
// chat_user_id: this.chat_user_id,
|
|
||||||
// other_user_id: this.other_user_id,
|
|
||||||
// pay_type: this.wxPayShow
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
that.trade_no = data.trade_no
|
|
||||||
if (data.length == 0) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.cardCur_1 = 0
|
|
||||||
that.getUserData()
|
|
||||||
that.initPageData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let wxconfig = data.config
|
|
||||||
// wx.config(JSON.parse(response.data.data.order.wx_pay.js));
|
|
||||||
if (wxconfig.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wx.requestPayment({
|
|
||||||
timeStamp: wxconfig.timeStamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
|
|
||||||
nonceStr: wxconfig.nonceStr, // 支付签名随机串,不长于 32 位
|
|
||||||
package: wxconfig.package, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=***)
|
|
||||||
signType: wxconfig.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
|
|
||||||
paySign: wxconfig.paySign, // 支付签名
|
|
||||||
success: function (res) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: function (res) {
|
|
||||||
wx.showToast({
|
|
||||||
title: '已取消支付',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
wx.hideLoading()
|
|
||||||
}, 1200)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
payChoice(e) {
|
payChoice(e) {
|
||||||
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
||||||
},
|
},
|
||||||
@ -506,6 +397,7 @@
|
|||||||
._bc-user{
|
._bc-user{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
|
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
|
||||||
|
background-color: #191919;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -635,29 +527,20 @@
|
|||||||
letter-spacing: 8rpx;
|
letter-spacing: 8rpx;
|
||||||
}
|
}
|
||||||
.payBtn{
|
.payBtn{
|
||||||
position: relative;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translate(-50%);
|
width: 100%;
|
||||||
width: 480rpx;
|
padding: 28rpx 24rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||||
height: 60px;
|
box-sizing: border-box;
|
||||||
background-image: url("https://images.ufutx.com/202007/17/fb7b8b1cd0d1b289d1e029257a97e949.png");
|
background: #fff;
|
||||||
background-size: contain;
|
color: #666;
|
||||||
background-repeat: no-repeat;
|
text-align: center;
|
||||||
.l_text{
|
font-size: 30rpx;
|
||||||
position: absolute;
|
z-index: 10;
|
||||||
left: 60rpx;
|
|
||||||
top: 12rpx;
|
|
||||||
}
|
|
||||||
.r_text{
|
|
||||||
color: #f8e8b2;
|
|
||||||
position: absolute;
|
|
||||||
right: 56rpx;
|
|
||||||
top: 12rpx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-vip{
|
.bg-vip{
|
||||||
background: #333333;
|
background: #333333;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
34
src/utils/virtual-payment.js
Normal file
34
src/utils/virtual-payment.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// All terminals use virtual payment. Never grant entitlements from a client callback.
|
||||||
|
export function checkVirtualSupport(wxApi) {
|
||||||
|
const info = wxApi.getSystemInfoSync()
|
||||||
|
if (typeof wxApi.requestVirtualPayment !== 'function' || (wxApi.canIUse && !wxApi.canIUse('requestVirtualPayment'))) throw new Error('当前微信版本不支持会员支付,请升级微信')
|
||||||
|
if (/ios/i.test(info.platform || '') || /^iOS/i.test(info.system || '')) {
|
||||||
|
const version = (info.version || '').split('.').map(Number)
|
||||||
|
if ((version[0] || 0) < 8 || (version[0] === 8 && (version[1] || 0) === 0 && (version[2] || 0) < 68) || Number((info.system || '').replace(/^iOS\s*/i, '').split('.')[0]) < 15) throw new Error('请升级至 iOS 15 和微信 8.0.68 或更高版本')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function buyMembership(wxApi, api, id, expectedPriceFen, onCreated) {
|
||||||
|
checkVirtualSupport(wxApi)
|
||||||
|
const status = await api.get('/rank/purchase/status')
|
||||||
|
const product = status.products && status.products.find(p => p.sub_rank_id === id)
|
||||||
|
if (!status.enabled || !product) throw new Error('会员购买暂不可用')
|
||||||
|
if (product.price_fen !== expectedPriceFen) throw new Error('套餐价格已更新,请重新确认')
|
||||||
|
const login = await new Promise((resolve, reject) => wxApi.login({success: resolve, fail: reject}))
|
||||||
|
if (!login.code) throw new Error('微信登录校验失败')
|
||||||
|
const order = await api.post('/rank/buy/virtual', {sub_rank_id: id, code: login.code})
|
||||||
|
const data = JSON.parse(order.sign_data)
|
||||||
|
if (data.goodsPrice !== expectedPriceFen || data.buyQuantity !== 1 || data.productId !== product.product_id || data.outTradeNo !== order.order_no) throw new Error('支付订单与确认信息不一致,请重新下单')
|
||||||
|
onCreated(order.order_no)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => wxApi.requestVirtualPayment({signData: order.sign_data, paySig: order.paySig, signature: order.signature, mode: order.mode, success: resolve, fail: reject}))
|
||||||
|
} catch (e) {
|
||||||
|
if (/cancel/i.test(e.errMsg || '')) throw new Error('已取消支付,可在订单中查询最终状态')
|
||||||
|
throw new Error('支付未完成,请稍后在订单中查询;如提示支付受限,请联系客服')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const result = await api.post('/order/virtual/' + encodeURIComponent(order.order_no) + '/confirm', {})
|
||||||
|
if (result.state === 'FULFILLED') return result
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
throw new Error('支付结果确认中,请稍后查询订单,勿重复购买')
|
||||||
|
}
|
||||||
6
tools/build/README.txt
Normal file
6
tools/build/README.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
正式编译:Node 16.20.2,依赖使用现有锁文件。npm run build 仅编译,不执行上传。
|
||||||
|
tools/build/build.cjs 在 WePY 完成后规范化非法 WXML 单引号转义,避免预览 unexpected character。
|
||||||
|
正式 AppID 经用户确认修改为 wxc41491431733671e;不改变首页商城跳转的其他应用 AppID。
|
||||||
|
正式制品从 main 的 Git 导出构建,不带 mock、local runtime 或测试入口;接口指向现有线上域名。
|
||||||
|
购买仍由服务端开关控制,默认关闭;不能把编译成功视为支付能力开通或生产验证。
|
||||||
|
前后端回滚交接见服务端 tools/release/ROLLBACK.txt。
|
||||||
11
tools/build/build.cjs
Normal file
11
tools/build/build.cjs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
'use strict'
|
||||||
|
// Compile only. Never upload, preview, or run the application.
|
||||||
|
const fs=require('fs'),path=require('path'),cp=require('child_process')
|
||||||
|
const root=path.resolve(__dirname,'../..')
|
||||||
|
const result=cp.spawnSync(process.execPath,[require.resolve('@wepy/cli/bin/wepy.js'),'build','--no-cache'],{cwd:root,env:{...process.env,NODE_ENV:'production'},stdio:'inherit'})
|
||||||
|
if(result.error || result.status!==0)process.exit(result.status || 1)
|
||||||
|
const fix=require('./fix-template-expressions.cjs')
|
||||||
|
let count=0
|
||||||
|
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.wxml')){fs.writeFileSync(p,fix(fs.readFileSync(p,'utf8')));count++}}}
|
||||||
|
walk(path.join(root,'weapp'))
|
||||||
|
console.log('Normalized WXML expressions in '+count+' templates')
|
||||||
17
tools/build/fix-template-expressions.cjs
Normal file
17
tools/build/fix-template-expressions.cjs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use strict'
|
||||||
|
const vm = require('vm')
|
||||||
|
// WePY 2 escapes quotes in :class/:style expressions as if they were string
|
||||||
|
// contents. WXML expects an expression here, so those escapes are invalid.
|
||||||
|
// Repair only expressions that fail parsing and become valid after removing
|
||||||
|
// that extra layer. Preserve valid strings (including escaped apostrophes).
|
||||||
|
function valid(expr) {
|
||||||
|
try { new vm.Script('(' + expr + ')'); return true } catch (_) { return false }
|
||||||
|
}
|
||||||
|
module.exports = function fixTemplates(markup) {
|
||||||
|
return markup.replace(/\{\{([\s\S]*?)\}\}/g, (whole, expr) => {
|
||||||
|
if (valid(expr) || !expr.includes("\\'")) return whole
|
||||||
|
const fixed = expr.replace(/\\'/g, "'")
|
||||||
|
if (!valid(fixed)) throw new Error('Unparseable generated WXML expression')
|
||||||
|
return '{{' + fixed + '}}'
|
||||||
|
})
|
||||||
|
}
|
||||||
33
tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
Normal file
33
tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
会员相关页面本地验收记录(2026-10-30)
|
||||||
|
|
||||||
|
范围:微信开发者工具模拟器,mp-local-api-build;不上传、不发布、不连接真实支付。
|
||||||
|
工具:Node 16.20.2、miniprogram-automator 0.13.0、微信开发者工具 2.02.2609240。
|
||||||
|
|
||||||
|
已核实:
|
||||||
|
1. 会员中心 purchaseEnabled=false;点击购买提示会员购买暂不可用。
|
||||||
|
2. 查询最近会员订单:无待查订单提示;合成 PENDING 保留待查记录;合成 FULFILLED 提示已到账并清除待查记录。
|
||||||
|
3. 未支持的合成订单显示查询失败,不访问真实服务。
|
||||||
|
4. 单项服务页空列表正常展示,点击显示暂不可购买;模拟请求失败显示可读说明。
|
||||||
|
5. 修复单项服务列表为空/索引越界及失败后 loading 未结束问题。
|
||||||
|
6. 修复单项服务关闭提示重叠、背景图片不可用时用户信息对比度不足;iOS 也显示关闭提示。
|
||||||
|
|
||||||
|
AppID:生成验收项目采用 wxc41491431733671e,与本机后端两个小程序 AppID 配置一致且 CLI 权限校验通过。
|
||||||
|
源项目仍为 wxe486777f4379507e(同时出现在商城跳转中),未改生产配置。正式应用身份仍应由管理平台核对。
|
||||||
|
早先 README 中 code 10 阻塞记录已被本次成功验收替代。
|
||||||
|
|
||||||
|
复现(工作目录 E:/pj/love,先加载 .local-acceptance/Enter-Environment.ps1):
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/build.cjs --api --appid=wxc41491431733671e
|
||||||
|
官方 CLI cache --clean compile --project E:/pj/love/.local-acceptance/mp-local-api-build
|
||||||
|
官方 CLI auto --project E:/pj/love/.local-acceptance/mp-local-api-build --auto-port 19420
|
||||||
|
等待模拟器编译完成后:
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/devtools-membership.test.cjs
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/pages.test.cjs
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/payment.test.cjs
|
||||||
|
最终上述构建、页面方法、支付逻辑测试退出 0;两仓库 git diff --check 通过(仅换行符警告)。
|
||||||
|
自动化初次重载存在超时/页面元信息错误;清理项目编译缓存并等待加载后重试成功。
|
||||||
|
|
||||||
|
证据:E:/pj/love/.local-acceptance/logs/membership-closed-click.png、order-empty.png、single-service-closed.png。
|
||||||
|
自动化通过元素 tap 操作页面;最终脚本观察 toast/modal 的参数并自动处理弹窗,截图用于布局检查。
|
||||||
|
订单状态来自专用 LOCAL-ACCEPTANCE-PENDING/FULFILLED mock,不代表平台回调或真实会员发放验收通过。
|
||||||
|
单项服务使用空列表合成数据,没有虚构商品权益或价格。正式商品图片和真实终端效果仍待业务验收。
|
||||||
|
本轮服务端未新增修改;保留两个 feature/virtual-payment-remediation 分支的已有变更,未提交、合并或发布。
|
||||||
10
tools/local-acceptance/Open-DevTools.ps1
Normal file
10
tools/local-acceptance/Open-DevTools.ps1
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
param([string]$CliPath = 'D:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat')
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$workspace = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||||
|
$workspace = Split-Path $workspace -Parent
|
||||||
|
$project = Join-Path $workspace '.local-acceptance\mp-local-api-build'
|
||||||
|
if (!(Test-Path -LiteralPath $CliPath)) { throw 'WeChat DevTools CLI not found; pass -CliPath.' }
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $project 'project.config.json'))) { throw 'Build the local acceptance project first.' }
|
||||||
|
# GPU flag takes effect only when launching a fresh IDE process. No cache deletion.
|
||||||
|
& $CliPath open --disable-gpu --project $project
|
||||||
|
# CLI can return exit 0 even on permission failure; inspect its printed result.
|
||||||
87
tools/local-acceptance/README.txt
Normal file
87
tools/local-acceptance/README.txt
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
最新状态:会员相关页面已完成模拟器验收,详见 MEMBERSHIP-ACCEPTANCE.txt。
|
||||||
|
重建请使用 --api --appid=wxc41491431733671e;以下旧 code 10 记录仅保留作排查历史。
|
||||||
|
|
||||||
|
福恋本地页面验收(2026-10-30)
|
||||||
|
|
||||||
|
空白窗口排查:官方 CLI quit 后以 open --disable-gpu 重启,已确认主进程
|
||||||
|
带禁用 GPU 参数;无项目 open 成功,辅助功能文本可读取完整项目列表。
|
||||||
|
截图仍超时,因此不声称已验证视觉恢复。CLI islogin 为 true,项目 AppID
|
||||||
|
与源配置一致,但普通 open 和 auto 均被 code 10 拒绝,页面尚未执行。
|
||||||
|
可用 Open-DevTools.ps1 打开本地项目;已有进程须先正常退出才应用 GPU 参数。
|
||||||
|
没有删除工具缓存、登录数据或修改权限。CLI 错误也可能退出 0,必须检查输出。
|
||||||
|
|
||||||
|
开发者工具自动化更新:用户授权本地 API 验收使用仓库已有 AppID。
|
||||||
|
重建使用 node .\ufutx_love_mp\tools\local-acceptance\build.cjs --api --source-appid。
|
||||||
|
该选项仅保留源项目 AppID,不改变接口隔离;不带该选项仍生成游客配置。
|
||||||
|
稳定版 2.02.2609240 的 CLI auto 拒绝 touristappid;使用源 AppID 后返回
|
||||||
|
code 10“登录用户不是该小程序的开发者”。需用户登录有权限的账号后继续。
|
||||||
|
重新构建、api.test.cjs、pages.test.cjs 及两仓库 diff --check 已通过;
|
||||||
|
页面渲染和模拟器点击验收仍未完成,数据方法测试不等同于界面验收。
|
||||||
|
|
||||||
|
新增真实本地 API 模式:导入 E:\pj\love\.local-acceptance\mp-local-api-build。
|
||||||
|
重建命令:node .\ufutx_love_mp\tools\local-acceptance\build.cjs --api
|
||||||
|
登录、资料读取/保存、用户状态和 DMA 请求转发到 http://127.0.0.1:18766;其他流程仍为 mock。
|
||||||
|
后端启动及范围见 love/tools/local-acceptance/README.txt。该模式仅适用本机开发者工具。
|
||||||
|
对应集成验证:node .\ufutx_love_mp\tools\local-acceptance\api.test.cjs
|
||||||
|
真实 API 网络失败不降级为模拟成功;纯离线模式仍按下文使用。
|
||||||
|
|
||||||
|
导入目录:E:\pj\love\.local-acceptance\mp-mock-build
|
||||||
|
在微信开发者工具中导入该目录的 project.config.json,使用测试号/游客模式。
|
||||||
|
不要导入原仓库或旧 mp-build 目录运行;它们仍是原有生产接口配置。
|
||||||
|
新包首屏应显示“福恋 · 本地模拟验收”,每页右上角有“本地模拟 · 合成数据”。
|
||||||
|
未出现这些标识则停止运行并核对导入目录。
|
||||||
|
本机未发现微信开发者工具安装记录,因此没有声称通过模拟器渲染或点击验收。
|
||||||
|
|
||||||
|
实现边界
|
||||||
|
这是在小程序 wx.request 边界注入的进程内 mock,不运行 Go 服务,也不伪造私有模块实现。
|
||||||
|
http://127.0.0.1:18765/go/api 是请求标识,由包内适配器处理,没有对应 HTTP 监听进程。
|
||||||
|
页面启动不依赖数据库、Docker、私有模块权限或互联网。
|
||||||
|
登录返回合成用户;API、IM、认证 SDK 均被隔离。真实支付、退款、短信、上传、外部跳转禁用。
|
||||||
|
未知接口明确提示“尚未模拟”,不会回退到原接口或返回假成功。
|
||||||
|
所有存储加 acceptance: 前缀;不使用真实 token,不清理原应用数据。
|
||||||
|
远程图片/字体引用使用本地占位资源;因此该包适合布局和基本交互验收,不能证明正式素材视觉一致。
|
||||||
|
会员价格、权益、认证标准、审批结果和权限规则均待核实,没有据名称生成。
|
||||||
|
|
||||||
|
建议验收
|
||||||
|
1. 从首屏进入首页:查看合成推荐用户、广场,点进用户详情,返回。
|
||||||
|
2. 打开“我的”、消息:查看测试用户,消息/订单为空态,不出现真实消息。
|
||||||
|
3. 首屏“编辑测试资料”:修改自我描述并保存,再打开应保持新内容。
|
||||||
|
4. 首屏“发布测试动态”:只输入文字,发布后到动态页查看;尝试点赞/取消点赞。
|
||||||
|
5. 首屏切换“请求失败场景”,再进入列表查看错误提示;回首屏关闭后重试。
|
||||||
|
6. 首屏“重置合成数据”恢复初始数据。重新编译返回首屏。
|
||||||
|
仅保存在开发者工具本地存储。测试注册验证码为 000000,没有发送短信。
|
||||||
|
照片/视频上传、真实 IM 互通、认证和购买不在可验收流程内;不会模拟支付成功或授予权益。
|
||||||
|
|
||||||
|
重建当前工作区修改
|
||||||
|
PowerShell 工作目录:E:\pj\love
|
||||||
|
. .\.local-acceptance\Enter-Environment.ps1
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\build.cjs
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\test.cjs
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\pages.test.cjs
|
||||||
|
任一命令失败即停止;不要使用旧包掩盖构建失败。
|
||||||
|
构建只覆盖 mp-mock-build/src、weapp 等专用生成文件,依赖复用已禁用安装脚本的独立 node_modules。
|
||||||
|
不修改业务 src、原 project.config.json、依赖锁文件或正常 build 脚本。
|
||||||
|
构建日志:.local-acceptance/logs/mp-mock-build.log;产物 manifest 记录版本与摘要。
|
||||||
|
此包禁止 preview/deploy、上传或发布;它不是服务端回归或上线依据。
|
||||||
|
|
||||||
|
验证范围
|
||||||
|
test.cjs:覆盖登录、分页、资料保存、发布/点赞、状态持久化、
|
||||||
|
错误场景、未知接口拒绝、越权字段不写入、外部 URL 拦截、支付/上传/IM 传输拦截、存储隔离。
|
||||||
|
pages.test.cjs:执行实际页面脚本的数据加载方法,覆盖首页/动态/消息/我的/用户详情/编辑;
|
||||||
|
不是模拟器测试,不执行海报组件、页面渲染或真实 SDK。
|
||||||
|
另检查所有生成 JavaScript 语法、入口顺序、本地验收首页配置。
|
||||||
|
后续接回真实服务端仍需私有模块权限、完整 schema、受限测试配置与独立业务回归。
|
||||||
|
|
||||||
|
本次执行结果
|
||||||
|
Node 16.20.2 / npm 8.19.4,工具均位于工作区 .local-acceptance/tools。
|
||||||
|
build.cjs 首次编译子进程被沙箱限制;获工具授权后编译成功,退出 0。
|
||||||
|
pages.test.cjs 首次因测试桩缺少 systemInfo 失败,补齐测试桩后通过。
|
||||||
|
最终构建、test.cjs、pages.test.cjs 均退出 0;101 个生成 JS 文件语法检查通过。
|
||||||
|
两个仓库 diff --check 通过;服务端无新增修改;小程序新增 tools/local-acceptance。
|
||||||
|
小程序位于 feature/local-acceptance-mock,未提交、推送、合并或发布;保留原有文档修改。
|
||||||
|
|
||||||
|
2026-10-30 预览 WXML 错误修复:
|
||||||
|
导航组件生成模板第 21 行含非法转义引号;根因是旧版 WePY :class/:style 表达式转义。
|
||||||
|
本地构建新增 fix-template-expressions.cjs:仅修复解析失败且去除额外单引号转义后可解析的表达式,保留合法字符串转义。
|
||||||
|
重建 mp-mock-build 完成。templates.test.cjs 使用开发工具自带 wcc.exe 离线编译全部 87 个模板,退出 0;转义回归、102 个 JS 语法检查及隔离测试通过。
|
||||||
|
未执行预览上传;预览码生成结果仍由用户重新点击预览验证。本次未修改业务源码或生产配置。
|
||||||
28
tools/local-acceptance/api.test.cjs
Normal file
28
tools/local-acceptance/api.test.cjs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),http=require('http'),fs=require('fs'),path=require('path'),vm=require('vm')
|
||||||
|
const {install,ORIGIN}=require('./runtime')
|
||||||
|
const store={},transport=[]
|
||||||
|
const wx={getStorageSync:k=>store[k],setStorageSync:(k,v)=>store[k]=v,removeStorageSync:k=>delete store[k],getStorageInfoSync:()=>({keys:Object.keys(store)}),showToast(){},request(options){
|
||||||
|
const url=new URL(options.url);assert.equal(url.origin,'http://127.0.0.1:18766');transport.push(url.pathname)
|
||||||
|
const body=options.data?JSON.stringify(options.data):''
|
||||||
|
const request=http.request(url,{method:options.method||'GET',headers:{...(options.header||{}),'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},res=>{let raw='';res.on('data',data=>raw+=data);res.on('end',()=>{const r={statusCode:res.statusCode,data:JSON.parse(raw),errMsg:'request:ok'};if(options.success)options.success(r);if(options.complete)options.complete(r)})})
|
||||||
|
request.setTimeout(8000,()=>request.destroy(Error('Local timeout')));request.on('error',e=>{if(options.fail)options.fail(e)});request.end(body);return {abort:()=>request.destroy()}
|
||||||
|
}}
|
||||||
|
const local=install(wx,{apiOrigin:'http://127.0.0.1:18766'})
|
||||||
|
const request=(route,method='GET',data)=>new Promise((resolve,reject)=>wx.request({url:ORIGIN+route,method,data,header:{Authorization:'Bearer '+(wx.getStorageSync('token')||'')},success:r=>resolve(r.data),fail:reject}))
|
||||||
|
async function main(){
|
||||||
|
let r=await request('/login','POST',{code:'LOCAL-MOCK-CODE'});assert.equal(r.code,0);wx.setStorageSync('token',r.data.token)
|
||||||
|
r=await request('/user/profile');assert.equal(r.code,0);const previous=r.data.introduction
|
||||||
|
try{assert.equal((await request('/user/other/profie','PUT',{introduction:'LOCAL_BRIDGE_CHECK'})).code,0);assert.equal((await request('/user/profile')).data.introduction,'LOCAL_BRIDGE_CHECK')}
|
||||||
|
finally{await request('/user/other/profie','PUT',{introduction:previous})}
|
||||||
|
assert.equal((await request('/user/status')).data.is_base_profile,true)
|
||||||
|
assert.equal((await request('/dma/carousel/list')).code,0)
|
||||||
|
const before=transport.length;assert.equal((await request('/moment/list')).code,0);assert.equal((await request('/order/refund','POST',{})).code,1);assert.equal(transport.length,before)
|
||||||
|
local.scenario('failure',true);assert.equal((await request('/user/profile')).code,1);assert.equal(transport.length,before);local.scenario('failure',false)
|
||||||
|
const output=path.resolve(__dirname,'../../../.local-acceptance/mp-local-api-build/weapp')
|
||||||
|
let files=0;function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.js')){new vm.Script(fs.readFileSync(p,'utf8'),{filename:p});files++}}}walk(output)
|
||||||
|
const app=fs.readFileSync(path.join(output,'app.js'),'utf8');assert(app.startsWith("require('./acceptance-runtime').install(wx, {apiOrigin:'http://127.0.0.1:18766'});"))
|
||||||
|
assert.equal(fs.readFileSync(path.join(output,'acceptance-runtime.js'),'utf8'),fs.readFileSync(path.join(__dirname,'runtime.js'),'utf8'))
|
||||||
|
console.log('PASS: mini-program adapter -> local gateway -> actual Go API -> MySQL read/write/read-back; mock-only and blocked routes never use transport; '+files+' generated JS files parse.')
|
||||||
|
}
|
||||||
|
main().catch(error=>{console.error('FAIL: '+error.message);process.exitCode=1})
|
||||||
61
tools/local-acceptance/build.cjs
Normal file
61
tools/local-acceptance/build.cjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use strict'
|
||||||
|
const fs=require('fs'), path=require('path'), cp=require('child_process')
|
||||||
|
const repo=path.resolve(__dirname,'../..'), workspace=path.dirname(repo)
|
||||||
|
const apiMode=process.argv.includes('--api')
|
||||||
|
const appidArg=process.argv.find(arg=>arg.startsWith('--appid='))
|
||||||
|
const localAppid=appidArg && appidArg.slice(8)
|
||||||
|
if(appidArg && !/^wx[0-9a-f]{16}$/.test(localAppid)) throw Error('Invalid local AppID')
|
||||||
|
const root=path.join(workspace,'.local-acceptance'), build=path.join(root,apiMode?'mp-local-api-build':'mp-mock-build')
|
||||||
|
const modules=path.join(root,'mp-build','node_modules')
|
||||||
|
if(!fs.existsSync(path.join(modules,'@wepy/cli/bin/wepy.js'))) throw Error('Run the isolated dependency installation first')
|
||||||
|
fs.mkdirSync(build,{recursive:true})
|
||||||
|
// Only remove this tool's generated directories, never source or another dependency tree.
|
||||||
|
for(const name of ['src','weapp']) {const target=path.resolve(build,name);if(!target.startsWith(build+path.sep))throw Error('Unsafe target');fs.rmSync(target,{recursive:true,force:true})}
|
||||||
|
fs.cpSync(path.join(repo,'src'),path.join(build,'src'),{recursive:true})
|
||||||
|
for(const name of ['package.json','package-lock.json','wepy.config.js'])fs.copyFileSync(path.join(repo,name),path.join(build,name))
|
||||||
|
if(!fs.existsSync(path.join(build,'node_modules')))fs.symlinkSync(modules,path.join(build,'node_modules'),'junction')
|
||||||
|
function walk(dir,fn){for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,entry.name);if(entry.isDirectory())walk(p,fn);else fn(p)}}
|
||||||
|
// Static media is local. No remote pictures, fonts, embedded web pages or SDK destinations remain.
|
||||||
|
const urlPattern=/(?:https?|wss?):\/\/[^\s"'`<>\\)]+/gi
|
||||||
|
let replaced=0
|
||||||
|
walk(path.join(build,'src'),p=>{if(!/\.(js|wpy|wxss|less|json|wxml)$/.test(p))return;let text=fs.readFileSync(p,'utf8');text=text.replace(urlPattern,()=>{replaced++;return '/images/tabBar/my.png'});text=text.replace(/<web-view\b[^>]*>(?:<\/web-view>)?/g,'<view>本地模拟:外部网页已禁用</view>');fs.writeFileSync(p,text)})
|
||||||
|
// Replace just the two host assignments in the copied config, with a hard assertion on source shape.
|
||||||
|
const config=path.join(build,'src/config.js');let text=fs.readFileSync(config,'utf8');let hosts=0
|
||||||
|
text=text.replace(/^(host|hostGo) = '[^']*'.*$/gm,(_,name)=>{hosts++;return name+" = 'http://127.0.0.1:18765/go/api'"})
|
||||||
|
if(hosts!==2)throw Error('Expected exactly two active API host assignments')
|
||||||
|
fs.writeFileSync(config,text)
|
||||||
|
// The legacy error branch throws after rejecting. In the acceptance copy, unsupported mocks
|
||||||
|
// should show the existing modal and reject normally, not crash the simulator callback.
|
||||||
|
const requests=path.join(build,'src/mixins/https.js')
|
||||||
|
fs.writeFileSync(requests,fs.readFileSync(requests,'utf8').replace(/throw errorMessage/g,'return'))
|
||||||
|
fs.copyFileSync(path.join(__dirname,'im.js'),path.join(build,'src/utils/im.js'))
|
||||||
|
fs.writeFileSync(path.join(build,'src/mp_ecard_sdk/main.js'),"export function initEid() {}\nexport function startEid() { wx.showModal({title:'本地模拟',content:'实名认证未执行,认证状态不会改变',showCancel:false}) }\n")
|
||||||
|
const result=cp.spawnSync(process.execPath,[path.join(modules,'@wepy/cli/bin/wepy.js'),'build','--no-cache'],{cwd:build,env:{...process.env,NODE_ENV:'production'},encoding:'utf8',maxBuffer:20*1024*1024})
|
||||||
|
fs.mkdirSync(path.join(root,'logs'),{recursive:true});fs.writeFileSync(path.join(root,'logs/mp-mock-build.log'),(result.stdout||'')+(result.stderr||''))
|
||||||
|
if(result.status!==0)throw Error('WePY build failed: '+(result.error?result.error.code:result.status)+'; inspect local log (redact before sharing)')
|
||||||
|
const output=path.join(build,'weapp')
|
||||||
|
const fixTemplates=require('./fix-template-expressions.cjs')
|
||||||
|
walk(output,p=>{if(p.endsWith('.wxml'))fs.writeFileSync(p,fixTemplates(fs.readFileSync(p,'utf8')))})
|
||||||
|
fs.copyFileSync(path.join(__dirname,'runtime.js'),path.join(output,'acceptance-runtime.js'))
|
||||||
|
const appFile=path.join(output,'app.js');fs.writeFileSync(appFile,"require('./acceptance-runtime').install(wx"+(apiMode?", {apiOrigin:'http://127.0.0.1:18766'}":"")+");\n"+fs.readFileSync(appFile,'utf8'))
|
||||||
|
const appPath=path.join(output,'app.json');const app=JSON.parse(fs.readFileSync(appPath));app.pages.unshift('local-acceptance/index');app.window.navigationBarTitleText='本地模拟验收';fs.writeFileSync(appPath,JSON.stringify(app,null,2))
|
||||||
|
const panel=path.join(output,'local-acceptance');fs.mkdirSync(panel,{recursive:true})
|
||||||
|
fs.writeFileSync(path.join(panel,'index.json'),JSON.stringify({navigationBarTitleText:'本地模拟验收'}))
|
||||||
|
fs.writeFileSync(path.join(panel,'index.wxml'),'<view style="padding:40rpx"><view style="font-size:40rpx;font-weight:bold">福恋 · 本地模拟验收</view><view style="margin:30rpx 0">合成数据,不访问后端或第三方服务。远程图片已替换,外部字体可能与正式版不同。支付、认证、上传不属于本轮可验收范围。</view><button bindtap="enter">进入首页</button><button bindtap="membership">会员购买关闭验收</button><button bindtap="edit">编辑测试资料</button><button bindtap="publish">发布测试动态</button><button bindtap="reset">重置合成数据</button><button bindtap="failure">切换请求失败场景:{{failed}}</button><view>重新编译回到此入口。测试注册验证码:000000。不要输入真实个人资料。</view></view>')
|
||||||
|
fs.writeFileSync(path.join(panel,'index.js'),`Page({data:{failed:false},onShow(){this.setData({failed:wx.__loveAcceptance.snapshot().failure})},enter(){wx.switchTab({url:'/pages/tabBar/home'})},membership(){wx.navigateTo({url:'/pages/users/upgradeVIP'})},edit(){wx.navigateTo({url:'/pages/users/selfTextarea?text=自我描述'})},publish(){wx.navigateTo({url:'/pages/dynamic/issue'})},reset(){wx.__loveAcceptance.reset();wx.reLaunch({url:'/local-acceptance/index'})},failure(){wx.__loveAcceptance.scenario('failure',!this.data.failed);this.onShow()}})`)
|
||||||
|
// Permanent visual marker on every generated page, including subpackages.
|
||||||
|
walk(output,p=>{if(p.endsWith('.wxml')){let markup=fs.readFileSync(p,'utf8').replace(/open-type="[^"]*"/g,'disabled="true" data-local-disabled="true"');fs.writeFileSync(p,markup+'\n<view style="position:fixed;right:8rpx;top:8rpx;z-index:2147483647;background:#fff1b8;color:#613400;padding:4rpx 12rpx;font-size:20rpx;pointer-events:none">本地模拟 · 合成数据</view>\n')}})
|
||||||
|
const project=JSON.parse(fs.readFileSync(path.join(repo,'project.config.json')))
|
||||||
|
if(!process.argv.includes('--source-appid')) project.appid='touristappid';if(localAppid)project.appid=localAppid;project.projectname=apiMode?'福恋-本地API验收':'福恋-本地模拟验收';project.setting={...project.setting,urlCheck:!apiMode};delete project.packOptions
|
||||||
|
fs.writeFileSync(path.join(build,'project.config.json'),JSON.stringify(project,null,2))
|
||||||
|
const testPackage=JSON.parse(fs.readFileSync(path.join(build,'package.json')))
|
||||||
|
testPackage.private=true
|
||||||
|
testPackage.scripts={build:'node ../../ufutx_love_mp/tools/local-acceptance/build.cjs'+(apiMode?' --api':'')+(process.argv.includes('--source-appid')?' --source-appid':'')+(localAppid?' --appid='+localAppid:''),test:'node ../../ufutx_love_mp/tools/local-acceptance/test.cjs'}
|
||||||
|
fs.writeFileSync(path.join(build,'package.json'),JSON.stringify(testPackage,null,2))
|
||||||
|
// Built vendor code can contain dormant SDK URLs. Scrub them too; runtime denies all native transport.
|
||||||
|
walk(output,p=>{if(!/\.(js|wxml|wxss|json)$/.test(p)||p.endsWith('acceptance-runtime.js'))return;let t=fs.readFileSync(p,'utf8');t=t.replace(urlPattern,url=>url.startsWith('http://127.0.0.1:18765/')||url==='http://127.0.0.1:18766'?url:'/images/tabBar/my.png');fs.writeFileSync(p,t)})
|
||||||
|
if(apiMode){const p=path.join(panel,'index.wxml');fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace('福恋 · 本地模拟验收','福恋 · 本地 API 验收').replace('合成数据,不访问后端或第三方服务。','登录、资料读取/保存与 DMA 走本机真实 Go API;其他流程仍为 mock。').replace('重置合成数据','重置 mock 数据(不重置数据库)'))}
|
||||||
|
fs.writeFileSync(path.join(build,'ACCEPTANCE-ONLY.txt'),'LOCAL MOCK ONLY. Never upload or publish this project. Generated from the current working tree. See repository tools/local-acceptance/README.txt.\n')
|
||||||
|
const crypto=require('crypto')
|
||||||
|
fs.writeFileSync(path.join(build,'acceptance-manifest.json'),JSON.stringify({builtAt:new Date().toISOString(),node:process.version,mode:apiMode?'local-api-with-mock-fallbacks':'offline-in-process-mock',source:repo,privateGoModulesUsed:apiMode,lockSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(repo,'package-lock.json'))).digest('hex'),runtimeSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'runtime.js'))).digest('hex')},null,2))
|
||||||
|
console.log(JSON.stringify({build,staticUrlsReplaced:replaced,mode:apiMode?'local-api-with-mock-fallbacks':'offline-in-process-mock',privateGoModulesUsed:apiMode}))
|
||||||
34
tools/local-acceptance/devtools-membership.test.cjs
Normal file
34
tools/local-acceptance/devtools-membership.test.cjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const path=require('path'),assert=require('assert');
|
||||||
|
const root=path.resolve(__dirname,'../../../.local-acceptance');
|
||||||
|
const auto=require(path.join(root,'automation/node_modules/miniprogram-automator'));
|
||||||
|
const timer=setTimeout(()=>{console.error('Timeout');process.exit(2)},55000);
|
||||||
|
(async()=>{const mp=await auto.connect({wsEndpoint:'ws://127.0.0.1:19420'});try{
|
||||||
|
await mp.evaluate(()=>{wx.__acceptanceMessages=[];if(!wx.__savedToast)wx.__savedToast=wx.showToast;if(!wx.__savedModal)wx.__savedModal=wx.showModal;wx.showToast=function(o){wx.__acceptanceMessages.push(o.title);return undefined};wx.showModal=function(o){wx.__acceptanceMessages.push(o.content);if(o.success)o.success({confirm:true,cancel:false})}});
|
||||||
|
let p=await mp.navigateTo('/pages/users/upgradeVIP');await p.waitFor(1200);
|
||||||
|
assert.strictEqual(await p.data('purchaseEnabled'),false);await (await p.$('.payBtn')).tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/membership-closed-click.png')});
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.removeStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/order-empty.png')});
|
||||||
|
for(const state of ['PENDING','FULFILLED']){
|
||||||
|
await mp.evaluate(s=>{const u=wx.getStorageSync('userInfo')||{};wx.setStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'),'LOCAL-ACCEPTANCE-'+s)},state);
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(500);
|
||||||
|
const stored=await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};return wx.getStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
assert.equal(!!stored,state==='PENDING');
|
||||||
|
}
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.setStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'),'LOCAL-ACCEPTANCE-UNKNOWN')});
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(500);
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.removeStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
p=await mp.navigateTo('/pages/users/upgradeVIP2');await p.waitFor(1000);
|
||||||
|
assert.equal((await p.data('rankList')).length,0);assert.match(await p.data('specialVIP.discount_desc'),/暂无/);
|
||||||
|
const b=await p.$('.payBtn');assert(b,'Single-service closed button missing');await b.tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/single-service-closed.png')});
|
||||||
|
await mp.evaluate(()=>wx.__loveAcceptance.scenario('failure',true));
|
||||||
|
const failed=await mp.navigateTo('/pages/users/upgradeVIP2');await failed.waitFor(800);
|
||||||
|
assert.match(await failed.data('specialVIP.discount_desc'),/加载失败/);
|
||||||
|
await mp.evaluate(()=>wx.__loveAcceptance.scenario('failure',false));
|
||||||
|
await mp.navigateBack();
|
||||||
|
const messages=await mp.evaluate(()=>wx.__acceptanceMessages);
|
||||||
|
for(const expected of ['会员购买暂不可用','暂无待查询订单','支付结果仍在确认中','会员权益已到账','单项服务暂不可购买'])assert(messages.some(s=>s.includes(expected)),expected);
|
||||||
|
console.log(JSON.stringify({passed:true,messages:[...new Set(messages)]}));
|
||||||
|
}finally{await mp.evaluate(()=>{wx.__loveAcceptance.scenario('failure',false);if(wx.__savedToast)wx.showToast=wx.__savedToast;if(wx.__savedModal)wx.showModal=wx.__savedModal});await mp.disconnect();clearTimeout(timer)}})().catch(e=>{console.error(e.message);clearTimeout(timer);process.exitCode=1});
|
||||||
2
tools/local-acceptance/fix-template-expressions.cjs
Normal file
2
tools/local-acceptance/fix-template-expressions.cjs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
'use strict'
|
||||||
|
module.exports = require('../build/fix-template-expressions.cjs')
|
||||||
13
tools/local-acceptance/im.js
Normal file
13
tools/local-acceptance/im.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Local-only SDK substitute. Never imports the real IM SDK.
|
||||||
|
export function IM() {
|
||||||
|
const send = options => {
|
||||||
|
const msg = {idClient: 'local-'+Date.now(), time: Date.now(), from: '900000001', to: options.to, body: options.body || '', type: 'text', scene: options.scene}
|
||||||
|
if (options.onSendBefore) options.onSendBefore(msg)
|
||||||
|
return Promise.resolve(msg)
|
||||||
|
}
|
||||||
|
const unsupported = () => Promise.reject(new Error('本地模拟:媒体上传已禁用'))
|
||||||
|
return {on(){},off(){},connect(){return Promise.resolve()},disconnect(){return Promise.resolve()},
|
||||||
|
msg: {sendTextMsg:send,sendImageMsg:unsupported,sendVideoMsg:unsupported,sendAudioMsg:unsupported,sendMsgReceipt:()=>Promise.resolve()},
|
||||||
|
session:{resetSessionUnreadCount:()=>Promise.resolve(),getSession:()=>Promise.resolve({unread:0})},
|
||||||
|
user:{getUsersNameCardFromServer:()=>Promise.resolve([])},team:{updateMyMemberInfo:()=>Promise.resolve()}}
|
||||||
|
}
|
||||||
43
tools/local-acceptance/pages.test.cjs
Normal file
43
tools/local-acceptance/pages.test.cjs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
'use strict'
|
||||||
|
// Executes actual page data-loading methods with the local contract. No UI renderer or app startup.
|
||||||
|
const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert')
|
||||||
|
const repo=path.resolve(__dirname,'../..'), modules=path.resolve(repo,'../.local-acceptance/mp-build/node_modules')
|
||||||
|
const babel=require(path.join(modules,'@babel/core'))
|
||||||
|
const {createMock,ORIGIN}=require('./runtime');const cache={};const mock=createMock({get:k=>cache[k],set:(k,v)=>cache[k]=v})
|
||||||
|
const user=mock.request(ORIGIN+'/login','POST',{}).data
|
||||||
|
const app={$wepy:{$options:{globalData:{systemInfo:{system:'Windows 10'}}}}}
|
||||||
|
const storage={userInfo:{...user.user,is_base_profile:true,name:user.user.nickname},adv:user.adv}
|
||||||
|
const wx=new Proxy({getStorageSync:k=>storage[k],setStorageSync:(k,v)=>storage[k]=v},{get:(o,k)=>o[k]||(()=>{})})
|
||||||
|
const errors=[]
|
||||||
|
function page(file){
|
||||||
|
let definition
|
||||||
|
const content=fs.readFileSync(path.join(repo,'src',file),'utf8').match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||||
|
const code=babel.transformSync(content,{babelrc:false,configFile:false,plugins:[require(path.join(modules,'@babel/plugin-transform-modules-commonjs'))]}).code
|
||||||
|
const requireMock=id=>{
|
||||||
|
if(id==='@wepy/core')return {page:opts=>definition=opts}
|
||||||
|
if(id.includes('config'))return {service:{host:ORIGIN,user:ORIGIN+'/user'}}
|
||||||
|
if(id.includes('plugins'))return {commentTimeHandle:s=>s,formatRemainTimeData:()=>({d:0,h:0,m:0,s:0})}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
vm.runInNewContext(code,{require:requireMock,exports:{},module:{exports:{}},wx,getApp:()=>app,console:{log(...args){if(args.some(x=>x&&x.stack))errors.push('caught page exception')},error(){errors.push('page error')}},setTimeout(){},clearTimeout(){},setInterval(){},clearInterval(){}})
|
||||||
|
const instance=Object.assign({},JSON.parse(JSON.stringify(definition.data||{})),{$app:app.$wepy,$nextTick(){},$showLoading(){},$showToast(){},$signInVerify:()=>true})
|
||||||
|
for(const [key,fn] of Object.entries(definition.methods||{}))instance[key]=fn.bind(instance)
|
||||||
|
for(const [key,method] of [['$get','GET'],['$post','POST'],['$put','PUT'],['$delete','DELETE']])instance[key]=({url,data})=>{const r=mock.request(url,method,data);if(r.code!==0){errors.push('unsupported page request: '+url.slice(ORIGIN.length));return Promise.reject(Error(r.message))}return Promise.resolve(r)}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
const tick=()=>new Promise(resolve=>setImmediate(resolve))
|
||||||
|
async function run(){
|
||||||
|
const home=page('pages/tabBar/home.wpy');for(const name of ['getTopUser','getArea','getEveryday','getSquare','applyTopUpState','getBannerList']){home[name]();await tick()}
|
||||||
|
assert.equal(home.topList.length,3);assert.equal(home.squareList.length,3);assert.equal(home.referrals.length,4)
|
||||||
|
const dynamic=page('pages/tabBar/dynamic.wpy');dynamic.getList();dynamic.getHotTopics();await tick();assert.equal(dynamic.dynamicList.length,1)
|
||||||
|
const news=page('pages/tabBar/news.wpy');news.getList();news.getNewCount();await tick();assert.equal(news.menuList[0].count,0)
|
||||||
|
const mine=page('pages/tabBar/user.wpy');mine.getIsBaseInfo();mine.initPageData();await tick();assert.equal(mine.user.id,900000001)
|
||||||
|
const other=page('pages/home/information.wpy');other.id=900000002;other.getDetail();await tick()
|
||||||
|
const editor=page('pages/users/selfTextarea.wpy');editor.text='自我描述';editor.getuser();await tick();assert.equal(editor.value,mock.snapshot().profile.introduction)
|
||||||
|
const service=page('pages/users/upgradeVIP2.wpy');service.initPageData();await tick();assert.equal(service.rankList.length,0);assert.match(service.specialVIP.discount_desc,/暂无可展示/)
|
||||||
|
for(const state of ['PENDING','FULFILLED'])assert.equal(mock.request(ORIGIN+'/order/virtual/LOCAL-ACCEPTANCE-'+state+'/confirm','POST',{}).data.state,state)
|
||||||
|
assert.notEqual(mock.request(ORIGIN+'/order/virtual/REAL-ORDER/confirm','POST',{}).code,0)
|
||||||
|
assert.deepEqual(errors,[])
|
||||||
|
console.log('PASS: actual home/dynamic/news/user/detail/editor data methods consume local mock contracts without recorded exceptions. Rendering not tested.')
|
||||||
|
}
|
||||||
|
run().catch(e=>{console.error(e);process.exitCode=1})
|
||||||
24
tools/local-acceptance/payment.test.cjs
Normal file
24
tools/local-acceptance/payment.test.cjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use strict'
|
||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert')
|
||||||
|
const source=fs.readFileSync('ufutx_love_mp/src/utils/virtual-payment.js','utf8').replace(/export /g,'')
|
||||||
|
const context={setTimeout:fn=>fn(),Promise,Error};vm.createContext(context);vm.runInContext(source+';this.buy=buyMembership;',context)
|
||||||
|
let payments=0,posts=[],state='FULFILLED',enabled=true,mismatch=false,cancel=false
|
||||||
|
const wx={getSystemInfoSync:()=>({platform:'android',system:'Android 14',version:'8.0.70'}),canIUse:()=>true,login:o=>o.success({code:'synthetic-code'}),requestVirtualPayment:o=>{payments++;cancel?o.fail({errMsg:'requestVirtualPayment:fail cancel'}):o.success({})}}
|
||||||
|
const api={get:async()=>({enabled,products:[{sub_rank_id:1,product_id:'synthetic',price_fen:100}]}),post:async(path,data)=>{posts.push(path);return path==='/rank/buy/virtual'?{order_no:'local-order',sign_data:JSON.stringify({goodsPrice:mismatch?101:100,buyQuantity:1,productId:'synthetic',outTradeNo:'local-order'}),mode:'short_series_goods',paySig:'synthetic',signature:'synthetic'}:{state}}}
|
||||||
|
;(async()=>{
|
||||||
|
enabled=false;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/暂不可用/);assert.equal(posts.length,0);assert.equal(payments,0)
|
||||||
|
enabled=true;let order;await context.buy(wx,api,1,100,n=>order=n);assert.equal(order,'local-order');assert.equal(payments,1)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ios',system:'iOS 18',version:'8.0.70'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,2)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ios',system:'iOS 14',version:'8.0.70'});await assert.rejects(context.buy(wx,api,1,100,()=>{}),/升级/);assert.equal(payments,2)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'windows',system:'Windows 11',version:'4.0'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,3)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ohos',system:'HarmonyOS',version:'8.0.70'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,4)
|
||||||
|
mismatch=true;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/不一致/);assert.equal(payments,4);mismatch=false
|
||||||
|
await assert.rejects(context.buy(wx,api,1,200,()=>{}),/价格已更新/)
|
||||||
|
cancel=true;const cancelStart=posts.length;let cancelledOrder;await assert.rejects(context.buy(wx,api,1,100,n=>cancelledOrder=n),/取消/);cancel=false
|
||||||
|
assert.equal(cancelledOrder,'local-order','cancel must preserve order for later reconciliation')
|
||||||
|
assert.deepEqual(posts.slice(cancelStart),['/rank/buy/virtual'],'cancel must not invoke fulfilment or confirmation')
|
||||||
|
state='PENDING';const pendingStart=posts.length;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/确认中/)
|
||||||
|
assert.equal(posts.slice(pendingStart).filter(p=>p.endsWith('/confirm')).length,5,'pending query must stop after bounded retries')
|
||||||
|
assert(!posts.some(p=>p==='/rank/buy' || p.includes('/callback')))
|
||||||
|
console.log('PASS: closed purchase, Android/iOS/Windows/HarmonyOS unified virtual pay, unsupported iOS, changed price/order mismatch, cancellation, pending confirmation; no legacy payment callback')
|
||||||
|
})().catch(e=>{console.error(e.message);process.exitCode=1})
|
||||||
129
tools/local-acceptance/runtime.js
Normal file
129
tools/local-acceptance/runtime.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
'use strict'
|
||||||
|
// This file is injected ONLY into the dedicated acceptance build, before app imports.
|
||||||
|
// No HTTP client, SDK, database or production credentials are used.
|
||||||
|
const ORIGIN = 'http://127.0.0.1:18765/go/api'
|
||||||
|
const IMAGE = '/images/tabBar/my.png'
|
||||||
|
const KEY = 'love.acceptance.v1'
|
||||||
|
const clone = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const ok = data => ({code: 0, message: '本地模拟数据', data})
|
||||||
|
const denied = message => ({code: 1, message: '本地模拟:' + message, data: null})
|
||||||
|
function initial() {
|
||||||
|
const user = {id: 900000001, nickname: '本地测试用户', name: '本地测试用户', photo: IMAGE,
|
||||||
|
mobile: '00000000000', sex: 1, type: 'single', rank_id: 0, is_rank: 0,
|
||||||
|
is_approved: 0, is_real_approved: 0, is_educate_approved: 0, hidden_profile: 'NONE',
|
||||||
|
create_time: '2026-10-30 00:00:00', city: '测试城市', resident_city: '测试城市',
|
||||||
|
year: '95年', birthday: '1995-01-01', stature: 170, weight: 60, degree: '待核实', industry_sub: '测试职业'}
|
||||||
|
return {user, profile: {introduction: '这是合成测试资料,可编辑保存。', ideal_mate: '仅供页面验收',
|
||||||
|
interest_hobby: '阅读', interest_label: ['阅读'], city_list_claim: '', mate_conditon: []},
|
||||||
|
complete: true, failure: false, nextId: 1002, follows: [],
|
||||||
|
moments: [{id: 1001, user_id: user.id, user, content: '本地测试动态:可查看、发布和点赞。',
|
||||||
|
images: [], photos: [], pics: [], type: 'moment', is_like: 0, like_count: 0, comment_count: 0,
|
||||||
|
create_time: '2026-10-30 00:00:00', created_at: '2026-10-30 00:00:00', topics: []}], comments: {}}
|
||||||
|
}
|
||||||
|
function createMock(storage) {
|
||||||
|
let state = storage.get(KEY) || initial()
|
||||||
|
const save = () => storage.set(KEY, clone(state))
|
||||||
|
const people = () => [2, 3, 4].map(n => Object.assign({}, state.user, {id: 900000000+n, nickname: '合成用户'+n, sex: n % 2 + 1}))
|
||||||
|
function page(rows, params) { return {data: Number(params.page || 1) > 1 ? [] : rows, total: rows.length, total_pages: 1, current_page: Number(params.page || 1), last_page: 1} }
|
||||||
|
function request(url, method, input) {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith(ORIGIN + '/')) return denied('已阻断非本地接口')
|
||||||
|
const path = url.slice(ORIGIN.length).split('?')[0]
|
||||||
|
const params = Object.assign({}, input || {})
|
||||||
|
;(url.split('?')[1] || '').split('&').filter(Boolean).forEach(pair => {const i=pair.indexOf('='); params[decodeURIComponent(i<0?pair:pair.slice(0,i))]=decodeURIComponent(i<0?'':pair.slice(i+1))})
|
||||||
|
method = (method || 'GET').toUpperCase()
|
||||||
|
if (state.failure && path !== '/login') return denied('模拟请求失败;请在验收入口关闭失败场景')
|
||||||
|
let result
|
||||||
|
const profile = () => Object.assign({}, state.user, state.profile)
|
||||||
|
const detail = user => ({user, base_profile: user, other_profile: clone(state.profile), profile_photos: [IMAGE],
|
||||||
|
approve_profile: {is_approved: 0, is_real_approved: 0, is_educate_approved: 0},
|
||||||
|
rank_profile: {rank_id: 0, deadline: ''}, share_qrcode: IMAGE, self_other_info: 0, self_is_real_approved: 0,
|
||||||
|
is_follow: state.follows.includes(user.id), follow_count: state.follows.length, friend_count: 0})
|
||||||
|
if (path === '/login' && method === 'POST') result = {token: 'LOCAL-MOCK-NOT-A-REAL-TOKEN', openid: 'LOCAL-MOCK', user: state.user,
|
||||||
|
im_user: {accid: 'local-user', token: 'LOCAL-MOCK'}, adv: {id:0}, is_servicer: 0, is_moment_admin: 0,
|
||||||
|
is_base_profile: state.complete, is_compeleted_profile: state.complete}
|
||||||
|
else if (path === '/user/status') result = {is_base_profile: state.complete, is_complete_profile: state.complete, is_photo_audited: 0}
|
||||||
|
else if (path === '/user/center') result = detail(state.user)
|
||||||
|
else if (path === '/user/profile' && method === 'GET') result = profile()
|
||||||
|
else if (['/user/other/profie', '/user/register/profile', '/user/info', '/profile'].includes(path) && ['POST','PUT'].includes(method)) {
|
||||||
|
// Only editable fixture fields; never accept privilege, identity, payment or approval fields.
|
||||||
|
;['introduction','ideal_mate','interest_hobby','city_list_claim'].forEach(k => {if(typeof params[k]==='string') state.profile[k]=params[k]})
|
||||||
|
;['nickname','city','resident_city','birthday','industry_sub'].forEach(k => {if(typeof params[k]==='string') state.user[k]=params[k]})
|
||||||
|
if(path === '/user/register/profile') {if(params.code !== '000000') return denied('测试验证码为 000000'); state.complete=true}
|
||||||
|
result=profile()
|
||||||
|
} else if (path === '/register/sms/code' && method === 'POST') result={mock:true, message:'未发送短信;测试验证码 000000'}
|
||||||
|
else if (path === '/area/top/users') result=people()
|
||||||
|
else if (path === '/area/top/users/status') result={status:0}
|
||||||
|
else if (path === '/area/class') result=[]
|
||||||
|
else if (path === '/other/user/recommend') result=people()
|
||||||
|
else if (/^\/other\/user\/(rand\/)?square$/.test(path)) result=page(people(),params)
|
||||||
|
else if (/^\/other\/user\/\d+$/.test(path)) {const user=people().find(p=>p.id===Number(path.split('/').pop())); if(!user)return denied('测试用户不存在');result=detail(user)}
|
||||||
|
else if (/^\/follow\/user\/\d+$/.test(path) && method==='POST') {const id=Number(path.split('/').pop());state.follows=state.follows.includes(id)?state.follows.filter(x=>x!==id):state.follows.concat(id);result={is_follow:state.follows.includes(id)}}
|
||||||
|
else if (path === '/rank/purchase/status') result={enabled:false,products:[],single_service_enabled:false,message:'会员购买暂不可用'}
|
||||||
|
else if (path === '/rank/list') result={rank_info:{nickname:'本地测试用户',rank_id:0},ranks:{name:'合成验收会员',feature_arr:[],explain_arr:['本地合成数据;购买默认关闭,不发起真实支付。'],sub_ranks:[{id:900011,name:'合成验收月卡',month:1,price:0,discount_price:0}]},pay_type:'cash'}
|
||||||
|
else if (path === '/rank/service/list') result={rank_info:state.user,services:[]}
|
||||||
|
else if (method === 'POST' && /^\/order\/virtual\/LOCAL-ACCEPTANCE-(PENDING|FULFILLED)\/confirm$/.test(path)) result={state:path.includes('-FULFILLED/')?'FULFILLED':'PENDING'}
|
||||||
|
else if (path === '/dma/carousel/list') result=[]
|
||||||
|
else if (path === '/moment/list' || /^\/moment\/list\/other\/user\/\d+$/.test(path)) result=page(state.moments,params)
|
||||||
|
else if (path === '/moment/topic/list') result=page([],params)
|
||||||
|
else if (path === '/moment/add' && method==='POST') {
|
||||||
|
if(!String(params.content||'').trim())return denied('请输入测试动态内容')
|
||||||
|
const item={id:state.nextId++,user_id:state.user.id,user:clone(state.user),content:String(params.content),images:[],photos:[],pics:[],type:'moment',is_like:0,like_count:0,comment_count:0,topics:[],create_time:'2026-10-30 00:00:00',created_at:'2026-10-30 00:00:00'}
|
||||||
|
state.moments.unshift(item);result=item
|
||||||
|
} else if (/^\/moment\/\d+$/.test(path)) {result=state.moments.find(m=>m.id===Number(path.split('/')[2]));if(!result)return denied('测试动态不存在')}
|
||||||
|
else if (/^\/moment\/\d+\/like$/.test(path) && method==='POST') {const item=state.moments.find(m=>m.id===Number(path.split('/')[2]));if(!item)return denied('测试动态不存在');item.is_like=item.is_like?0:1;item.like_count=item.is_like;result=item}
|
||||||
|
else if (/^\/moment\/\d+\/comment\/list$/.test(path)) result=params.nopage?[]:page([],params)
|
||||||
|
else if (path === '/notice/count') result={system_count:0,friend_count:0,follow_count:0,visit_count:0}
|
||||||
|
else if (['/chat/linkmen','/order/rank/list','/friends','/followers','/followings'].includes(path)) result=page([],params)
|
||||||
|
else if (/^\/label\/user\/\d+\/list$/.test(path)) result=page([],params)
|
||||||
|
else if (/^\/label\/user\/\d+\/logs$/.test(path)) result=[]
|
||||||
|
else if (path === '/interest/labels/list') result=['阅读','运动']
|
||||||
|
else if (path === '/address/country/list') result=[]
|
||||||
|
else return denied('此操作尚未模拟:'+method+' '+path+';未调用真实服务')
|
||||||
|
save();return ok(clone(result))
|
||||||
|
}
|
||||||
|
return {request, reset(){state=initial();save()}, scenario(name,value){if(name==='failure'||name==='complete'){state[name]=!!value;save()}}, snapshot(){return clone(state)}}
|
||||||
|
}
|
||||||
|
function install(wx, config = {}) {
|
||||||
|
if(config.apiOrigin && config.apiOrigin !== 'http://127.0.0.1:18766') throw new Error('Only the local acceptance gateway is permitted')
|
||||||
|
const nativeRequest=wx.request.bind(wx)
|
||||||
|
const prefix=config.apiOrigin?'acceptance-api:':'acceptance:'
|
||||||
|
const done=(options,result,success=true)=>{setTimeout(()=>{if(success){if(options.success)options.success(result)}else if(options.fail)options.fail(result);if(options.complete)options.complete(result)},0);return {abort(){},onProgressUpdate(){},offProgressUpdate(){},onHeadersReceived(){},offHeadersReceived(){}}}
|
||||||
|
const override=(name,fn)=>{wx[name]=fn;if(wx[name]!==fn)throw new Error('Cannot isolate wx.'+name+'; abort acceptance build')}
|
||||||
|
// Namespace ALL storage so an acceptance run cannot reuse or modify real login state.
|
||||||
|
const nativeGet=wx.getStorageSync.bind(wx),nativeSet=wx.setStorageSync.bind(wx),nativeRemove=wx.removeStorageSync.bind(wx)
|
||||||
|
override('getStorageSync',k=>nativeGet(prefix+k))
|
||||||
|
override('setStorageSync',(k,v)=>nativeSet(prefix+k,k==='userInfo'?Object.assign({},v,{is_base_profile:namespaced.snapshot().complete}):v))
|
||||||
|
override('removeStorageSync',k=>nativeRemove(prefix+k))
|
||||||
|
const nativeInfo=wx.getStorageInfoSync && wx.getStorageInfoSync.bind(wx)
|
||||||
|
override('clearStorageSync',()=>{if(nativeInfo)nativeInfo().keys.filter(k=>k.startsWith(prefix)).forEach(nativeRemove)})
|
||||||
|
const namespaced=createMock({get:k=>wx.getStorageSync(k),set:(k,v)=>wx.setStorageSync(k,v)})
|
||||||
|
const fixture=namespaced.snapshot()
|
||||||
|
wx.setStorageSync('userInfo',Object.assign({},fixture.user,{name:fixture.user.nickname,avatar:IMAGE,showId:fixture.user.id}))
|
||||||
|
wx.setStorageSync('adv',{id:0})
|
||||||
|
override('request',options=>{
|
||||||
|
const suffix=typeof options.url==='string' && options.url.startsWith(ORIGIN+'/')?options.url.slice(ORIGIN.length):''
|
||||||
|
const route=(options.method||'GET').toUpperCase()+' '+suffix.split('?')[0]
|
||||||
|
const realRoutes=['POST /login','GET /user/profile','PUT /user/other/profie','GET /user/status','GET /dma/carousel/list','GET /rank/purchase/status']
|
||||||
|
if(config.apiOrigin && realRoutes.includes(route) && !namespaced.snapshot().failure) {
|
||||||
|
// Exact origin and method/path allowlist; no fallback to production or silent mock success.
|
||||||
|
return nativeRequest(Object.assign({},options,{url:config.apiOrigin+'/go/api'+suffix}))
|
||||||
|
}
|
||||||
|
let response;try{response=namespaced.request(options.url,options.method,options.data)}catch(e){response=denied('模拟请求参数无效')}
|
||||||
|
return done(options,{statusCode:200,header:{},data:response,errMsg:'request:ok'})
|
||||||
|
})
|
||||||
|
override('login',options=>done(options,{code:'LOCAL-MOCK-CODE',errMsg:'login:ok'}))
|
||||||
|
const blocked=['uploadFile','downloadFile','connectSocket','requestPayment','requestVirtualPayment','requestSubscribeMessage','navigateToMiniProgram','openCustomerServiceChat','makePhoneCall','startFacialRecognitionVerify','startFacialRecognitionVerifyAndUploadVideo','openUrl','openDocument','shareAppMessage']
|
||||||
|
blocked.forEach(name=>override(name,(options={})=>{if(wx.showToast)wx.showToast({title:'本地模拟:此第三方操作已禁用',icon:'none'});return done(options,{errMsg:name+':fail local mock blocked'},false)}))
|
||||||
|
override('getFuzzyLocation',options=>done(options,{latitude:0,longitude:0,errMsg:'getFuzzyLocation:ok'}))
|
||||||
|
override('chooseLocation',options=>done(options,{latitude:0,longitude:0,name:'测试地点',address:'合成地址',errMsg:'chooseLocation:ok'}))
|
||||||
|
override('getLogManager',()=>({log(){},info(){},debug(){},warn(){}}))
|
||||||
|
override('getRealtimeLogManager',()=>({info(){},warn(){},error(){}}))
|
||||||
|
override('getUpdateManager',()=>({onCheckForUpdate(){},onUpdateReady(){},onUpdateFailed(){},applyUpdate(){}}))
|
||||||
|
// Cloud APIs must never retain access to any previously configured real environment.
|
||||||
|
if(wx.cloud) Object.keys(wx.cloud).forEach(name=>{if(typeof wx.cloud[name]==='function'){wx.cloud[name]=()=>{throw new Error('Cloud disabled in local acceptance') }}})
|
||||||
|
const nativeImage=wx.getImageInfo && wx.getImageInfo.bind(wx)
|
||||||
|
if(nativeImage)override('getImageInfo',options=>nativeImage(Object.assign({},options,{src:/^(https?:)?\/\//i.test(options.src||'')?IMAGE:options.src})))
|
||||||
|
wx.__loveAcceptance=namespaced
|
||||||
|
return namespaced
|
||||||
|
}
|
||||||
|
module.exports={ORIGIN,createMock,install}
|
||||||
20
tools/local-acceptance/templates.test.cjs
Normal file
20
tools/local-acceptance/templates.test.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),fs=require('fs'),path=require('path'),cp=require('child_process')
|
||||||
|
const fix=require('./fix-template-expressions.cjs')
|
||||||
|
assert.equal(fix(String.raw`{{ modalName==\'Modal\' ? 'show' : '' }}`),`{{ modalName=='Modal' ? 'show' : '' }}`)
|
||||||
|
assert.equal(fix(String.raw`{{ 'height:' + (height + \'px\') }}`),`{{ 'height:' + (height + 'px') }}`)
|
||||||
|
const escaped=String.raw`{{ name === 'O\'Brien' ? 'show' : '' }}`
|
||||||
|
assert.equal(fix(escaped),escaped)
|
||||||
|
assert.throws(()=>fix(String.raw`{{ value + \'unterminated }}`),/Unparseable/)
|
||||||
|
const root=path.resolve(__dirname,'../../../.local-acceptance')
|
||||||
|
const build=path.join(root,'mp-mock-build/weapp')
|
||||||
|
const compiler=process.argv[2]
|
||||||
|
if(!compiler)throw Error('Pass the installed WeChat wcc.exe path for full template validation')
|
||||||
|
const files=[]
|
||||||
|
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.wxml'))files.push(path.relative(build,p).replace(/\\/g,'/'))}}
|
||||||
|
walk(build)
|
||||||
|
const output=path.join(root,'logs/wxml-compiled.js')
|
||||||
|
const result=cp.spawnSync(compiler,['-o',output,...files],{cwd:build,encoding:'utf8',maxBuffer:4*1024*1024})
|
||||||
|
fs.writeFileSync(path.join(root,'logs/wxml-compile.log'),(result.stdout||'')+(result.stderr||''))
|
||||||
|
if(result.error || result.status!==0)throw Error('WXML compiler failed: '+(result.error?result.error.code:result.status)+'; inspect wxml-compile.log')
|
||||||
|
console.log('PASS: escaping regression checks and official WXML compilation of '+files.length+' templates')
|
||||||
46
tools/local-acceptance/test.cjs
Normal file
46
tools/local-acceptance/test.cjs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),fs=require('fs'),path=require('path'),vm=require('vm')
|
||||||
|
const {createMock,install,ORIGIN}=require('./runtime')
|
||||||
|
async function main(){
|
||||||
|
const data={};const storage={get:k=>data[k],set:(k,v)=>{data[k]=v}};const mock=createMock(storage)
|
||||||
|
const call=(p,method='GET',input={})=>mock.request(ORIGIN+p,method,input)
|
||||||
|
assert.equal(call('/login','POST').data.user.id,900000001)
|
||||||
|
assert.equal(call('/user/center').data.rank_profile.rank_id,0)
|
||||||
|
assert.equal(call('/other/user/rand/square').data.data.length,3)
|
||||||
|
assert.equal(call('/other/user/rand/square?page=2').data.data.length,0)
|
||||||
|
assert.equal(call('/other/user/999').code,1)
|
||||||
|
assert.equal(call('/user/other/profie','PUT',{introduction:'edited',rank_id:999,is_approved:1}).code,0)
|
||||||
|
assert.equal(call('/user/profile').data.introduction,'edited');assert.equal(call('/user/center').data.user.rank_id,0)
|
||||||
|
assert.equal(call('/moment/add','POST',{content:''}).code,1)
|
||||||
|
const added=call('/moment/add','POST',{content:'local test'}).data
|
||||||
|
assert.equal(call('/moment/list').data.data[0].id,added.id)
|
||||||
|
assert.equal(call('/moment/'+added.id+'/like','POST').data.like_count,1)
|
||||||
|
assert.equal(call('/moment/'+added.id+'/like','POST').data.like_count,0)
|
||||||
|
assert.equal(createMock(storage).snapshot().moments.length,2)
|
||||||
|
assert.equal(call('/register/sms/code','POST').data.mock,true)
|
||||||
|
assert.equal(call('/user/register/profile','PUT',{code:'wrong'}).code,1)
|
||||||
|
assert.equal(call('/user/register/profile','PUT',{code:'000000'}).code,0)
|
||||||
|
for(const p of ['/pay','/refund','/not-implemented','/upload','/certification'])assert.equal(call(p,'POST').code,1)
|
||||||
|
for(const url of ['https://example.invalid/login','http://127.0.0.1:18765.evil/go/api/login',ORIGIN+'evil/login','//example.invalid'])assert.equal(mock.request(url,'POST',{}).code,1)
|
||||||
|
mock.scenario('failure',true);assert.equal(call('/user/profile').code,1);mock.reset();assert.equal(call('/user/profile').code,0)
|
||||||
|
let external=0;const saved={token:'real-state-must-stay'}
|
||||||
|
const wx={getStorageSync:k=>saved[k],setStorageSync:(k,v)=>{saved[k]=v},removeStorageSync:k=>{delete saved[k]},getStorageInfoSync:()=>({keys:Object.keys(saved)}),showToast(){},request(){external++;throw Error('Network accessed')},login(){external++},getImageInfo(o){o.success({path:o.src})}}
|
||||||
|
const isolated=install(wx)
|
||||||
|
function invoke(name,opts={}){return new Promise(resolve=>wx[name]({...opts,success:r=>resolve({ok:true,r}),fail:r=>resolve({ok:false,r})}))}
|
||||||
|
assert.equal((await invoke('login')).r.code,'LOCAL-MOCK-CODE')
|
||||||
|
assert.equal((await invoke('request',{url:ORIGIN+'/login',method:'POST'})).r.data.code,0)
|
||||||
|
assert.equal((await invoke('request',{url:'https://example.invalid'})).r.data.code,1)
|
||||||
|
for(const name of ['uploadFile','downloadFile','connectSocket','requestPayment','requestVirtualPayment','navigateToMiniProgram','requestSubscribeMessage','openCustomerServiceChat','makePhoneCall'])assert.equal((await invoke(name)).ok,false,name)
|
||||||
|
wx.setStorageSync('token','local');assert.equal(saved.token,'real-state-must-stay');assert.equal(wx.getStorageSync('token'),'local')
|
||||||
|
wx.clearStorageSync();assert.equal(saved.token,'real-state-must-stay');assert.equal(wx.getStorageSync('token'),undefined)
|
||||||
|
assert.equal(external,0);isolated.reset()
|
||||||
|
console.log('PASS: login, pagination, profile persistence, privilege isolation, publish/like, SMS fixture, unsupported operations, failures, storage isolation and zero native transport calls')
|
||||||
|
const build=path.resolve(__dirname,'../../../.local-acceptance/mp-mock-build/weapp')
|
||||||
|
if(fs.existsSync(build)){
|
||||||
|
let count=0;function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(e.name.endsWith('.js')){new vm.Script(fs.readFileSync(p,'utf8'),{filename:p});count++}}}walk(build)
|
||||||
|
assert(fs.readFileSync(path.join(build,'app.js'),'utf8').startsWith("require('./acceptance-runtime').install(wx);"))
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(build,'app.json'))).pages[0],'local-acceptance/index')
|
||||||
|
console.log('PASS: '+count+' generated JavaScript files parse; isolation loads before application; acceptance launch page configured')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||||
Loading…
Reference in New Issue
Block a user