feat: WeChat Pay V3 integration (fill credentials to activate)

Server:
- New lib/wxpay.js: native crypto RSA-SHA256 signing, JSAPI prepay,
  AES-256-GCM notify decryption, order query (no npm deps)
- New dao/payment-order.dao.js: createOrder, markPrepay,
  markPaidAndActivateSubscription (idempotent + transactional)
- New routes/payment.js: GET order status, POST order sync
- New routes/payment-notify.js: WeChat async callback handler with
  signature verification, amount/appid/mchid validation
- Modified subscription/purchase: auto-detects wxpay config, returns
  real payment_params or mock fallback
- Schema: payment_orders table with out_trade_no unique key
- app.js: express.raw() for notify path, payment routes mounted
- config.js: wxpay block with 7 env vars
- .env.example: all WeChat Pay fields documented
- .gitignore: certs/, *.pem, *.p12

Miniprogram:
- subscribe-plans doPurchase: calls real purchase API, falls back to
  mockPurchase only when server returns mock:true
- Added syncAndRedirect for post-payment order confirmation
- api.js: getPaymentOrder, syncPaymentOrder
- Removed "模拟支付"/"测试环境" from UI text
这个提交包含在:
Guoguo
2026-05-18 03:07:59 -07:00
父节点 92416261ee
当前提交 78ea1a03c5
修改 12 个文件,包含 449 行新增13 行删除
@@ -130,8 +130,8 @@ Page({
var planDays = plan.key === 'yearly' ? 365 : 30
var title = isRenew ? '确认续费' : '确认支付'
var content = isRenew
? '在现有订阅基础上延长' + planDays + '天,模拟支付 ¥' + plan.price + '?(测试环境)'
: '模拟支付 ¥' + plan.price + '?(测试环境)'
? '在现有订阅基础上延长' + planDays + '天,支付 ¥' + plan.price
: '支付 ¥' + plan.price
wx.showModal({
title: title,
@@ -162,15 +162,65 @@ Page({
doPurchase: function (plan) {
var self = this
self.setData({ purchasing: true })
api.mockPurchase(plan.key).then(function (order) {
self.setData({ purchasing: false })
wx.showToast({ title: '支付成功', icon: 'success' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + plan.key })
}, 1000)
api.purchase(plan.key).then(function (data) {
if (data.mock || !data.payment_params) {
// Dev fallback: use mock purchase
return api.mockPurchase(plan.key).then(function () {
self.setData({ purchasing: false })
wx.showToast({ title: '支付成功', icon: 'success' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + plan.key })
}, 1000)
})
}
// Real payment
var params = data.payment_params
self._currentOrderId = data.order_id
wx.requestPayment({
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType,
paySign: params.paySign,
success: function () {
// Payment dialog succeeded, sync order to confirm
self.syncAndRedirect(data.order_id, plan.key)
},
fail: function (err) {
self.setData({ purchasing: false })
var msg = (err.errMsg || '').indexOf('cancel') > -1 ? '已取消支付' : '支付失败'
wx.showToast({ title: msg, icon: 'none' })
}
})
}).catch(function (err) {
self.setData({ purchasing: false })
wx.showToast({ title: err.message || '支付失败', icon: 'none' })
wx.showToast({ title: err.message || '创建订单失败', icon: 'none' })
})
},
syncAndRedirect: function (orderId, planKey) {
var self = this
api.syncPaymentOrder(orderId).then(function (result) {
self.setData({ purchasing: false })
if (result.status === 'paid') {
wx.showToast({ title: '支付成功', icon: 'success' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey })
}, 1000)
} else {
// Callback may not have arrived yet, still redirect optimistically
wx.showToast({ title: '支付处理中', icon: 'none' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey })
}, 2000)
}
}).catch(function () {
self.setData({ purchasing: false })
// Even if sync fails, payment may still succeed via callback
wx.showToast({ title: '支付处理中,请稍后查看', icon: 'none' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey })
}, 2000)
})
}
})