小程序接入支付功能

This commit is contained in:
Yeomai committed 2026-09-29 01:14:24 +08:00
1 parent edf1e51846
commit 07a90e860d
38 files changed
+3644 -90

No files matched your search

+302
View File
@@ -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
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "开通使用",
"usingComponents": {}
}
+34
View File
@@ -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>
+182
View File
@@ -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);
}