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
这个提交包含在:
@@ -0,0 +1,43 @@
|
||||
const { ok } = require('../lib/response')
|
||||
const config = require('../config')
|
||||
const wxpay = require('../lib/wxpay')
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
async function handleNotify(req, res) {
|
||||
try {
|
||||
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)
|
||||
|
||||
const result = wxpay.decryptNotifyResource(parsed.resource)
|
||||
|
||||
// Validate appid and mchid
|
||||
const expectedAppid = config.wxpay.appid || config.wechat.appid
|
||||
if (result.appid !== expectedAppid) throw new Error('appid mismatch')
|
||||
if (result.mchid !== config.wxpay.mchId) throw new Error('mchid mismatch')
|
||||
|
||||
const orderId = result.out_trade_no
|
||||
const order = await paymentOrderDao.findByOutTradeNo(orderId)
|
||||
if (!order) throw new Error('order not found: ' + orderId)
|
||||
|
||||
// Validate amount
|
||||
if (result.amount && result.amount.total !== order.amount_fen) throw new Error('amount mismatch')
|
||||
|
||||
if (result.trade_state === 'SUCCESS') {
|
||||
const activated = await paymentOrderDao.markPaidAndActivateSubscription(orderId, result.transaction_id, result)
|
||||
if (activated) {
|
||||
await logDao.write({ user_id: order.user_id, action: 'payment_notify_success', detail: 'order: ' + orderId + ' tx: ' + result.transaction_id, ip: req.ip })
|
||||
}
|
||||
}
|
||||
|
||||
// WeChat expects this exact response format
|
||||
res.status(200).json({ code: 'SUCCESS', message: '' })
|
||||
} catch (err) {
|
||||
console.error('[WXPAY NOTIFY ERROR]', err.message)
|
||||
res.status(400).json({ code: 'FAIL', message: err.message || 'processing error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = handleNotify
|
||||
@@ -0,0 +1,47 @@
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const wxpay = require('../lib/wxpay')
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
// Query order status (user)
|
||||
router.get('/payment/orders/:order_id', requireUser, wrap(async (req, res) => {
|
||||
const order = await paymentOrderDao.findByOutTradeNo(req.params.order_id)
|
||||
if (!order || order.user_id !== req.user.user_id) return res.json(fail(1005, 'order_not_found'))
|
||||
res.json(ok({
|
||||
order_id: order.order_id,
|
||||
plan: order.plan,
|
||||
amount_fen: order.amount_fen,
|
||||
status: order.status,
|
||||
paid_at: order.paid_at
|
||||
}))
|
||||
}))
|
||||
|
||||
// Sync order — query WeChat and activate if paid (user)
|
||||
router.post('/payment/orders/:order_id/sync', requireUser, wrap(async (req, res) => {
|
||||
const order = await paymentOrderDao.findByOutTradeNo(req.params.order_id)
|
||||
if (!order || order.user_id !== req.user.user_id) return res.json(fail(1005, 'order_not_found'))
|
||||
if (order.status === 'paid') return res.json(ok({ status: 'paid', message: 'already activated' }))
|
||||
|
||||
if (!wxpay.isConfigured()) return res.json(fail(2001, 'payment not configured'))
|
||||
|
||||
const wxOrder = await wxpay.queryOrder(order.order_id)
|
||||
if (wxOrder.trade_state === 'SUCCESS') {
|
||||
await paymentOrderDao.markPaidAndActivateSubscription(order.order_id, wxOrder.transaction_id, wxOrder)
|
||||
await logDao.write({ user_id: order.user_id, action: 'payment_sync_success', detail: 'order: ' + order.order_id, ip: req.ip })
|
||||
return res.json(ok({ status: 'paid', message: 'subscription activated' }))
|
||||
}
|
||||
|
||||
if (wxOrder.trade_state === 'CLOSED' || wxOrder.trade_state === 'REVOKED') {
|
||||
await paymentOrderDao.markClosed(order.order_id)
|
||||
} else if (wxOrder.trade_state === 'PAYERROR') {
|
||||
await paymentOrderDao.markFailed(order.order_id, wxOrder.trade_state)
|
||||
}
|
||||
|
||||
res.json(ok({ status: order.status, trade_state: wxOrder.trade_state }))
|
||||
}))
|
||||
|
||||
module.exports = router
|
||||
@@ -42,9 +42,40 @@ router.get('/subscription', requireUser, wrap(async (req, res) => {
|
||||
|
||||
router.post('/subscription/purchase', requireUser, wrap(async (req, res) => {
|
||||
const plan = req.body.plan || req.body.plan_type
|
||||
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
|
||||
const orderId = 'ORD' + Date.now()
|
||||
res.json(ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }))
|
||||
if (!PLANS[plan] || plan === 'trial') return res.json(fail(2001, 'invalid plan'))
|
||||
|
||||
const wxpay = require('../lib/wxpay')
|
||||
if (!wxpay.isConfigured()) {
|
||||
// Dev mode fallback: return mock indicator
|
||||
const orderId = 'ORD' + Date.now()
|
||||
return res.json(ok({ order_id: orderId, payment_params: null, mock: true, plan, amount: PLANS[plan].amount }))
|
||||
}
|
||||
|
||||
// Read price from server settings, not from client
|
||||
const settingsDao = require('../dao/settings.dao')
|
||||
const settings = await settingsDao.getAll()
|
||||
const priceYuan = plan === 'yearly'
|
||||
? (Number(settings.yearly_price) || PLANS[plan].amount)
|
||||
: (Number(settings.monthly_price) || PLANS[plan].amount)
|
||||
const amountFen = Math.round(priceYuan * 100)
|
||||
|
||||
const orderId = 'ORD' + Date.now() + String(Math.floor(Math.random() * 10000)).padStart(4, '0')
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
await paymentOrderDao.createOrder(orderId, req.user.user_id, plan, amountFen)
|
||||
|
||||
const prepayId = await wxpay.createPrepayOrder({
|
||||
openid: req.user.openid,
|
||||
orderId,
|
||||
amountFen,
|
||||
description: '光子美容仪-' + (plan === 'yearly' ? '年卡' : '月卡')
|
||||
})
|
||||
|
||||
await paymentOrderDao.markPrepay(orderId, prepayId)
|
||||
const paymentParams = wxpay.generatePaymentParams(prepayId)
|
||||
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'payment_create', detail: 'order: ' + orderId + ' plan: ' + plan, ip: req.ip })
|
||||
|
||||
res.json(ok({ order_id: orderId, payment_params: paymentParams, plan, amount: priceYuan }))
|
||||
}))
|
||||
|
||||
router.post('/subscription/mock-purchase', requireUser, wrap(async (req, res) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户