From 78ea1a03c5bf217a1531dce535941377f2ffd037 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Mon, 18 May 2026 03:07:59 -0700 Subject: [PATCH] feat: WeChat Pay V3 integration (fill credentials to activate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + .../pages/subscribe-plans/subscribe-plans.js | 68 ++++++-- miniprogram/utils/api.js | 4 + server/.env.example | 4 +- server/sql/schema.sql | 18 +++ server/src/app.js | 3 + server/src/config.js | 9 ++ server/src/dao/payment-order.dao.js | 79 ++++++++++ server/src/lib/wxpay.js | 147 ++++++++++++++++++ server/src/routes/payment-notify.js | 43 +++++ server/src/routes/payment.js | 47 ++++++ server/src/routes/subscription.js | 37 ++++- 12 files changed, 449 insertions(+), 13 deletions(-) create mode 100644 server/src/dao/payment-order.dao.js create mode 100644 server/src/lib/wxpay.js create mode 100644 server/src/routes/payment-notify.js create mode 100644 server/src/routes/payment.js diff --git a/.gitignore b/.gitignore index 83ff3bb..041db3d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs/reference/小程序及后台管理软件开发资料/ *.docx !docs/protocols/协议简述.docx cloud/sql/ +certs/ +*.pem +*.p12 diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js index 32097cc..f77239c 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.js +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -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) }) } }) diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js index e2d1d71..b19d3bc 100644 --- a/miniprogram/utils/api.js +++ b/miniprogram/utils/api.js @@ -23,6 +23,10 @@ module.exports = { purchase: function (plan) { return http.post('/api/v1/subscription/purchase', { plan: plan }) }, mockPurchase: function (plan) { return http.post('/api/v1/subscription/mock-purchase', { plan: plan }) }, + // Payment + getPaymentOrder: function (orderId) { return http.get('/api/v1/payment/orders/' + orderId) }, + syncPaymentOrder: function (orderId) { return http.post('/api/v1/payment/orders/' + orderId + '/sync') }, + // Treatment getRecords: function (params) { return http.get('/api/v1/treatment/history', params) }, syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) }, diff --git a/server/.env.example b/server/.env.example index 1359036..869b9f5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -25,11 +25,13 @@ ADMIN_PASSWORD=admin CORS_ORIGIN=https://admin.vsai.net.cn +# WeChat Pay V3 WX_MCH_ID= WX_MCH_API_V3_KEY= WX_MCH_SERIAL_NO= +WX_MCH_PRIVATE_KEY= WX_MCH_PRIVATE_KEY_PATH= -WX_PAY_NOTIFY_URL= +WX_PAY_NOTIFY_URL=https://api.vsai.net.cn/api/v1/payment/wechat/notify DB_POOL_SIZE=10 diff --git a/server/sql/schema.sql b/server/sql/schema.sql index fa3a37c..da42a8f 100644 --- a/server/sql/schema.sql +++ b/server/sql/schema.sql @@ -159,6 +159,24 @@ CREATE TABLE IF NOT EXISTS firmware_files ( KEY idx_firmware_version (version, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS payment_orders ( + order_id VARCHAR(64) NOT NULL COMMENT 'out_trade_no', + user_id BIGINT UNSIGNED NOT NULL, + plan VARCHAR(32) NOT NULL, + amount_fen INT UNSIGNED NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'created' COMMENT 'created/paying/paid/closed/failed/refunded', + prepay_id VARCHAR(128) NOT NULL DEFAULT '', + transaction_id VARCHAR(64) NOT NULL DEFAULT '', + trade_state VARCHAR(32) NOT NULL DEFAULT '', + raw_notify_json JSON NULL, + paid_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (order_id), + KEY idx_payment_orders_user (user_id, status), + CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_id) REFERENCES users (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES ('system_name', '"光子美容仪后台"'), ('monthly_price', '99'), diff --git a/server/src/app.js b/server/src/app.js index 4c47ee0..82f23dd 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -29,6 +29,7 @@ const uploadLimiter = rateLimit({ message: { code: 2001, message: 'too_many_attempts' } }) +app.use('/api/v1/payment/wechat/notify', express.raw({ type: 'application/json' })) app.use(express.json()) app.use((req, res, next) => { const origin = config.nodeEnv === 'production' @@ -57,6 +58,8 @@ app.use('/api/v1', require('./routes/subscription')) 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 d447425..df696bd 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -30,6 +30,15 @@ const config = { admin: { username: process.env.ADMIN_USERNAME || 'admin', password: process.env.ADMIN_PASSWORD || 'admin' + }, + wxpay: { + appid: process.env.WECHAT_APPID, + mchId: process.env.WX_MCH_ID || '', + apiV3Key: process.env.WX_MCH_API_V3_KEY || '', + mchSerialNo: process.env.WX_MCH_SERIAL_NO || '', + privateKey: process.env.WX_MCH_PRIVATE_KEY || '', + privateKeyPath: process.env.WX_MCH_PRIVATE_KEY_PATH || '', + notifyUrl: process.env.WX_PAY_NOTIFY_URL || '' } } diff --git a/server/src/dao/payment-order.dao.js b/server/src/dao/payment-order.dao.js new file mode 100644 index 0000000..f3d5662 --- /dev/null +++ b/server/src/dao/payment-order.dao.js @@ -0,0 +1,79 @@ +const { query, one, transaction } = require('../lib/db') + +async function createOrder(orderId, userId, plan, amountFen) { + return query( + 'INSERT INTO payment_orders (order_id, user_id, plan, amount_fen, status) VALUES (:order_id, :user_id, :plan, :amount_fen, :status)', + { order_id: orderId, user_id: userId, plan, amount_fen: amountFen, status: 'created' } + ) +} + +async function findByOutTradeNo(orderId) { + return one('SELECT * FROM payment_orders WHERE order_id = :order_id', { order_id: orderId }) +} + +async function markPrepay(orderId, prepayId) { + return query( + 'UPDATE payment_orders SET status = :status, prepay_id = :prepay_id WHERE order_id = :order_id AND status = :old_status', + { order_id: orderId, status: 'paying', prepay_id: prepayId, old_status: 'created' } + ) +} + +// Idempotent: only activates if order is not already paid +async function markPaidAndActivateSubscription(orderId, transactionId, rawNotify) { + const subscriptionDao = require('./subscription.dao') + return transaction(async conn => { + const [rows] = await conn.execute( + 'SELECT * FROM payment_orders WHERE order_id = ? AND status != ? FOR UPDATE', + [orderId, 'paid'] + ) + if (rows.length === 0) return false // already paid or not found + const order = rows[0] + if (order.status === 'paid') return false // double check + + await conn.execute( + 'UPDATE payment_orders SET status = ?, transaction_id = ?, trade_state = ?, raw_notify_json = ?, paid_at = NOW() WHERE order_id = ?', + ['paid', transactionId, 'SUCCESS', JSON.stringify(rawNotify), orderId] + ) + + // Activate subscription using the plan from the order + const PLAN_DAYS = { monthly: 30, yearly: 365 } + const days = PLAN_DAYS[order.plan] || 30 + // Use raw conn for the subscription activation within the same transaction + const [subRows] = await conn.execute( + 'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', + [order.user_id] + ) + if (subRows.length > 0) { + await conn.execute( + 'UPDATE subscriptions SET expire_time = DATE_ADD(expire_time, INTERVAL ? DAY), plan = ?, amount = amount + ? WHERE subscription_id = ?', + [days, order.plan, order.amount_fen / 100, subRows[0].subscription_id] + ) + } else { + await conn.execute( + 'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', + [order.user_id] + ) + await conn.execute( + 'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', + [order.user_id, order.plan, order.amount_fen / 100, orderId, days] + ) + } + return true + }) +} + +async function markClosed(orderId) { + return query( + 'UPDATE payment_orders SET status = :status, trade_state = :trade_state WHERE order_id = :order_id AND status IN (:s1, :s2)', + { order_id: orderId, status: 'closed', trade_state: 'CLOSED', s1: 'created', s2: 'paying' } + ) +} + +async function markFailed(orderId, tradeState) { + return query( + 'UPDATE payment_orders SET status = :status, trade_state = :trade_state WHERE order_id = :order_id AND status IN (:s1, :s2)', + { order_id: orderId, status: 'failed', trade_state: tradeState || 'PAYERROR', s1: 'created', s2: 'paying' } + ) +} + +module.exports = { createOrder, findByOutTradeNo, markPrepay, markPaidAndActivateSubscription, markClosed, markFailed } diff --git a/server/src/lib/wxpay.js b/server/src/lib/wxpay.js new file mode 100644 index 0000000..27d01e6 --- /dev/null +++ b/server/src/lib/wxpay.js @@ -0,0 +1,147 @@ +const crypto = require('crypto') +const https = require('https') +const fs = require('fs') +const config = require('../config') + +function isConfigured() { + const c = config.wxpay + return !!(c.mchId && c.apiV3Key && c.mchSerialNo && (c.privateKey || c.privateKeyPath)) +} + +function getPrivateKey() { + if (config.wxpay.privateKey) return config.wxpay.privateKey + if (config.wxpay.privateKeyPath) return fs.readFileSync(config.wxpay.privateKeyPath, 'utf8') + throw new Error('WeChat Pay private key not configured') +} + +function generateNonce() { + return crypto.randomBytes(16).toString('hex') +} + +function buildSignMessage(method, url, timestamp, nonce, body) { + return method + '\n' + url + '\n' + timestamp + '\n' + nonce + '\n' + (body || '') + '\n' +} + +function signSHA256WithRSA(message) { + const sign = crypto.createSign('RSA-SHA256') + sign.update(message) + return sign.sign(getPrivateKey(), 'base64') +} + +function buildAuthHeader(method, url, body) { + const timestamp = Math.floor(Date.now() / 1000).toString() + const nonce = generateNonce() + const message = buildSignMessage(method, url, timestamp, nonce, body || '') + const signature = signSHA256WithRSA(message) + return 'WECHATPAY2-SHA256-RSA2048 mchid="' + config.wxpay.mchId + '",nonce_str="' + nonce + '",signature="' + signature + '",timestamp="' + timestamp + '",serial_no="' + config.wxpay.mchSerialNo + '"' +} + +function httpsRequest(method, path, body) { + return new Promise((resolve, reject) => { + const bodyStr = body ? JSON.stringify(body) : '' + const auth = buildAuthHeader(method, path, bodyStr) + const options = { + hostname: 'api.mch.weixin.qq.com', + port: 443, + path: path, + method: method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': auth, + 'User-Agent': 'jw-beauty-server/1.0' + } + } + if (bodyStr) options.headers['Content-Length'] = Buffer.byteLength(bodyStr) + + const req = https.request(options, res => { + let raw = '' + res.on('data', chunk => { raw += chunk }) + res.on('end', () => { + try { + const data = JSON.parse(raw) + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(data) + } else { + reject(new Error(data.message || 'wxpay request failed: ' + res.statusCode)) + } + } catch (e) { + reject(new Error('wxpay response parse error')) + } + }) + }) + req.on('error', reject) + if (bodyStr) req.write(bodyStr) + req.end() + }) +} + +async function createPrepayOrder({ openid, orderId, amountFen, description }) { + const path = '/v3/pay/transactions/jsapi' + const body = { + appid: config.wxpay.appid || config.wechat.appid, + mchid: config.wxpay.mchId, + description: description || '光子美容仪订阅', + out_trade_no: orderId, + notify_url: config.wxpay.notifyUrl, + amount: { total: amountFen, currency: 'CNY' }, + payer: { openid: openid } + } + const result = await httpsRequest('POST', path, body) + if (!result.prepay_id) throw new Error('prepay_id not returned') + return result.prepay_id +} + +function generatePaymentParams(prepayId) { + const appId = config.wxpay.appid || config.wechat.appid + const timeStamp = Math.floor(Date.now() / 1000).toString() + const nonceStr = generateNonce() + const pkg = 'prepay_id=' + prepayId + const message = appId + '\n' + timeStamp + '\n' + nonceStr + '\n' + pkg + '\n' + const paySign = signSHA256WithRSA(message) + return { timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign } +} + +function verifyNotifySignature(headers, rawBody) { + // For full implementation, need WeChat platform certificate to verify + // For now, decrypt and validate content + const timestamp = headers['wechatpay-timestamp'] + const nonce = headers['wechatpay-nonce'] + const signature = headers['wechatpay-signature'] + const serial = headers['wechatpay-serial'] + if (!timestamp || !nonce || !signature) throw new Error('missing wechatpay headers') + // Note: Full signature verification requires downloading WeChat's platform certificate + // and verifying with it. For MVP, we verify the decrypted content instead. + return true +} + +function decryptNotifyResource(resource) { + if (!resource || !resource.ciphertext) throw new Error('invalid notify resource') + const { ciphertext, nonce, associated_data } = resource + const key = Buffer.from(config.wxpay.apiV3Key, 'utf8') + const iv = Buffer.from(nonce, 'utf8') + const aad = Buffer.from(associated_data || '', 'utf8') + const data = Buffer.from(ciphertext, 'base64') + const authTag = data.slice(data.length - 16) + const encrypted = data.slice(0, data.length - 16) + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(authTag) + decipher.setAAD(aad) + let decrypted = decipher.update(encrypted, null, 'utf8') + decrypted += decipher.final('utf8') + return JSON.parse(decrypted) +} + +async function queryOrder(orderId) { + const path = '/v3/pay/transactions/out-trade-no/' + orderId + '?mchid=' + config.wxpay.mchId + return httpsRequest('GET', path) +} + +module.exports = { + isConfigured, + createPrepayOrder, + generatePaymentParams, + verifyNotifySignature, + decryptNotifyResource, + queryOrder +} diff --git a/server/src/routes/payment-notify.js b/server/src/routes/payment-notify.js new file mode 100644 index 0000000..ffa58be --- /dev/null +++ b/server/src/routes/payment-notify.js @@ -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 diff --git a/server/src/routes/payment.js b/server/src/routes/payment.js new file mode 100644 index 0000000..178f601 --- /dev/null +++ b/server/src/routes/payment.js @@ -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 diff --git a/server/src/routes/subscription.js b/server/src/routes/subscription.js index 03a1ed7..9c900c8 100644 --- a/server/src/routes/subscription.js +++ b/server/src/routes/subscription.js @@ -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) => {