From 731122064b3957d61af426c8070d74ebf78067fe Mon Sep 17 00:00:00 2001 From: Guoguo Date: Mon, 18 May 2026 03:16:44 -0700 Subject: [PATCH] fix: 5 critical payment issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Move notify route before authMiddleware (WeChat callback has no JWT) 2. Add await to verifyNotifySignature call (was fire-and-forget) 3. Remove dangerous verify fallback — all signature failures now throw 4. Payment sync polls 3x before giving up, never redirects to success page unless confirmed paid 5. Production guard enforces all WX_MCH_* env vars on startup --- .../pages/subscribe-plans/subscribe-plans.js | 51 +++++++++++-------- server/src/app.js | 4 +- server/src/config.js | 3 ++ server/src/lib/wxpay.js | 6 +-- server/src/routes/payment-notify.js | 2 +- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js index f77239c..fb77991 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.js +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -200,27 +200,34 @@ Page({ 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) - }) + var retryCount = 0 + var maxRetries = 3 + + function pollSync() { + api.syncPaymentOrder(orderId).then(function (result) { + if (result.status === 'paid') { + self.setData({ purchasing: false }) + wx.showToast({ title: '支付成功', icon: 'success' }) + setTimeout(function () { + wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey }) + }, 1000) + } else if (retryCount < maxRetries) { + retryCount++ + setTimeout(pollSync, 2000) + } else { + self.setData({ purchasing: false }) + wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' }) + } + }).catch(function () { + if (retryCount < maxRetries) { + retryCount++ + setTimeout(pollSync, 2000) + } else { + self.setData({ purchasing: false }) + wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' }) + } + }) + } + pollSync() } }) diff --git a/server/src/app.js b/server/src/app.js index 82f23dd..ac08e5c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -47,6 +47,9 @@ app.use('/api/v1/admin/login', adminLoginLimiter) app.use('/api/v1/user/avatar', uploadLimiter) app.use('/api/v1/user/phone', uploadLimiter) +// WeChat Pay callback — must be before authMiddleware (no JWT) +app.post('/api/v1/payment/wechat/notify', require('./routes/payment-notify')) + app.use(authMiddleware) app.get('/health', (req, res) => res.json(ok({ status: 'ok' }))) @@ -59,7 +62,6 @@ app.use('/api/v1', require('./routes/treatment')) app.use('/api/v1/admin', require('./routes/admin')) app.use('/api/v1', require('./routes/firmware')) app.use('/api/v1', require('./routes/payment')) -app.post('/api/v1/payment/wechat/notify', require('./routes/payment-notify')) app.use((req, res) => res.status(404).json(fail(404, 'not_found'))) diff --git a/server/src/config.js b/server/src/config.js index df696bd..d5e6aa8 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -46,6 +46,9 @@ if (config.nodeEnv === 'production') { if (config.jwt.secret === 'dev-user-secret') throw new Error('JWT_SECRET must be set in production') if (config.jwt.adminSecret === 'dev-admin-secret') throw new Error('ADMIN_JWT_SECRET must be set in production') if (config.admin.username === 'admin' || config.admin.password === 'admin') throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD must be changed from defaults in production') + const wp = config.wxpay + if (!wp.mchId || !wp.apiV3Key || !wp.mchSerialNo || !wp.notifyUrl) throw new Error('WeChat Pay credentials (WX_MCH_ID, WX_MCH_API_V3_KEY, WX_MCH_SERIAL_NO, WX_PAY_NOTIFY_URL) must be set in production') + if (!wp.privateKey && !wp.privateKeyPath) throw new Error('WX_MCH_PRIVATE_KEY or WX_MCH_PRIVATE_KEY_PATH must be set in production') } module.exports = config diff --git a/server/src/lib/wxpay.js b/server/src/lib/wxpay.js index 98e84da..f0e8775 100644 --- a/server/src/lib/wxpay.js +++ b/server/src/lib/wxpay.js @@ -142,10 +142,8 @@ async function verifyNotifySignature(headers, rawBody) { throw new Error('notify signature verification failed') } } catch (err) { - if (err.message.indexOf('unknown platform certificate') !== -1 || err.message.indexOf('signature verification') !== -1) { - throw err - } - console.error('[WXPAY] platform cert verification fallback:', err.message) + console.error('[WXPAY] signature verification failed:', err.message) + throw err } return true } diff --git a/server/src/routes/payment-notify.js b/server/src/routes/payment-notify.js index ffa58be..5156f1d 100644 --- a/server/src/routes/payment-notify.js +++ b/server/src/routes/payment-notify.js @@ -9,7 +9,7 @@ async function handleNotify(req, res) { const rawBody = typeof req.body === 'string' ? req.body : (Buffer.isBuffer(req.body) ? req.body.toString('utf8') : JSON.stringify(req.body)) const parsed = typeof req.body === 'object' && !Buffer.isBuffer(req.body) ? req.body : JSON.parse(rawBody) - wxpay.verifyNotifySignature(req.headers, rawBody) + await wxpay.verifyNotifySignature(req.headers, rawBody) const result = wxpay.decryptNotifyResource(parsed.resource)