From 0831faa3a309cf9f7851933190883f7103120feb Mon Sep 17 00:00:00 2001 From: Glore Zeng Date: Fri, 30 Oct 2026 23:14:44 +0800 Subject: [PATCH] Migrate membership checkout to virtual payment and fix template builds --- package.json | 2 +- project.config.json | 2 +- src/mixins/https.js | 15 +- src/pages/tabBar/home.wpy | 4 - src/pages/users/upgradeVIP.wpy | 220 +++++------------- src/pages/users/upgradeVIP2.wpy | 173 +++----------- src/utils/virtual-payment.js | 34 +++ tools/build/README.txt | 6 + tools/build/build.cjs | 11 + tools/build/fix-template-expressions.cjs | 17 ++ .../MEMBERSHIP-ACCEPTANCE.txt | 33 +++ tools/local-acceptance/Open-DevTools.ps1 | 10 + tools/local-acceptance/README.txt | 87 +++++++ tools/local-acceptance/api.test.cjs | 28 +++ tools/local-acceptance/build.cjs | 61 +++++ .../devtools-membership.test.cjs | 34 +++ .../fix-template-expressions.cjs | 2 + tools/local-acceptance/im.js | 13 ++ tools/local-acceptance/pages.test.cjs | 43 ++++ tools/local-acceptance/payment.test.cjs | 24 ++ tools/local-acceptance/runtime.js | 129 ++++++++++ tools/local-acceptance/templates.test.cjs | 20 ++ tools/local-acceptance/test.cjs | 46 ++++ 23 files changed, 683 insertions(+), 331 deletions(-) create mode 100644 src/utils/virtual-payment.js create mode 100644 tools/build/README.txt create mode 100644 tools/build/build.cjs create mode 100644 tools/build/fix-template-expressions.cjs create mode 100644 tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt create mode 100644 tools/local-acceptance/Open-DevTools.ps1 create mode 100644 tools/local-acceptance/README.txt create mode 100644 tools/local-acceptance/api.test.cjs create mode 100644 tools/local-acceptance/build.cjs create mode 100644 tools/local-acceptance/devtools-membership.test.cjs create mode 100644 tools/local-acceptance/fix-template-expressions.cjs create mode 100644 tools/local-acceptance/im.js create mode 100644 tools/local-acceptance/pages.test.cjs create mode 100644 tools/local-acceptance/payment.test.cjs create mode 100644 tools/local-acceptance/runtime.js create mode 100644 tools/local-acceptance/templates.test.cjs create mode 100644 tools/local-acceptance/test.cjs diff --git a/package.json b/package.json index fcf742b..01b1967 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/project.config.json b/project.config.json index 1598d35..780adca 100644 --- a/project.config.json +++ b/project.config.json @@ -14,7 +14,7 @@ "enhance": true }, "compileType": "miniprogram", - "appid": "wxe486777f4379507e", + "appid": "wxc41491431733671e", "projectname": "myproj", "miniprogramRoot": "weapp/", "srcMiniprogramRoot": "weapp/", diff --git a/src/mixins/https.js b/src/mixins/https.js index ccbee49..a68e418 100644 --- a/src/mixins/https.js +++ b/src/mixins/https.js @@ -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) }) } } diff --git a/src/pages/tabBar/home.wpy b/src/pages/tabBar/home.wpy index 3bd4a9c..51e7618 100644 --- a/src/pages/tabBar/home.wpy +++ b/src/pages/tabBar/home.wpy @@ -397,10 +397,6 @@ page { - - -
-
置顶推荐 diff --git a/src/pages/users/upgradeVIP.wpy b/src/pages/users/upgradeVIP.wpy index 50de8b8..6817d54 100644 --- a/src/pages/users/upgradeVIP.wpy +++ b/src/pages/users/upgradeVIP.wpy @@ -27,7 +27,7 @@ - + {{item.name}}{{purchaseEnabled ? '购买前确认价格' : '会员购买暂不可用'}} @@ -70,16 +70,17 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);"> + 查询最近会员订单 - 合计: ¥{{ payData.discount_price }} + {{purchaseEnabled ? '点击查看当前套餐价格' : '会员购买暂不可用'}} - 立即购买 + {{purchasing ? '正在确认订单' : (purchaseEnabled ? '购买会员' : '暂不可购买')}} @@ -114,11 +115,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);"> {{ deductionPrice > 0 ? deductionPrice : '0.00' }} - - 原价:¥{{ - payData.price - }} - + {{ payData.title }} @@ -127,7 +124,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);"> - 微信支付 + {{system === 'iOS' ? 'Apple 支付' : '微信支付'}} @@ -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 diff --git a/src/pages/users/upgradeVIP2.wpy b/src/pages/users/upgradeVIP2.wpy index 6e0fee6..81079b5 100644 --- a/src/pages/users/upgradeVIP2.wpy +++ b/src/pages/users/upgradeVIP2.wpy @@ -53,19 +53,7 @@ - - - - ¥{{specialVIP.discount_price}} - - - - - - 立即购买 - - - + 单项服务暂不可购买 @@ -116,7 +104,7 @@ - 立即支付 + 暂不可购买 @@ -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; diff --git a/src/utils/virtual-payment.js b/src/utils/virtual-payment.js new file mode 100644 index 0000000..7597e3d --- /dev/null +++ b/src/utils/virtual-payment.js @@ -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('支付结果确认中,请稍后查询订单,勿重复购买') +} diff --git a/tools/build/README.txt b/tools/build/README.txt new file mode 100644 index 0000000..1ab4092 --- /dev/null +++ b/tools/build/README.txt @@ -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。 diff --git a/tools/build/build.cjs b/tools/build/build.cjs new file mode 100644 index 0000000..13cd221 --- /dev/null +++ b/tools/build/build.cjs @@ -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') diff --git a/tools/build/fix-template-expressions.cjs b/tools/build/fix-template-expressions.cjs new file mode 100644 index 0000000..5ab8473 --- /dev/null +++ b/tools/build/fix-template-expressions.cjs @@ -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 + '}}' + }) +} diff --git a/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt b/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt new file mode 100644 index 0000000..6a4c080 --- /dev/null +++ b/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt @@ -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 分支的已有变更,未提交、合并或发布。 diff --git a/tools/local-acceptance/Open-DevTools.ps1 b/tools/local-acceptance/Open-DevTools.ps1 new file mode 100644 index 0000000..00eac8d --- /dev/null +++ b/tools/local-acceptance/Open-DevTools.ps1 @@ -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. diff --git a/tools/local-acceptance/README.txt b/tools/local-acceptance/README.txt new file mode 100644 index 0000000..5903ffc --- /dev/null +++ b/tools/local-acceptance/README.txt @@ -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 语法检查及隔离测试通过。 +未执行预览上传;预览码生成结果仍由用户重新点击预览验证。本次未修改业务源码或生产配置。 diff --git a/tools/local-acceptance/api.test.cjs b/tools/local-acceptance/api.test.cjs new file mode 100644 index 0000000..65c94de --- /dev/null +++ b/tools/local-acceptance/api.test.cjs @@ -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}) diff --git a/tools/local-acceptance/build.cjs b/tools/local-acceptance/build.cjs new file mode 100644 index 0000000..8aeae1d --- /dev/null +++ b/tools/local-acceptance/build.cjs @@ -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>)?/g,'本地模拟:外部网页已禁用');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'),'福恋 · 本地模拟验收合成数据,不访问后端或第三方服务。远程图片已替换,外部字体可能与正式版不同。支付、认证、上传不属于本轮可验收范围。重新编译回到此入口。测试注册验证码:000000。不要输入真实个人资料。') +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本地模拟 · 合成数据\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})) diff --git a/tools/local-acceptance/devtools-membership.test.cjs b/tools/local-acceptance/devtools-membership.test.cjs new file mode 100644 index 0000000..2269cc1 --- /dev/null +++ b/tools/local-acceptance/devtools-membership.test.cjs @@ -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}); diff --git a/tools/local-acceptance/fix-template-expressions.cjs b/tools/local-acceptance/fix-template-expressions.cjs new file mode 100644 index 0000000..ee34fe9 --- /dev/null +++ b/tools/local-acceptance/fix-template-expressions.cjs @@ -0,0 +1,2 @@ +'use strict' +module.exports = require('../build/fix-template-expressions.cjs') diff --git a/tools/local-acceptance/im.js b/tools/local-acceptance/im.js new file mode 100644 index 0000000..fa2e56e --- /dev/null +++ b/tools/local-acceptance/im.js @@ -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()}} +} diff --git a/tools/local-acceptance/pages.test.cjs b/tools/local-acceptance/pages.test.cjs new file mode 100644 index 0000000..7260c18 --- /dev/null +++ b/tools/local-acceptance/pages.test.cjs @@ -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(/]*>([\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}) diff --git a/tools/local-acceptance/payment.test.cjs b/tools/local-acceptance/payment.test.cjs new file mode 100644 index 0000000..14688a5 --- /dev/null +++ b/tools/local-acceptance/payment.test.cjs @@ -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}) diff --git a/tools/local-acceptance/runtime.js b/tools/local-acceptance/runtime.js new file mode 100644 index 0000000..158ea70 --- /dev/null +++ b/tools/local-acceptance/runtime.js @@ -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} diff --git a/tools/local-acceptance/templates.test.cjs b/tools/local-acceptance/templates.test.cjs new file mode 100644 index 0000000..f6453f5 --- /dev/null +++ b/tools/local-acceptance/templates.test.cjs @@ -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') diff --git a/tools/local-acceptance/test.cjs b/tools/local-acceptance/test.cjs new file mode 100644 index 0000000..9625a79 --- /dev/null +++ b/tools/local-acceptance/test.cjs @@ -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})