const { post, get, login } = require('../../utils/pay-api.js') // 道具列表(与 MP 后台一致,单位:分) const GOODS = [ { id: '90001', name: '补单专用', price: 10, adminOnly: true, note: '0.1 元测试用,iOS 最低 1 元买不了' }, { id: '10001', name: '基础激活码', price: 3900 }, { id: '10002', name: '高级激活码', price: 6900 }, { id: '10000', name: '永久激活码', price: 9900 }, ] // 只把常见的几个错误码翻译成人话,方便对着测 const PAY_ERR = { 1001: '参数错误', '-1': '支付失败', '-2': '用户取消支付', '-4': '风控拦截', '-15002': 'outTradeNo 重复,换新单号重试', '-15005': '用户态签名 signature 错误', '-15006': '支付签名 paySig 错误', '-15007': 'session_key 过期,重进小程序再试', '-15010': '道具未发布(未发布的道具不能下单)', '-15011': '现网版本 env 只能填 0', '-15013': 'goodsPrice 与后台道具价格不一致', '-15014': '道具刚发布未生效,等几分钟再试', } const ORDER_STATUS = { 0: '订单初始化', 1: '订单创建成功(未支付)', 2: '已支付,待发货', 3: '发货中', 4: '已发货', 5: '已退款', 6: '已关闭', 7: '退款失败', 8: '用户退款完成', 9: '回收广告金完成', 10: '分账回退完成', } const IOS_MIN_VERSION = [8, 0, 68] const BASE_LIB_MIN_VERSION = '2.19.2' // 上次问出来的身份先当初始值:切到本页时,补单道具和沙箱开关就不会先闪一下再消失 const cachedIsAdmin = Boolean(wx.getStorageSync('isAdmin')) // 页面文案的默认值(和 server/copy.js 保持一致);数据库 settings 表里的同名配置会覆盖它 const DEFAULT_COPY = { tab: '开通', title: '选择套餐', subtitle: '一次付费,激活码自动发放到「我的」', button: '购买', tip: '购买成功后激活码会自动发放,在「我的」里可以随时查看和复制。同一笔订单重复点只会拿到同一张码,不用担心重复扣款。', } // 上次拿到的文案先当初始值,避免切页时文字闪一下 const cachedCopy = Object.assign({}, DEFAULT_COPY, wx.getStorageSync('copy') || {}) // 底部标签的文字也走配置(本页是 tabBar 的第 1 项:首页、开通、预览、我的) function applyTabText(text) { if (!text || text === cachedCopy.tab) return try { wx.setTabBarItem({ index: 1, text }) } catch (err) { console.warn('设置标签文字失败', err) } } // 1234 分 -> 12.34 function yuan(fen) { return (fen / 100).toFixed(2).replace(/\.?0+$/, '') } // 价目表转成页面要的字段,顺便按身份过滤掉管理员专属道具 function toView(list, isAdmin) { const view = list .filter((item) => isAdmin || !item.adminOnly) .sort((a, b) => a.price - b.price) // 价格从低到高 .map((item) => ({ id: String(item.productId || item.id), name: item.name, price: item.price, priceText: yuan(item.price), note: item.note || '', isBest: false, })) if (view.length) view[view.length - 1].isBest = true // 最贵那档标「推荐」 return view } // 官方示例同款版本比较 function compareVersion(v1, v2) { const a = String(v1).split('.') const b = String(v2).split('.') const len = Math.max(a.length, b.length) while (a.length < len) a.push('0') while (b.length < len) b.push('0') for (let i = 0; i < len; i++) { const n1 = parseInt(a[i], 10) const n2 = parseInt(b[i], 10) if (n1 > n2) return 1 if (n1 < n2) return -1 } return 0 } Page({ data: { goods: toView(GOODS, cachedIsAdmin), isAdmin: cachedIsAdmin, // 沙箱、补单道具、退款都是管理员功能,后端也会拦 copy: cachedCopy, env: 0, // 0 现网(真机),1 沙箱(只在开发者工具里能拉起,且不扣钱) buying: '', result: null, lastOrder: '', needPay: false, // 上一笔还没付款成功(取消、失败),按钮就提示「重新支付」 retryProductId: '', }, onEnv(e) { this.setData({ env: Number(e.currentTarget.dataset.env), result: null, lastOrder: '' }) }, // 付过钱但没发货的单(关过小程序也算),重新进来还能补发货 async onShow() { await this.loadMe() if (this.data.lastOrder) return try { const code = await login() const data = await post('/pay/pending', { code }) if (!data.outTradeNo) return this.setData({ lastOrder: data.outTradeNo, env: data.env === 1 ? 1 : 0, result: { ok: true, title: '有一笔订单还没发货', detail: '刚才付过款的话,点「确认发货」把激活码补出来' }, }) } catch (err) { console.warn('查询待发货订单失败', err.message) } }, // 后端带回「我是不是管理员」和该用户能看到的价格表(补单道具只有管理员能看到) async loadMe() { let me try { me = await post('/pay/me', { code: await login() }) } catch (err) { // 拉不到就保持上次的身份和列表,别让管理员因为一次网络抖动丢了开关 console.warn('获取用户信息失败,沿用上次的身份:', err.message) return } const isAdmin = Boolean(me.isAdmin) wx.setStorageSync('isAdmin', isAdmin) const list = me.goods && me.goods.length ? me.goods : GOODS const fromServer = me.copy || {} // 服务端还没更新时这里会是空的,全部走默认值 const copy = { tab: fromServer['tab.buy'] || DEFAULT_COPY.tab, title: fromServer['buy.title'] || DEFAULT_COPY.title, subtitle: fromServer['buy.subtitle'] || DEFAULT_COPY.subtitle, button: fromServer['buy.button'] || DEFAULT_COPY.button, tip: fromServer['buy.tip'] || DEFAULT_COPY.tip, } wx.setStorageSync('copy', copy) applyTabText(copy.tab) this.setData({ isAdmin, env: isAdmin ? this.data.env : 0, goods: toView(list, isAdmin), copy }) }, async onBuy(e) { const productId = e.currentTarget.dataset.id console.log('[购买] 点击', productId, 'env=', this.data.env, 'buying=', this.data.buying || '空') if (this.data.buying) { console.log('[购买] 上一次还在处理中,这次点击被忽略') return } this.setData({ needPay: false, retryProductId: productId }) try { // 检查也放进 try 里:万一环境判断本身抛错,也要在结果区看到原因 if (!this.checkEnv(productId)) { console.log('[购买] 环境检查没通过') return } this.setData({ buying: productId, result: null }) const code = await login() console.log('[购买] 拿到登录 code,开始下单') const order = await post('/pay/order', { productId, code, env: this.data.env }) if (!order.payData || !order.payData.signData) throw new Error('服务端没返回支付参数,请查看服务器日志') console.log('[购买] 拿到支付参数,拉起支付') this.setData({ lastOrder: order.outTradeNo }) await this.pay(order.payData) this.setData({ result: { ok: true, title: '支付成功', detail: '正在发货…' } }) await this.onDeliver() } catch (err) { console.log('[购买] 中断:', err.message) if (err.fromPay) { // 支付没成功(取消/失败/风控):这次没扣钱,提示重新支付 this.setData({ needPay: true, result: { ok: false, title: err.errCode === -2 ? '已取消支付' : '支付未完成', detail: err.errCode === -2 ? '本次没有扣款,点下方可以重新支付' : err.message, }, }) } else this.fail(err.message) } finally { this.setData({ buying: '' }) } }, // 基础库和微信版本不满足时,拉起支付必失败,提前拦掉 checkEnv(productId) { const sys = wx.getSystemInfoSync() if (!(compareVersion(sys.SDKVersion, BASE_LIB_MIN_VERSION) >= 0 || wx.canIUse('requestVirtualPayment'))) { return this.fail(`基础库版本过低(${sys.SDKVersion}),虚拟支付需要 ≥ ${BASE_LIB_MIN_VERSION}`) } if (sys.platform === 'ios' && compareVersion(sys.version, IOS_MIN_VERSION.join('.')) < 0) { return this.fail('iOS 需微信 8.0.68 及以上,请先更新微信') } if (this.data.env === 1 && sys.platform === 'ios') { return this.fail('沙箱不支持 iOS,苹果支付只有现网。请用开发者工具的模拟器,或切到安卓真机') } // 苹果支付最低 1 元,低于这个金额的单 iOS 上必然被拒(错误码 -15001) if (sys.platform === 'ios') { // 用页面当前展示的价格判断(改价从数据库来,可能和内置价目表不一致) const goods = this.data.goods.find((item) => item.id === productId) || GOODS.find((item) => item.id === productId) || {} if ((goods.price || 0) < 100) { return this.fail(`iOS 最低支付 1 元,「${goods.name}」只有 ¥${yuan(goods.price || 0)},iPhone 上买不了`) } } return true }, pay(payData) { return new Promise((resolve, reject) => { wx.requestVirtualPayment( Object.assign({}, payData, { success: resolve, fail: (err) => { const error = new Error(`errCode ${err.errCode}:${PAY_ERR[err.errCode] || err.errMsg}`) error.errCode = err.errCode error.fromPay = true // 标记「卡在支付这一步」,用来决定按钮提示什么 reject(error) }, }) ) }) }, // 重新支付:按上一笔的道具再走一遍下单+拉起 onRetryPay() { if (!this.data.retryProductId) return this.onBuy({ currentTarget: { dataset: { id: this.data.retryProductId } } }) }, // 支付回调可能丢,所以发货一律以服务端查单为准;重复点也只是把同一张码再给一遍 async onDeliver() { const outTradeNo = this.data.lastOrder if (!outTradeNo) return wx.showLoading({ title: '发货中', mask: true }) try { const code = await login() this.showDeliver(await post('/pay/deliver', { outTradeNo, code })) } catch (err) { this.fail(err.message) } finally { wx.hideLoading() } }, showDeliver(data) { if (!data.paid) { return this.setData({ result: { ok: false, title: '订单状态:' + (ORDER_STATUS[data.status] || data.status), detail: '还没查到支付成功,等半分钟再点「确认发货」', }, }) } this.setData({ result: { ok: true, title: '购买成功 · ' + (data.productName || ''), code: data.code, detail: data.notified ? '激活码已生成,点一下即可复制' : `平台没确认发货:${data.notifyError || '未知原因'}(激活码已生成,不影响使用;稍后再点一次「确认发货」重试)`, }, }) }, copyCode() { if (!this.data.result || !this.data.result.code) return wx.setClipboardData({ data: this.data.result.code }) }, fail(message) { this.setData({ buying: '', result: { ok: false, title: '失败', detail: message, code: '' } }) wx.showToast({ title: message, icon: 'none', duration: 3000 }) return false }, })