小程序接入支付功能
This commit is contained in:
1 parent
edf1e51846
commit
07a90e860d
38 files changed
+3644
-90
No files matched your search
@@ -0,0 +1,302 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "开通使用",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<view class="page">
|
||||
<view class="head">
|
||||
<view class="head-title">{{copy.title}}</view>
|
||||
<view class="head-sub">{{isAdmin ? (env === 1 ? '沙箱环境 · 不扣钱,只有开发者工具能拉起' : '现网环境 · 会真扣款(管理员可见)') : copy.subtitle}}</view>
|
||||
<view wx:if="{{isAdmin}}" class="env-switch">
|
||||
<view class="env-chip {{env === 0 ? 'on' : ''}}" data-env="0" bindtap="onEnv">现网</view>
|
||||
<view class="env-chip {{env === 1 ? 'on' : ''}}" data-env="1" bindtap="onEnv">沙箱</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:for="{{goods}}" wx:key="id" data-id="{{item.id}}" bindtap="onBuy">
|
||||
<view class="card-main">
|
||||
<view class="card-name">{{item.name}}<text wx:if="{{item.isBest}}" class="card-badge">推荐</text></view>
|
||||
<view wx:if="{{item.note}}" class="card-note">{{item.note}}</view>
|
||||
<view wx:if="{{isAdmin}}" class="card-id">道具 ID {{item.id}}</view>
|
||||
</view>
|
||||
<view class="card-right">
|
||||
<view class="card-price">¥{{item.priceText}}</view>
|
||||
<view class="card-btn {{buying === item.id ? 'dim' : ''}}">{{buying === item.id ? '进行中…' : copy.button}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{result}}" class="result {{result.ok ? 'ok' : 'bad'}}">
|
||||
<view class="result-title">{{result.title}}</view>
|
||||
<view wx:if="{{result.detail}}" class="result-detail">{{result.detail}}</view>
|
||||
<view wx:if="{{result.code}}" class="result-code" bindtap="copyCode">{{result.code}}</view>
|
||||
<view wx:if="{{lastOrder && isAdmin}}" class="result-order">业务单号 {{lastOrder}}</view>
|
||||
<view wx:if="{{lastOrder && needPay}}" class="result-btn" bindtap="onRetryPay">重新支付</view>
|
||||
<view wx:if="{{lastOrder && !needPay}}" class="result-btn" bindtap="onDeliver">{{isAdmin ? '确认发货' : '重新获取激活码'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="tip">{{copy.tip}}</view>
|
||||
<view wx:if="{{isAdmin}}" class="tip dev">管理员备注:下单、查单、发货都走你自己的服务端(server/index.js),发货以查单结果为准;沙箱用自己的沙箱 AppKey 和订单池,和现网互不影响。</view>
|
||||
</view>
|
||||
@@ -0,0 +1,182 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 32rpx 28rpx 48rpx;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.head {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.head-sub {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.env-switch {
|
||||
display: flex;
|
||||
width: 320rpx;
|
||||
margin-top: 22rpx;
|
||||
padding: 6rpx;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.env-chip {
|
||||
flex: 1;
|
||||
height: 58rpx;
|
||||
line-height: 58rpx;
|
||||
text-align: center;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 25rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.env-chip.on {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 26rpx 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
margin-left: 14rpx;
|
||||
padding: 2rpx 14rpx;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(242, 53, 59, 0.18);
|
||||
color: var(--accent-2);
|
||||
font-size: 20rpx;
|
||||
vertical-align: 4rpx;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.card-note {
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
color: #f0b45c;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-right: 22rpx;
|
||||
}
|
||||
|
||||
.card-btn {
|
||||
min-width: 132rpx;
|
||||
height: 62rpx;
|
||||
line-height: 62rpx;
|
||||
text-align: center;
|
||||
border-radius: var(--r-pill);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.card-btn.dim {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 12rpx;
|
||||
padding: 26rpx 28rpx;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--panel);
|
||||
border-left: 6rpx solid var(--accent);
|
||||
}
|
||||
|
||||
.result.bad {
|
||||
border-left-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
margin-top: 10rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.result-order {
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result-code {
|
||||
margin-top: 18rpx;
|
||||
padding: 20rpx 0;
|
||||
border-radius: var(--r-lg);
|
||||
background: rgba(79, 193, 255, 0.12);
|
||||
color: var(--cyan);
|
||||
font-size: 38rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
display: inline-block;
|
||||
margin-top: 20rpx;
|
||||
padding: 12rpx 30rpx;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(79, 193, 255, 0.16);
|
||||
color: var(--cyan);
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 26rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.tip.dev {
|
||||
margin-top: 12rpx;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
Reference in new issue
Block a user