303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
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
|
||
},
|
||
})
|