Merge branch 'feat/production-ready'

这个提交包含在:
Guoguo
2026-05-20 06:59:35 -07:00
当前提交 778167e47d
修改 33 个文件,包含 1057 行新增57 行删除
+28 -1
查看文件
@@ -1,5 +1,6 @@
const express = require('express')
const rateLimit = require('express-rate-limit')
const config = require('./config')
const { ok, fail } = require('./lib/response')
const { authMiddleware } = require('./middleware/auth')
@@ -28,19 +29,36 @@ 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) => {
res.header('Access-Control-Allow-Origin', '*')
const origin = config.nodeEnv === 'production'
? (process.env.CORS_ORIGIN || 'https://admin.vsai.net.cn')
: '*'
res.header('Access-Control-Allow-Origin', origin)
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
const paymentLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { code: 2001, message: 'too_many_attempts' }
})
app.use('/api/v1/auth/login', userLoginLimiter)
app.use('/api/v1/admin/login', adminLoginLimiter)
app.use('/api/v1/user/avatar', uploadLimiter)
app.use('/api/v1/user/phone', uploadLimiter)
app.use('/api/v1/subscription/purchase', paymentLimiter)
app.use('/api/v1/payment/orders', paymentLimiter)
// WeChat Pay callback — must be before authMiddleware (no JWT)
app.post('/api/v1/payment/wechat/notify', require('./routes/payment-notify'))
app.use(authMiddleware)
@@ -53,6 +71,7 @@ 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.use((req, res) => res.status(404).json(fail(404, 'not_found')))
@@ -61,4 +80,12 @@ app.use((err, req, res, _next) => {
res.status(500).json(fail(3001, 'server_error'))
})
// Clean up stale records on startup (SCF cold start)
require('./dao/payment-order.dao').closeStaleOrders().catch(err => {
console.error('[STARTUP] closeStaleOrders failed:', err.message)
})
require('./dao/binding.dao').closeExpiredPending().catch(err => {
console.error('[STARTUP] closeExpiredPending failed:', err.message)
})
module.exports = app
+13
查看文件
@@ -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 || ''
}
}
@@ -37,6 +46,10 @@ 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')
if (!config.wechat.appid) throw new Error('WECHAT_APPID must be set 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
+7
查看文件
@@ -184,11 +184,18 @@ async function countActiveByUser(userId) {
return rows[0].total
}
async function closeExpiredPending() {
return query(
'UPDATE bindings SET bind_status = 4 WHERE bind_status = 3 AND bind_expires < NOW()'
)
}
module.exports = {
findActiveByUser,
findDeviceExists,
cancelPending,
createPending,
closeExpiredPending,
confirmBind,
mockBind,
unbindByUser,
+93
查看文件
@@ -0,0 +1,93 @@
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' }
)
}
async function countPendingByUser(userId) {
const rows = await query(
"SELECT COUNT(*) AS cnt FROM payment_orders WHERE user_id = :user_id AND status IN ('created', 'paying') AND created_at > DATE_SUB(NOW(), INTERVAL 2 HOUR)",
{ user_id: userId }
)
return rows[0].cnt
}
async function closeStaleOrders() {
return query(
"UPDATE payment_orders SET status = 'closed', trade_state = 'EXPIRED' WHERE status IN ('created', 'paying') AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"
)
}
module.exports = { createOrder, findByOutTradeNo, markPrepay, markPaidAndActivateSubscription, markClosed, markFailed, countPendingByUser, closeStaleOrders }
+1 -1
查看文件
@@ -12,7 +12,7 @@ function getPool() {
password: config.db.password,
database: config.db.database,
waitForConnections: true,
connectionLimit: 5,
connectionLimit: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
namedPlaceholders: true,
timezone: '+08:00'
})
+202
查看文件
@@ -0,0 +1,202 @@
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))
}
let _cachedPrivateKey = null
function getPrivateKey() {
if (_cachedPrivateKey) return _cachedPrivateKey
let key = ''
if (config.wxpay.privateKey) {
key = config.wxpay.privateKey.replace(/\\n/g, '\n')
} else if (config.wxpay.privateKeyPath) {
key = fs.readFileSync(config.wxpay.privateKeyPath, 'utf8')
} else {
throw new Error('WeChat Pay private key not configured')
}
if (!key.includes('-----BEGIN')) throw new Error('Invalid private key format (missing PEM header)')
_cachedPrivateKey = key
return _cachedPrivateKey
}
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.setTimeout(15000, () => { req.destroy(new Error('wxpay request timeout (15s)')) })
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 }
}
let _platformCerts = {}
let _platformCertsExpiry = 0
async function fetchPlatformCertificates() {
if (_platformCertsExpiry > Date.now()) return _platformCerts
try {
const path = '/v3/certificates'
const result = await httpsRequest('GET', path)
const certs = {}
for (const item of (result.data || [])) {
const resource = item.encrypt_certificate
if (!resource) continue
const certPem = decryptResource(resource)
certs[item.serial_no] = certPem
}
if (Object.keys(certs).length > 0) {
_platformCerts = certs
_platformCertsExpiry = Date.now() + 12 * 3600 * 1000
}
} catch (err) {
console.error('[WXPAY] cert refresh failed, keeping old certs:', err.message)
if (Object.keys(_platformCerts).length === 0) throw err
}
return _platformCerts
}
async function verifyNotifySignature(headers, rawBody) {
const timestamp = headers['wechatpay-timestamp']
const nonce = headers['wechatpay-nonce']
const signature = headers['wechatpay-signature']
const serial = headers['wechatpay-serial']
if (!timestamp || !nonce || !signature || !serial) throw new Error('missing wechatpay headers')
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - parseInt(timestamp, 10)) > 300) throw new Error('notify timestamp too old (replay?)')
try {
const certs = await fetchPlatformCertificates()
const publicKey = certs[serial]
if (!publicKey) throw new Error('unknown platform certificate serial: ' + serial)
const message = timestamp + '\n' + nonce + '\n' + rawBody + '\n'
const verify = crypto.createVerify('RSA-SHA256')
verify.update(message)
if (!verify.verify(publicKey, signature, 'base64')) {
throw new Error('notify signature verification failed')
}
} catch (err) {
console.error('[WXPAY] signature verification failed:', err.message)
throw err
}
return true
}
function decryptResource(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 decrypted
}
function decryptNotifyResource(resource) {
return JSON.parse(decryptResource(resource))
}
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
}
+43
查看文件
@@ -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 {
if (!Buffer.isBuffer(req.body)) throw new Error('invalid body format')
const rawBody = req.body.toString('utf8')
const parsed = JSON.parse(rawBody)
await 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)
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: 'processing error' })
}
}
module.exports = handleNotify
+50
查看文件
@@ -0,0 +1,50 @@
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') {
if (!wxOrder.amount || wxOrder.amount.payer_total !== order.amount_fen) {
return res.json(fail(2001, 'amount mismatch'))
}
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
+38 -3
查看文件
@@ -1,3 +1,4 @@
const crypto = require('crypto')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../middleware/auth')
@@ -42,9 +43,43 @@ 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')
const config = require('../config')
if (!wxpay.isConfigured()) {
if (config.nodeEnv === 'production') return res.json(fail(2001, 'payment not configured'))
const orderId = 'ORD' + Date.now()
return res.json(ok({ order_id: orderId, payment_params: null, mock: true, plan, amount: PLANS[plan].amount }))
}
const paymentOrderDao = require('../dao/payment-order.dao')
const pendingCount = await paymentOrderDao.countPendingByUser(req.user.user_id)
if (pendingCount >= 5) return res.json(fail(2001, '待支付订单过多,请先完成或取消现有订单'))
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() + crypto.randomBytes(6).toString('hex')
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) => {