Files
mini-viewer/pages/buy/buy.js
T
2026-09-29 01:14:24 +08:00

303 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
},
})