Migrate membership checkout to virtual payment and fix template builds

This commit is contained in:
Glore Zeng 2026-10-30 23:14:44 +08:00
parent 3560457e0b
commit 0831faa3a3
23 changed files with 683 additions and 331 deletions

View File

@ -6,7 +6,7 @@
"scripts": {
"start": "npm run dev",
"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",
"lint": "eslint --ext .js,.wpy src/",
"lint:fix": "eslint --ext .js,.wpy src/ --fix",

View File

@ -14,7 +14,7 @@
"enhance": true
},
"compileType": "miniprogram",
"appid": "wxe486777f4379507e",
"appid": "wxc41491431733671e",
"projectname": "myproj",
"miniprogramRoot": "weapp/",
"srcMiniprogramRoot": "weapp/",

View File

@ -97,7 +97,6 @@ export default {
wx.removeStorageSync('message')
wx.stopPullDownRefresh()
// 控制台调试日志
console.log('[SUCCESS]', statusCode, typeof data === 'object' ? data : data.toString().substring(0, 100))
// 状态码正常 & 确认有数据
if (data.code === 0) {
// 成功回调
@ -122,14 +121,13 @@ export default {
})
}
let errorMessage = {message: '错误,请寻找开发人员协助'}
throw errorMessage
wx.hideLoading()
return
wx.hideLoading()
} else if (data.code === 2) {
console.error(data.message)
vm.errorTwo += 1
if (vm.errorTwo <= 2) {
utils.wx_login().then((data) => {
console.log(data, '444')
let route = `/${currentPage.route}?${url}`
if (!data.data.token) {
wx.removeStorageSync('information')
@ -142,7 +140,6 @@ export default {
route = route.split('?')[0]
wx.switchTab({url: route})
} else {
console.log('222')
wx.redirectTo({url: route})
}
}
@ -155,7 +152,6 @@ export default {
throw errorMessage
} else if (code === 3) {
utils.wx_login().then((data) => {
console.log(data, '444')
let route = `/${currentPage.route}?${url}`
if (!data.data.token) {
wx.removeStorageSync('information')
@ -168,7 +164,6 @@ export default {
route = route.split('?')[0]
wx.switchTab({url: route})
} else {
console.log('222')
wx.redirectTo({url: route})
}
}
@ -191,7 +186,6 @@ export default {
fail: (err) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject(err)
console.error('[ERROR]', err)
},
complete: (res) => {
setTimeout(() => {
@ -221,7 +215,6 @@ export default {
}
})
}
console.log('当前网络不稳定!请重试...')
let errorMessage = {message: '当前网络不稳定!请重试'}
throw errorMessage
}
@ -231,8 +224,6 @@ export default {
}
},
created() {
console.log(this.i18nL)
console.log('created in mixin', '插件打印')
let vm = this
let cacheBodyData = wx.getStorageSync('cacheBodyData')
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
@ -240,7 +231,6 @@ export default {
data: cacheBodyData
}
vm.$app.$options.globalData.resubmit = 2
console.log(data, '提交数组数据***********')
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
if (code === 0) {
vm.app.globalData.bodyFatData = null
@ -249,7 +239,6 @@ export default {
}
wx.hideLoading()
}).catch(err => {
console.log(err)
})
}
}

View File

@ -397,10 +397,6 @@ page {
</swiper-item>
</block>
</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&#45;&#45;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="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>

View File

@ -27,7 +27,7 @@
<swiper-item v-for="(item,index) in rankList" :key="index" class="{{cardCur==index?'cur':''}}"
@tap="showFn(index)">
<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>
</swiper-item>
</swiper>
@ -70,16 +70,17 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
</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="l_text">
<span>
合计: <span style="color: #FF2946">¥</span><span class="price">{{ payData.discount_price }}</span>
{{purchaseEnabled ? '点击查看当前套餐价格' : '会员购买暂不可用'}}
</span>
</view>
<view class="r_text">
<span class="font_32">
立即购买
{{purchasing ? '正在确认订单' : (purchaseEnabled ? '购买会员' : '暂不可购买')}}
</span>
</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;"
v-else>{{ deductionPrice > 0 ? deductionPrice : '0.00' }}</span>
</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">
<span class="font_24" style="color: #66676C;">{{ payData.title }}</span>
</view>
@ -127,7 +124,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
<image class="u_payData_img"
src="https://images.health.ufutx.com/202512/25/5bcd5b54fc5325aa50e841e0cf9542d0.png"
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>
<image src="https://images.health.ufutx.com/202512/25/911f75d508bc6c0de593fbb815a2c479.png" mode="aspectFit"
class="iconV2"></image>
@ -168,6 +165,7 @@ import wepy from '@wepy/core'
import https from '../../mixins/https'
import base from '../../mixins/base'
import {service} from '../../config.js'
import {buyMembership} from '../../utils/virtual-payment'
const app = getApp().$wepy.$options
@ -198,6 +196,9 @@ wepy.page({
modalName: '',
chat_user_id: '',
trade_no: '',
purchaseEnabled: false,
purchasing: false,
virtualProducts: [],
system: '', // 系统类型 iOS/Android
share_type: '',
shareId: '',
@ -230,6 +231,7 @@ wepy.page({
this.rankList = []
// 获取系统类型
this.system = app.globalData.systemInfo.system.split(' ')[0]
this.refreshPurchase()
this.getPageData()
},
@ -263,6 +265,24 @@ wepy.page({
},
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) {
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
@ -278,6 +298,7 @@ wepy.page({
data.ranks.sub_ranks.forEach((item) => {
vm.rankList.push(item)
})
if (!vm.rankList.length) {wx.hideLoading();return}
vm.payData.title = `超级会员(${vm.rankList[0].name})`
vm.payData.price = `${vm.rankList[0].price}`
vm.payData.discount_price = `${vm.rankList[0].discount_price}`
@ -312,28 +333,19 @@ wepy.page({
}
}, 800)
},
showFn(index) {
if (!this.$signInVerify()) {
this.loginShow = true
return
}
// 移除原来的iOS禁止支付逻辑
async showFn(index) {
if (this.purchasing) return
if (!this.$signInVerify()) {this.loginShow = true; return}
await this.refreshPurchase()
if (!this.purchaseEnabled) {wx.showToast({title:'会员购买暂不可用',icon:'none'});return}
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.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) {
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'
}
this.payData.payId = item.id
this.payData.discount_price = (product.price_fen / 100).toFixed(2)
this.payData.title = item.name
this.modalName = 'DialogModal'
},
hideModal() {
this.modalName = ''
@ -356,143 +368,17 @@ wepy.page({
showTost() {
this.$showToast('请同意《福恋服务协议》')
},
// ======================================
// 核心支付方法(Android原接口,iOS新虚拟支付接口)
// ======================================
conversion(item) {
let that = this
that.modalName = ''
that.$showLoading('支付中...')
// 根据系统类型选择不同的接口和参数
if (that.system === 'iOS') {
// 先获取微信登录 code
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'})
}
})
})
}
async conversion() {
if (this.purchasing) return
this.purchasing = true
this.modalName = ''
try {
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) })
this.getPageData()
wx.removeStorageSync(this.pendingStorageKey())
wx.showToast({title:'会员权益已到账',icon:'success'})
} catch (e) {wx.showModal({title:'会员支付',content:e.message || '支付暂不可用,请稍后查询订单',showCancel:false})}
finally {this.purchasing = false;wx.hideLoading()}
},
payChoice(e) {
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e

View File

@ -53,19 +53,7 @@
</view>
</view>
<block v-if="type == 'single'">
<view class="payBtn" v-if="system != 'iOS' || active == '1'" @tap="showFn()">
<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>
<view class="payBtn" @tap="showFn()">单项服务暂不可购买</view>
</block>
<block v-else>
<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 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>
@ -196,15 +184,12 @@
}
if (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) {
this.chat_user_id = e.chat_user_id
}
console.log(this.chat_user_id, '99999')
this.rankList = []
this.system = wx.getStorageSync('system')
console.log(this.system)
this.initPageData()
},
@ -215,7 +200,6 @@
let openid = wx.getStorageSync('openid')
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}`
console.log(url)
return {
title: that.shareText,
path: url,
@ -239,9 +223,7 @@
watch: {
cardCur() {
console.log('payData', this.cardCur)
// if (this.active == 1) {
console.log(this.rankList[this.cardCur])
this.payData.title = this.rankList[this.cardCur].title
this.payData.price = this.rankList[this.cardCur].price
this.payData.discount_price = this.rankList[this.cardCur].discount_price
@ -271,8 +253,7 @@
// rank_id: data.rank_id
// }
// wx.setStorageSync('userInfo', userInfo)
console.log(data)
})
})
},
// 判断是否json
isValidJSON(str) {
@ -296,12 +277,17 @@
}
})
} catch (e) {}
that.rankList = data.services.map(item => {
that.rankList = (Array.isArray(data.services) ? data.services : []).map(item => {
if (that.isValidJSON(item.feature)) {
item.feature = JSON.parse(item.feature)
}
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.payData.title = `${that.rankList[that.cardCur_1].title}`
that.payData.price = `${that.rankList[that.cardCur_1].price}`
@ -312,10 +298,10 @@
if (wx.getStorageSync('from_user_id')) {
// that.worthShare()
}
wx.hideLoading()
console.log(that.payData, 'rankList')
console.log(that.rankList, 'rankList')
})
}).catch(() => {
that.rankList = []
that.specialVIP = {feature: [], discount_desc: '服务套餐加载失败,请稍后重试;单项服务暂不可购买'}
}).finally(() => wx.hideLoading())
},
worthShare() {
let vm = this,
@ -333,48 +319,21 @@
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
},
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.sharePic = this.rankList[e.$wx.detail.current].pic
// if (this.active == 1) {
console.log(this.cardCur)
this.specialVIP = this.rankList[this.cardCur]
// }
clearTimeout(this.timeID)
this.timeID = setTimeout(() => {
this.shareId = this.rankList[this.cardCur].id
console.log(this.shareId)
if (wx.getStorageSync('from_user_id')) {
this.worthShare()
}
}, 800)
},
showFn (index) { // 弹框支付
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, '===')
},
showFn() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
hideModal () {
this.modalName = ''
},
@ -397,75 +356,7 @@
showTost() {
this.$showToast('请同意《福恋服务协议》')
},
conversion(item) {
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)
})
},
conversion() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
payChoice(e) {
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
},
@ -506,6 +397,7 @@
._bc-user{
width: 100%;
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
background-color: #191919;
background-size: cover;
background-repeat: no-repeat;
position: relative;
@ -635,29 +527,20 @@
letter-spacing: 8rpx;
}
.payBtn{
position: relative;
position: fixed;
bottom: 0;
left: 50%;
transform: translate(-50%);
width: 480rpx;
height: 60px;
background-image: url("https://images.ufutx.com/202007/17/fb7b8b1cd0d1b289d1e029257a97e949.png");
background-size: contain;
background-repeat: no-repeat;
.l_text{
position: absolute;
left: 60rpx;
top: 12rpx;
}
.r_text{
color: #f8e8b2;
position: absolute;
right: 56rpx;
top: 12rpx;
}
left: 0;
width: 100%;
padding: 28rpx 24rpx calc(28rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #fff;
color: #666;
text-align: center;
font-size: 30rpx;
z-index: 10;
}
}
.bg-vip{
background: #333333;
color: white;

View 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
View 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
View 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')

View 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 + '}}'
})
}

View 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 分支的已有变更,未提交、合并或发布。

View 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.

View 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 语法检查及隔离测试通过。
未执行预览上传;预览码生成结果仍由用户重新点击预览验证。本次未修改业务源码或生产配置。

View 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})

View 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}))

View 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});

View File

@ -0,0 +1,2 @@
'use strict'
module.exports = require('../build/fix-template-expressions.cjs')

View 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()}}
}

View 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})

View 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})

View 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}

View 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')

View 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})