小程序接入支付功能

This commit is contained in:
Yeomai committed 2026-09-29 01:14:24 +08:00
1 parent edf1e51846
commit 07a90e860d
38 files changed
+3644 -90

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
// 激活码生成:16 位、4 组、80 位随机
// 字符表去掉了容易看错的 I/L/O/U,正好 32 个,刚好整除 256,取模不偏
const crypto = require('crypto')
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
function newActivationCode() {
const raw = Array.from(crypto.randomBytes(16), (b) => ALPHABET[b % ALPHABET.length]).join('')
return (raw.match(/.{4}/g) || []).join('-')
}
module.exports = { newActivationCode, ALPHABET }
+19
View File
@@ -0,0 +1,19 @@
// 复制本文件为同目录下的 config.js 再填值(config.js 不入库)
// 也可以用环境变量 PAY_APP_ID / PAY_APP_SECRET / PAY_OFFER_ID / PAY_APP_KEY 覆盖
module.exports = {
appId: 'wxb2de4030b2cff601', // MP 后台 → 设置 → 基本设置
appSecret: '', // MP 后台 → 设置 → 开发设置 → 小程序密钥(code2Session 要用)
offerId: '1450663371', // MP 后台 → 虚拟支付 → 基本配置
appKey: '', // MP 后台 → 虚拟支付 → 基本配置 → 现网 AppKey
sandboxAppKey: '', // 同上「沙箱AppKey」,只在开发者工具里用沙箱(env=1)时需要
pushToken: '', // MP 后台 → 开发管理 → 消息推送 → Token(要和后台填的一模一样)
// 配了 db 就走 MySQL(宝塔 → 数据库里建库和账号),不配就继续用 data/orders.jsonl
db: {
host: '127.0.0.1',
port: 3306,
user: '',
password: '',
database: '',
},
}
+14
View File
@@ -0,0 +1,14 @@
// 页面文案的默认值。数据库 settings 表里的同名配置会覆盖它,所以改文案不用发版
// 备注会写进库里,方便在 phpMyAdmin 里看清每个配置显示在哪
const COPY = {
'tab.buy': { value: '开通', comment: '底部「开通」标签的文字' },
'buy.title': { value: '选择套餐', comment: '开通页的大标题' },
'buy.subtitle': { value: '一次付费,激活码自动发放到「我的」', comment: '开通页副标题(普通用户看到的)' },
'buy.button': { value: '购买', comment: '套餐卡片上的按钮文字' },
'buy.tip': {
value: '购买成功后激活码会自动发放,在「我的」里可以随时查看和复制。同一笔订单重复点只会拿到同一张码,不用担心重复扣款。',
comment: '开通页底部说明',
},
}
module.exports = { COPY }
+241
View File
@@ -0,0 +1,241 @@
// MySQL 连接池、建表、字段备注同步
// 表结构写成数据(下面的 TABLES),建表语句和「备注同步」都由它生成,不会两处写法对不上
const fileConfig = require('./read-config.js')
const { COPY } = require('./copy.js')
const settings = {
host: process.env.PAY_DB_HOST || (fileConfig.db && fileConfig.db.host) || '127.0.0.1',
port: Number(process.env.PAY_DB_PORT || (fileConfig.db && fileConfig.db.port) || 3306),
user: process.env.PAY_DB_USER || (fileConfig.db && fileConfig.db.user) || '',
password: process.env.PAY_DB_PASSWORD || (fileConfig.db && fileConfig.db.password) || '',
database: process.env.PAY_DB_NAME || (fileConfig.db && fileConfig.db.database) || '',
}
// 默认:配了库名和账号就走 MySQL,否则继续用 JSONL 文件
// PAY_STORE=jsonl / mysql 可以强制指定(自测强制用文件,不碰你的库)
const useMysql = process.env.PAY_STORE ? process.env.PAY_STORE === 'mysql' : Boolean(settings.database && settings.user)
const TABLES = {
orders: {
comment: '订单表,金额单位分',
columns: [
{ name: 'out_trade_no', type: 'VARCHAR(32)', notNull: true, comment: '业务单号,幂等键' },
{ name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' },
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' },
{ name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' },
{ name: 'amount', type: 'INT', notNull: true, default: '0', comment: '下单金额(分)' },
{ name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'created'", comment: 'created 已下单 delivered 已发货' },
{ name: 'wx_order_id', type: 'VARCHAR(64)', comment: '平台单号,唯一' },
{ name: 'paid_fee', type: 'INT', comment: '实付金额(分)' },
{ name: 'notified', type: 'TINYINT', notNull: true, default: '0', comment: '已通知平台发货' },
{ name: 'refunded', type: 'TINYINT', notNull: true, default: '0', comment: '已退款' },
{ name: 'refund_order_id', type: 'VARCHAR(32)', comment: '退款单号' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '下单时间' },
{ name: 'delivered_at', type: 'DATETIME(3)', comment: '发货时间' },
{ name: 'refunded_at', type: 'DATETIME(3)', comment: '退款时间' },
],
keys: [
'PRIMARY KEY (out_trade_no)',
'UNIQUE KEY uniq_wx_order (wx_order_id)',
'KEY idx_openid (openid)',
'KEY idx_pending (refunded, created_at)',
],
},
codes: {
comment: '激活码表,退款只作废不删行',
columns: [
{ name: 'code', type: 'VARCHAR(24)', notNull: true, comment: '激活码,主键' },
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID(等级)' },
{ name: 'out_trade_no', type: 'VARCHAR(32)', comment: '绑定订单号,唯一' },
{ name: 'openid', type: 'VARCHAR(64)', comment: '归属用户 openid' },
{ name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' },
{ name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'issued'", comment: 'issued 已发出 revoked 已作废' },
{ name: 'batch', type: 'VARCHAR(32)', comment: '批次号,备用' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '生成时间' },
{ name: 'bound_at', type: 'DATETIME(3)', comment: '绑定时间' },
{ name: 'used_at', type: 'DATETIME(3)', comment: '核销时间,暂无核销' },
{ name: 'revoked_at', type: 'DATETIME(3)', comment: '作废时间' },
],
keys: ['PRIMARY KEY (code)', 'UNIQUE KEY uniq_order (out_trade_no)', 'KEY idx_openid (openid)'],
},
goods: {
comment: '道具价目表,价格需与 MP 后台一致',
columns: [
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' },
{ name: 'name', type: 'VARCHAR(64)', notNull: true, comment: '显示名' },
{ name: 'price', type: 'INT', notNull: true, comment: '单价(分)' },
{ name: 'enabled', type: 'TINYINT', notNull: true, default: '1', comment: '0 下架 1 在售' },
{ name: 'admin_only', type: 'TINYINT', notNull: true, default: '0', comment: '1 仅管理员可购买' },
{ name: 'note', type: 'VARCHAR(255)', comment: '购买页提示文案,例如优惠信息' },
{ name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' },
],
keys: ['PRIMARY KEY (product_id)'],
},
users: {
comment: '用户表,激活码看 codes 表',
columns: [
{ name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '首次使用时间' },
{ name: 'last_seen_at', type: 'DATETIME(3)', notNull: true, comment: '最近一次活跃' },
{ name: 'is_admin', type: 'TINYINT', notNull: true, default: '0', comment: '0 普通 1 管理员' },
],
keys: ['PRIMARY KEY (openid)', 'KEY idx_created (created_at)'],
},
settings: {
comment: '页面文案配置,改完最多 1 分钟生效',
columns: [
{ name: 'cfg_key', type: 'VARCHAR(64)', notNull: true, comment: '配置项,如 buy.title' },
{ name: 'cfg_value', type: 'VARCHAR(255)', notNull: true, comment: '文案内容' },
{ name: 'remark', type: 'VARCHAR(255)', comment: '这个配置显示在哪里' },
{ name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' },
],
keys: ['PRIMARY KEY (cfg_key)'],
},
}
let pool = null
function getPool() {
if (pool) return pool
let mysql
try {
mysql = require('mysql2/promise')
} catch (err) {
throw new Error('没装 mysql2,请先在 server 目录执行 npm install')
}
pool = mysql.createPool(
Object.assign({}, settings, {
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 5,
supportBigNumbers: true,
bigNumberStrings: false, // 大整数当数字返回,别变字符串
})
)
return pool
}
const quoteComment = (text) => String(text).replace(/'/g, "''")
function columnSql(column) {
return [
`\`${column.name}\` ${column.type}`,
column.notNull ? 'NOT NULL' : 'NULL',
column.default === undefined ? '' : `DEFAULT ${column.default}`,
`COMMENT '${quoteComment(column.comment)}'`,
]
.filter(Boolean)
.join(' ')
}
// 早期版本用 BIGINT 存毫秒时间戳,这里自动换成 DATETIME(3),跑一次就没了
async function upgradeTimeColumns(db) {
const [columns] = await db.query(
`SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND DATA_TYPE = 'bigint' AND COLUMN_NAME LIKE '%\\_at'`,
[settings.database]
)
for (const column of columns) {
const table = column.TABLE_NAME
const name = column.COLUMN_NAME
await db.query(`ALTER TABLE \`${table}\` ADD COLUMN \`${name}_new\` DATETIME(3) NULL`)
await db.query(
`UPDATE \`${table}\` SET \`${name}_new\` = FROM_UNIXTIME(\`${name}\` / 1000) WHERE \`${name}\` IS NOT NULL`
)
await db.query(`ALTER TABLE \`${table}\` DROP COLUMN \`${name}\``)
await db.query(`ALTER TABLE \`${table}\` CHANGE \`${name}_new\` \`${name}\` DATETIME(3) NULL`)
}
return columns.length
}
// 表已经存在时建表语句不会生效,所以单独把「字段备注 + 表备注」同步一遍
// ponytail: 一个 ALTER 会把整表重建,数据量小(几百行)无所谓;真到几十万行再改成按需比对
async function syncComments(db, name, table) {
const [rows] = await db.query(
`SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
const current = new Map(rows.map((row) => [row.COLUMN_NAME, row.COLUMN_COMMENT]))
const stale = table.columns.filter((column) => current.has(column.name) && current.get(column.name) !== column.comment)
if (stale.length) {
await db.query(`ALTER TABLE \`${name}\` ${stale.map((column) => 'MODIFY COLUMN ' + columnSql(column)).join(', ')}`)
}
const [tableRows] = await db.query(
`SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
if (tableRows.length && tableRows[0].TABLE_COMMENT !== table.comment) {
await db.query(`ALTER TABLE \`${name}\` COMMENT = '${quoteComment(table.comment)}'`)
}
return stale.length
}
// 表已存在时建表语句改不了结构,所以按声明把缺的列补上(只加列,不动已有数据)
// 注意:以后新增 NOT NULL 列一定要带 DEFAULT,否则在非空表上会加不进去
async function addMissingColumns(db, name, table) {
const [rows] = await db.query(
`SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
const existing = new Set(rows.map((row) => row.COLUMN_NAME))
const missing = table.columns.filter((column) => !existing.has(column.name))
for (const column of missing) {
await db.query(`ALTER TABLE \`${name}\` ADD COLUMN ${columnSql(column)}`)
}
return missing.length
}
async function initSchema(goodsSeed) {
const db = getPool()
for (const [name, table] of Object.entries(TABLES)) {
const columns = table.columns.map(columnSql).join(', ')
await db.query(
`CREATE TABLE IF NOT EXISTS \`${name}\` (${columns}, ${table.keys.join(', ')}) ` +
`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='${quoteComment(table.comment)}'`
)
}
let added = 0
for (const [name, table] of Object.entries(TABLES)) added += await addMissingColumns(db, name, table)
if (added) console.log(`已给已有表补上 ${added} 个字段`)
const converted = await upgradeTimeColumns(db)
if (converted) console.log(`已把 ${converted} 个时间列从时间戳改成可读时间`)
let commented = 0
for (const [name, table] of Object.entries(TABLES)) commented += await syncComments(db, name, table)
if (commented) console.log(`已同步 ${commented} 个字段备注`)
// 价目表空的时候用代码里的价目表播种一次;之后改价直接改库(记得 MP 后台一起改)
const [rows] = await db.query('SELECT COUNT(*) AS n FROM goods')
if (rows[0].n === 0) {
for (const productId of Object.keys(goodsSeed)) {
const goods = goodsSeed[productId]
await db.query('INSERT IGNORE INTO goods (product_id, name, price, enabled, updated_at) VALUES (?, ?, ?, 1, ?)', [
String(productId),
goods.name,
goods.price,
new Date(),
])
}
}
// admin_only 以代码里的价目表为准(价格、名称还是以库里为准,方便改价不发版)
for (const productId of Object.keys(goodsSeed)) {
await db.query('UPDATE goods SET admin_only = ? WHERE product_id = ?', [
goodsSeed[productId].adminOnly ? 1 : 0,
String(productId),
])
}
// 文案配置:缺哪个补哪个,只刷新备注列,不覆盖你已经改过的 cfg_value
for (const [key, item] of Object.entries(COPY)) {
await db.query(
`INSERT INTO settings (cfg_key, cfg_value, remark, updated_at) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE remark = VALUES(remark)`,
[key, item.value, item.comment, new Date()]
)
}
return db
}
module.exports = { settings, useMysql, getPool, initSchema }
+582
View File
@@ -0,0 +1,582 @@
// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
// 启动:node server/index.js(默认端口 3000,用 PORT 覆盖)
const http = require('http')
const https = require('https')
const crypto = require('crypto')
const { GOODS, newOutTradeNo, buildSignData, paySig, userSignature } = require('./sign.js')
const store = require('./store.js')
const { COPY } = require('./copy.js')
// 密钥优先读环境变量,其次读同目录 config.js(config.js 不入库)
const fileConfig = require('./read-config.js')
const config = {
appId: process.env.PAY_APP_ID || fileConfig.appId,
appSecret: process.env.PAY_APP_SECRET || fileConfig.appSecret,
offerId: process.env.PAY_OFFER_ID || fileConfig.offerId,
appKey: process.env.PAY_APP_KEY || fileConfig.appKey,
sandboxAppKey: process.env.PAY_SANDBOX_APP_KEY || fileConfig.sandboxAppKey,
pushToken: process.env.PAY_PUSH_TOKEN || fileConfig.pushToken,
}
const WX_ORIGIN = process.env.WX_API_ORIGIN || 'https://api.weixin.qq.com'
const XPAY_QUERY_URI = '/xpay/query_order'
const XPAY_NOTIFY_URI = '/xpay/notify_provide_goods'
const XPAY_REFUND_URI = '/xpay/refund_order'
const PORT = Number(process.env.PORT ?? 3000)
const MAX_BODY = 4096
// 定时查单兜底的间隔,设 0 关掉;回看窗口限制一下,免得半路放弃的单被无限查下去
const POLL_MS = Number(process.env.PAY_POLL_MS ?? 5 * 60 * 1000)
const PENDING_WINDOW_MS = 24 * 60 * 60 * 1000
// 价目表和页面文案都以数据库为准(表里没写就退回代码里的默认值),缓存 60 秒
let configCache = { at: 0, goods: [], copy: {} }
async function refreshConfig() {
const fromDb = await store.listGoods()
const list = fromDb.length
? fromDb
: Object.keys(GOODS).map((id) => ({
productId: id,
name: GOODS[id].name,
price: GOODS[id].price,
enabled: true,
adminOnly: Boolean(GOODS[id].adminOnly),
note: GOODS[id].note || '',
}))
// 文案配置读不到不该挡住下单,退回默认文案就行
const settings = await store.getSettings().catch((err) => {
console.error('读取文案配置失败:', err.message)
return {}
})
const copy = {}
for (const [key, item] of Object.entries(COPY)) copy[key] = settings[key] || item.value
configCache = {
at: Date.now(),
goods: list.slice().sort((a, b) => a.price - b.price), // 价格从低到高,页面和后台顺序一致
copy,
}
}
async function currentConfig() {
if (Date.now() - configCache.at >= 60000) await refreshConfig()
return configCache
}
const allGoods = async () => (await currentConfig()).goods
const allCopy = async () => (await currentConfig()).copy
const goodsOf = async (productId) =>
(await allGoods()).find((goods) => String(goods.productId) === String(productId) && goods.enabled) || null
const nameOf = async (productId) => ((await goodsOf(productId)) || {}).name || String(productId)
// 管理员判定:只看用户表的 is_admin(查不到就按普通用户算)
async function isAdmin(openid) {
const user = await store.getUser(openid)
return Boolean(user && user.isAdmin)
}
const sha1 = (text) => crypto.createHash('sha1').update(text).digest('hex')
const isPaid = (status) => status >= 2 && status <= 4 // 2 已支付待发货 / 3 发货中 / 4 已发货
// 从推送的 XML 里取字段,值可能在 CDATA 里
function xmlTag(xml, name) {
const matched = new RegExp(`<${name}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${name}>`).exec(xml)
return matched ? matched[1].trim() : ''
}
// 0 现网、1 沙箱;两套 AppKey 不能混用,混了会报 -15006 签名错误
function appKeyOf(env) {
if (env === 1) {
if (!config.sandboxAppKey) throw new Error('没配沙箱 AppKey(sandboxAppKey),沙箱下单用不了')
return config.sandboxAppKey
}
return config.appKey
}
function request(url, body) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https:') ? https : http
const options = {
method: body ? 'POST' : 'GET',
// 请求体原样发出,pay_sig 是对这串原文算的
headers: body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {},
}
const req = client.request(url, options, (res) => {
let raw = ''
res.on('data', (chunk) => (raw += chunk))
res.on('end', () => {
if (!raw.trim()) return resolve({}) // 有的接口成功时返回体是空的
try {
resolve(JSON.parse(raw))
} catch (err) {
reject(new Error('微信接口返回的不是 JSON:' + raw.slice(0, 200)))
}
})
})
req.on('error', reject)
req.end(body || undefined)
})
}
// wx.login 的 code 换 openid + session_key
async function code2Session(code) {
if (!code) throw new Error('缺少 code(前端要先 wx.login)')
const res = await request(
`${WX_ORIGIN}/sns/jscode2session?appid=${config.appId}&secret=${config.appSecret}` +
`&js_code=${code}&grant_type=authorization_code`
)
if (!res.openid) throw new Error(`code2Session 失败:${res.errcode} ${res.errmsg}`)
// 顺手记一笔用户:首次出现建行,之后只更新活跃时间。这里失败不该挡住支付
try {
await store.touchUser(res.openid)
} catch (err) {
console.error('记录用户失败:', err.message)
}
return res
}
let tokenCache = { value: '', expireAt: 0 }
async function getAccessToken() {
if (tokenCache.value && Date.now() < tokenCache.expireAt) return tokenCache.value
const res = await request(
`${WX_ORIGIN}/cgi-bin/token?grant_type=client_credential&appid=${config.appId}&secret=${config.appSecret}`
)
if (!res.access_token) throw new Error(`获取 access_token 失败:${res.errcode} ${res.errmsg}`)
tokenCache = { value: res.access_token, expireAt: Date.now() + (res.expires_in - 300) * 1000 }
return tokenCache.value
}
// 下单:生成业务单号 + 双签名,返回给前端去拉起支付
async function createPay(productId, code, env) {
const goods = await goodsOf(productId)
if (!goods) throw new Error('未知道具 ID:' + productId)
const appKey = appKeyOf(env)
const session = await code2Session(code)
// 沙箱能免费拿货、补单道具是测试价,这两件事只有管理员能用(不能只靠前端藏按钮)
const admin = await isAdmin(session.openid)
if (goods.adminOnly && !admin) throw new Error(`「${goods.name}」只有管理员能购买`)
if (env === 1 && !admin) throw new Error('沙箱环境只有管理员能用')
const outTradeNo = newOutTradeNo()
const signData = buildSignData({
offerId: config.offerId,
productId,
price: goods.price,
outTradeNo,
attach: 'test-' + productId,
env,
})
// 先落一条「已创建」,发货时靠它认人(防止别人拿单号来领别人的货)
await store.saveOrder({
outTradeNo,
openid: session.openid,
productId: String(productId),
env,
amount: goods.price,
status: 'created',
createdAt: Date.now(),
})
return {
outTradeNo,
env,
payData: {
signData,
paySig: paySig(appKey, 'requestVirtualPayment', signData),
signature: userSignature(session.session_key, signData),
mode: 'short_series_goods',
},
}
}
// 查单:支付成功回调可能丢,用这个确认真实订单状态
async function queryOrderRaw(outTradeNo, openid, env) {
const token = await getAccessToken()
const body = JSON.stringify({ openid, env, order_id: outTradeNo })
const url = `${WX_ORIGIN}${XPAY_QUERY_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_QUERY_URI, body)}`
const res = await request(url, body)
return { errcode: res.errcode, errmsg: res.errmsg, order: res.order }
}
// 通知平台「已发货」。文档的调用示例里只写了 access_token,但平台实际要 pay_sig
// (少了会回 268490002 签名字段[pay_sig]为空),所以按查单那套一起签上
async function notifyProvideGoods(record) {
const env = record.env || 0
const token = await getAccessToken()
const payload = { order_id: record.outTradeNo, env }
if (record.wxOrderId) payload.wx_order_id = record.wxOrderId // 查单没给内部单号就别塞空串
const body = JSON.stringify(payload)
const url = `${WX_ORIGIN}${XPAY_NOTIFY_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_NOTIFY_URI, body)}`
const res = await request(url, body)
console.log(new Date().toISOString(), 'notify_provide_goods', body, '->', JSON.stringify(res))
if (res.errcode) throw new Error(`通知平台发货失败:${res.errcode} ${res.errmsg}`)
await store.markNotified(record.outTradeNo)
}
// 平台通知失败不算发货失败:钱已经付了,激活码先给到用户,下次调用再补通知
async function tryNotify(record) {
if (record.notified) return { ok: true }
// 沙箱是模拟环境,平台侧不需要真实发货回执,跳过通知免得反复白调接口
if ((record.env || 0) === 1) {
await store.markNotified(record.outTradeNo)
return { ok: true }
}
try {
await notifyProvideGoods(record)
return { ok: true }
} catch (err) {
console.error('通知平台发货失败:', err.message)
return { ok: false, error: err.message }
}
}
// 发激活码 + 通知平台。不依赖前端登录,所以定时补发货和发货推送也能复用
async function deliverRecord(record, order) {
let detail = order
if (!detail) {
const queried = await queryOrderRaw(record.outTradeNo, record.openid, record.env || 0)
detail = queried.order || {}
}
const delivered = await store.assignCode(record.outTradeNo, {
wxOrderId: detail.wx_order_id || record.wxOrderId || '',
paidFee: detail.paid_fee || 0,
})
const notify = await tryNotify(delivered)
return {
paid: true,
status: detail.status || 2,
code: delivered.code,
productId: delivered.productId,
productName: await nameOf(delivered.productId),
notified: notify.ok,
notifyError: notify.error || '',
}
}
function deliveredResult(record, productName) {
return {
paid: true,
status: 4,
code: record.code,
productId: record.productId,
productName,
}
}
// 发货:查到已支付就发激活码,并告诉平台已发货;同一单重复调用只发一次(幂等)
async function deliver(outTradeNo, code) {
const session = await code2Session(code)
const record = await store.get(outTradeNo)
if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
if (record.openid !== session.openid) throw new Error('这笔订单不属于当前用户')
if (record.refunded) throw new Error('这笔订单已经退过款了')
// 已经发过货:把原来那张码再给一遍
if (record.code) {
const notify = await tryNotify(record)
return Object.assign(deliveredResult(record, await nameOf(record.productId)), {
notified: notify.ok,
notifyError: notify.error || '',
})
}
const { order } = await queryOrderRaw(outTradeNo, session.openid, record.env || 0)
const status = (order && order.status) || 0
if (!isPaid(status)) {
return { paid: false, status, productId: record.productId, productName: await nameOf(record.productId) }
}
return deliverRecord(record, order)
}
// 退款:iOS 的 Apple 订单不能走这里(只能用户去 App Store 申请),前端会隐藏按钮
async function refund(outTradeNo, code) {
const session = await code2Session(code)
const record = await store.get(outTradeNo)
if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
if (record.openid !== session.openid) throw new Error('这笔订单不属于当前用户')
if (record.refunded) throw new Error('这笔订单已经退过了')
if (!(await isAdmin(session.openid))) throw new Error('退款目前只有管理员能操作')
const env = record.env || 0
const { order } = await queryOrderRaw(outTradeNo, session.openid, env)
const status = (order && order.status) || 0
if (!isPaid(status)) throw new Error('这笔订单没有可退的支付')
// left_fee 要用查单返回的值,自己算会被平台拒(268490016)
const leftFee = order.left_fee != null ? order.left_fee : order.paid_fee || 0
if (leftFee <= 0) throw new Error('这笔订单没有可退金额(可能已经退过款)')
const refundOrderId = 'R' + outTradeNo
const body = JSON.stringify({
openid: session.openid,
order_id: outTradeNo,
refund_order_id: refundOrderId,
left_fee: leftFee,
refund_fee: leftFee,
biz_meta: 'refund',
refund_reason: 3, // 意愿问题,用户主动退款
req_from: 2, // 用户自己发起
env,
})
const token = await getAccessToken()
const url = `${WX_ORIGIN}${XPAY_REFUND_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_REFUND_URI, body)}`
const res = await request(url, body)
console.log(new Date().toISOString(), 'refund_order', body, '->', JSON.stringify(res))
if (res.errcode && res.errcode !== 268490014) throw new Error(`退款失败:${res.errcode} ${res.errmsg}`) // 14 = 退款进行中
// 激活码作废:「我的」和待发货都不会再带这张码
await store.markRefunded(outTradeNo, refundOrderId)
return { refundOrderId, refundFee: leftFee }
}
// 我的:当前用户已经买到的激活码
async function myCodes(code) {
const session = await code2Session(code)
const admin = await isAdmin(session.openid)
const list = []
for (const record of await store.listByOpenid(session.openid)) {
list.push({
outTradeNo: record.outTradeNo,
code: record.code,
productId: record.productId,
productName: await nameOf(record.productId),
env: record.env || 0,
deliveredAt: record.deliveredAt,
})
}
return { list, isAdmin: admin }
}
// 还没发货的最近一笔:页面重进后仍能点「确认发货」补上(支付回调丢了也能救)
async function pendingOrder(code) {
const session = await code2Session(code)
// 只看最近几笔,并且要平台确认真的付了钱——半路放弃的单(订单初始化)不该一直提示发货
const candidates = await store.listPendingByOpenid(session.openid)
for (const record of candidates) {
const { order } = await queryOrderRaw(record.outTradeNo, session.openid, record.env || 0)
const status = (order && order.status) || 0
if (status >= 2 && status <= 4) {
return {
outTradeNo: record.outTradeNo,
productId: record.productId,
productName: await nameOf(record.productId),
env: record.env || 0,
status,
}
}
}
return { outTradeNo: '' }
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = ''
req.on('data', (chunk) => {
raw += chunk
if (raw.length > MAX_BODY) {
reject(new Error('请求体过大'))
req.destroy()
}
})
req.on('end', () => {
try {
resolve(raw ? JSON.parse(raw) : {})
} catch (err) {
reject(new Error('请求体不是合法 JSON'))
}
})
})
}
// 发货推送的 XML 原文(明文模式),带体积上限
function readText(req) {
return new Promise((resolve, reject) => {
let raw = ''
req.on('data', (chunk) => {
raw += chunk
if (raw.length > MAX_BODY * 4) {
reject(new Error('推送内容过大'))
req.destroy()
}
})
req.on('end', () => resolve(raw))
req.on('error', reject)
})
}
// 平台推来「已发货」(xpay_goods_deliver_notify):幂等发货,回 0 平台就不再重试
async function handleDeliverPush(xml) {
const event = xmlTag(xml, 'Event')
if (event !== 'xpay_goods_deliver_notify') {
console.log(new Date().toISOString(), '推送事件(忽略):', event || '(没有 Event)')
return
}
const outTradeNo = xmlTag(xml, 'OutTradeNo')
const openid = xmlTag(xml, 'OpenId')
if (!outTradeNo || !openid) throw new Error('推送里缺 OutTradeNo 或 OpenId')
const env = Number(xmlTag(xml, 'Env')) === 1 ? 1 : 0
let record = await store.get(outTradeNo)
if (!record) {
// 极少见:本地没这笔单(比如订单表被删了),按推送内容补一条,别漏发
record = await store.saveOrder({
outTradeNo,
openid,
productId: xmlTag(xml, 'ProductId'),
env,
amount: 0,
status: 'created',
createdAt: Date.now(),
})
}
if (record.code) return // 幂等:同一单重复推送只发一次
// 推送这边回了 0,平台就认为发货完成了,所以直接标记 notified,不用再调通知接口
const delivered = await store.assignCode(outTradeNo, {
wxOrderId: xmlTag(xml, 'MchOrderNo') || record.wxOrderId || '',
notified: true,
})
console.log(new Date().toISOString(), '发货推送', outTradeNo, '->', delivered.code)
}
// 消息推送入口:GET 是后台配置 URL 时的校验,POST 是发货/退款等事件(明文模式)
async function handlePush(req, res) {
const params = new URL(req.url, 'http://localhost').searchParams
const expected = sha1([config.pushToken, params.get('timestamp'), params.get('nonce')].sort().join(''))
if (!config.pushToken || params.get('signature') !== expected) {
res.statusCode = 403
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
return res.end('signature 校验失败')
}
if (req.method === 'GET') {
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
return res.end(params.get('echostr') || '')
}
const xml = '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>'
try {
await handleDeliverPush(await readText(req))
} catch (err) {
console.error(new Date().toISOString(), '处理发货推送失败:', err.message)
res.setHeader('Content-Type', 'text/xml; charset=utf-8')
// 出错要回非 0,平台才会重试
return res.end(`<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${err.message}]]></ErrMsg></xml>`)
}
res.setHeader('Content-Type', 'text/xml; charset=utf-8')
res.end(xml)
}
// 定时查单兜底:用户付完直接关掉小程序(不再打开购买页)也能自动发货
async function pollPending() {
for (const record of await store.listPendingAll(Date.now() - PENDING_WINDOW_MS)) {
try {
const { order } = await queryOrderRaw(record.outTradeNo, record.openid, record.env || 0)
if (!isPaid((order && order.status) || 0)) continue
if (record.code) await tryNotify(record)
else await deliverRecord(record, order)
} catch (err) {
console.error('定时补发货失败:', record.outTradeNo, err.message)
}
}
}
function send(res, data, status) {
res.statusCode = status
res.end(JSON.stringify(data))
}
const ROUTES = {
'/pay/order': async (body) =>
Object.assign(
{ ok: true },
// 只认数字 1,字符串 '1' 也要当沙箱:万一前端把 env 传成字符串,不能悄悄签成现网真扣钱
await createPay(String(body.productId), body.code, Number(body.env) === 1 ? 1 : 0)
),
'/pay/deliver': async (body) => Object.assign({ ok: true }, await deliver(String(body.outTradeNo), body.code)),
'/pay/my': async (body) => Object.assign({ ok: true }, await myCodes(body.code)),
'/pay/pending': async (body) => Object.assign({ ok: true }, await pendingOrder(body.code)),
'/pay/refund': async (body) => Object.assign({ ok: true }, await refund(String(body.outTradeNo), body.code)),
// 登录态 + 该用户能看到的道具(管理员才看得到沙箱和补单道具)
'/pay/me': async (body) => {
const session = await code2Session(body.code)
const admin = await isAdmin(session.openid)
return {
ok: true,
isAdmin: admin,
goods: (await allGoods()).filter((item) => item.enabled && (admin || !item.adminOnly)),
copy: await allCopy(),
}
},
}
const server = http.createServer(async (req, res) => {
const path = req.url.split('?')[0]
const startedAt = Date.now()
let summary = ''
res.setHeader('Content-Type', 'application/json; charset=utf-8')
try {
if (path === '/pay/notify') {
summary = 'push'
return await handlePush(req, res)
}
if (path === '/health') {
const missing = ['appId', 'appSecret', 'offerId', 'appKey'].filter((key) => !config[key])
summary = 'ok'
return send(
res,
{
ok: missing.length === 0,
missing,
sandbox: Boolean(config.sandboxAppKey),
goods: (await allGoods()).map((item) => String(item.productId)),
store: store.backend,
routes: Object.keys(ROUTES), // 用来一眼确认服务器上跑的是不是最新代码
},
200
)
}
if (!ROUTES[path]) {
summary = '未知接口'
return send(res, { ok: false, error: '未知接口:' + path }, 404)
}
if (req.method !== 'POST') {
summary = '方法不对'
return send(res, { ok: false, error: '请用 POST' }, 405)
}
const data = await ROUTES[path](await readJson(req))
// 下单带上环境,事后一眼看出这笔是现网还是沙箱
summary = data.ok ? 'ok' : `失败:${data.error}`
if (path === '/pay/order') summary += ` env=${data.env}`
send(res, data, 200)
} catch (err) {
summary = `异常:${err.message}`
// 业务失败也回 200 + ok:false,前端统一按 ok 判断
send(res, { ok: false, error: err.message }, 200)
} finally {
// 每个请求都留一行,哪怕是「前端压根没调过来」也能看出来
console.log(new Date().toISOString(), req.method, path, summary, `${Date.now() - startedAt}ms`)
}
})
// 存储层先就绪(MySQL 要建表、播种价目表),失败就直接退出,别带着坏库对外服务
store
.ready()
.then(() => {
server.listen(PORT, () => {
console.log(`xpay 服务已启动:http://localhost:${server.address().port}(存储:${store.backend})`)
const missing = ['appId', 'appSecret', 'offerId', 'appKey'].filter((key) => !config[key])
if (missing.length) console.warn('还缺配置:' + missing.join('、') + '(写在 server/config.js 或环境变量里)')
if (!config.sandboxAppKey) console.warn('没配沙箱 AppKey,沙箱(env=1)下单会失败')
if (!config.pushToken) console.warn('没配 pushToken,平台的发货推送会被拒(查单兜底仍然可用)')
})
if (POLL_MS > 0) {
setInterval(pollPending, POLL_MS)
console.log(`定时补发货已开启:每 ${Math.round(POLL_MS / 1000)} 秒扫一次没发完的单`)
}
})
.catch((err) => {
console.error('存储层初始化失败:', err.message)
process.exit(1)
})
+62
View File
@@ -0,0 +1,62 @@
// 把 orders.jsonl 里的历史订单导进 MySQL。可重复跑(按单号 upsert,不覆盖已有激活码)
// 用法:node server/migrate.js
const fs = require('fs')
const path = require('path')
const store = require('./store.js')
const FILE = process.env.DATA_DIR
? path.join(process.env.DATA_DIR, 'orders.jsonl')
: path.join(__dirname, 'data', 'orders.jsonl')
async function main() {
await store.ready()
if (store.backend !== 'mysql') {
throw new Error('现在走的是 JSONL 后端,先在 config.js 里配好 db(库名 + 账号)再跑迁移')
}
if (!fs.existsSync(FILE)) {
console.log('没有找到 ' + FILE + ',不用迁移')
return
}
// 一行一条、同一单号可能有多行(改状态就追加),后写的覆盖先写的
const records = new Map()
let badLines = 0
for (const line of fs.readFileSync(FILE, 'utf8').split('\n')) {
if (!line.trim()) continue
try {
const record = JSON.parse(line)
records.set(record.outTradeNo, record)
} catch (err) {
badLines++
}
}
let withCode = 0
let amount = 0
const firstSeen = new Map() // openid -> 最早的下单时间,用来还原「首次使用时间」
for (const record of records.values()) {
await store.saveOrder(record)
if (record.code) withCode++
amount += record.amount || 0
const seen = firstSeen.get(record.openid)
if (record.openid && (!seen || record.createdAt < seen)) firstSeen.set(record.openid, record.createdAt)
}
// 历史订单里的用户也补进用户表,首次使用时间取他们最早那笔单
for (const [openid, createdAt] of firstSeen) await store.touchUser(openid, createdAt)
// 核对:库里的单数和码数应该不小于文件里读出来的
const stats = await store.stats()
console.log('迁移完成:')
console.log(` 文件里有 ${records.size} 笔单(${withCode} 笔带激活码,跳过 ${badLines} 行坏数据)`)
console.log(
` 库里有 ${stats.orders} 笔单、${stats.codes} 个激活码、${stats.users} 个用户,其中 ${stats.pending} 笔还没发完`
)
console.log(` 本次导入的金额合计 ${amount} 分`)
console.log(' 请在「我的」页确认激活码都在,再决定要不要把 orders.jsonl 归档')
}
main().catch((err) => {
console.error('迁移失败:', err.message)
process.exit(1)
})
+14
View File
@@ -0,0 +1,14 @@
{
"name": "mini-viewer-server",
"private": true,
"description": "虚拟支付下单 / 发货 / 退款服务",
"scripts": {
"start": "node index.js",
"migrate": "node migrate.js",
"test": "node test.js",
"test:db": "node test-db.js"
},
"dependencies": {
"mysql2": "^3.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
// 只负责把 config.js 读出来(没有就返回空对象),避免 index.js / db.js 互相 require
try {
module.exports = require('./config.js')
} catch (err) {
module.exports = {}
}
+47
View File
@@ -0,0 +1,47 @@
// 虚拟支付签名与下单参数(纯函数,index.js 与 test.js 共用)
const crypto = require('crypto')
// 道具价目表(单位:分),必须与 MP 后台【虚拟支付 → 道具管理】的价格一致
const GOODS = {
// 0.1 元的测试道具,只给管理员用
90001: { name: '补单专用', price: 10, adminOnly: true, note: '0.1 元测试用,iOS 最低 1 元买不了' },
10001: { name: '基础激活码', price: 3900 },
10002: { name: '高级激活码', price: 6900 },
10000: { name: '永久激活码', price: 9900 },
}
function hmacSha256(key, message) {
return crypto.createHmac('sha256', key).update(message, 'utf8').digest('hex')
}
// outTradeNo:8-32 位,仅数字/大小写字母/_-|*@,不能以下划线开头,每单都要换新号
function newOutTradeNo(now) {
return 'T' + (now || Date.now()) + Math.random().toString(36).slice(2, 8).toUpperCase()
}
// 返回的字符串要原样传给 wx.requestVirtualPayment,签名与传参必须是同一串原文
function buildSignData(params) {
return JSON.stringify({
offerId: params.offerId,
buyQuantity: params.buyQuantity || 1,
env: params.env === undefined ? 0 : params.env, // 0 现网,1 沙箱(沙箱还要换沙箱 AppKey)
currencyType: 'CNY',
productId: String(params.productId),
goodsPrice: params.price, // 单位分,不要换算成元
outTradeNo: params.outTradeNo,
attach: params.attach,
})
}
// paySig = HMAC-SHA256(AppKey, uri + '&' + body)
// C 端下单 uri 固定为 requestVirtualPayment;B 端接口用实际路径,如 /xpay/query_order
function paySig(appKey, uri, body) {
return hmacSha256(appKey, uri + '&' + body)
}
// 用户态签名 = HMAC-SHA256(sessionKey, signData)
function userSignature(sessionKey, signData) {
return hmacSha256(sessionKey, signData)
}
module.exports = { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig, userSignature }
+154
View File
@@ -0,0 +1,154 @@
// JSONL 后端:一行一条 JSON 追加写(改状态就追加新行),启动时读一遍建索引
// 量小、单进程够用;也是没配数据库时的默认后端,自测和回滚都靠它
const fs = require('fs')
const path = require('path')
const { newActivationCode: randomCode } = require('./code.js')
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const FILE = path.join(DATA_DIR, 'orders.jsonl')
const USERS_FILE = path.join(DATA_DIR, 'users.jsonl')
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json')
const orders = new Map() // outTradeNo -> 最新一条记录
const usedCodes = new Set()
const users = new Map() // openid -> 记录
const persistedSeen = new Map() // openid -> 最近一次落盘的活跃时间
function newActivationCode() {
for (;;) {
const code = randomCode()
if (!usedCodes.has(code)) return code
}
}
function load() {
orders.clear()
usedCodes.clear()
users.clear()
persistedSeen.clear()
if (fs.existsSync(FILE)) {
fs.readFileSync(FILE, 'utf8')
.split('\n')
.forEach((line) => {
if (!line.trim()) return
const record = JSON.parse(line)
orders.set(record.outTradeNo, record) // 同一单号,后写的覆盖先写的
if (record.code) usedCodes.add(record.code)
})
}
if (fs.existsSync(USERS_FILE)) {
fs.readFileSync(USERS_FILE, 'utf8')
.split('\n')
.forEach((line) => {
if (!line.trim()) return
const record = JSON.parse(line)
users.set(record.openid, record)
persistedSeen.set(record.openid, record.lastSeenAt)
})
}
}
load()
module.exports = {
backend: 'jsonl',
file: FILE,
ready: async () => {},
get: async (outTradeNo) => orders.get(outTradeNo) || null,
// 全量落一条(新建、改状态、迁移导入都走它)
saveOrder: async (record) => {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.appendFileSync(FILE, JSON.stringify(record) + '\n')
orders.set(record.outTradeNo, record)
if (record.code) usedCodes.add(record.code)
return record
},
// 发货:生成激活码并绑定;已经有码就直接返回(幂等)
assignCode: async (outTradeNo, extra) => {
const record = orders.get(outTradeNo)
if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
if (record.code) return record
return module.exports.saveOrder(
Object.assign(
{},
record,
{ status: 'delivered', code: newActivationCode(), deliveredAt: Date.now(), notified: false },
extra
)
)
},
markNotified: async (outTradeNo) => {
const record = orders.get(outTradeNo)
if (!record) return
await module.exports.saveOrder(Object.assign({}, record, { notified: true }))
},
markRefunded: async (outTradeNo, refundOrderId) => {
const record = orders.get(outTradeNo)
if (!record) return
await module.exports.saveOrder(Object.assign({}, record, { refunded: true, refundOrderId, refundedAt: Date.now() }))
},
listByOpenid: async (openid) =>
Array.from(orders.values())
.filter((o) => o.openid === openid && o.code && !o.refunded)
.sort((a, b) => b.deliveredAt - a.deliveredAt),
listPendingByOpenid: async (openid) =>
Array.from(orders.values())
.filter((o) => o.openid === openid && !o.refunded && (!o.code || !o.notified))
.sort((a, b) => b.createdAt - a.createdAt),
listPendingAll: async (since) =>
Array.from(orders.values())
.filter((o) => !o.refunded && (!o.code || !o.notified) && o.createdAt >= since)
.sort((a, b) => a.createdAt - b.createdAt),
listGoods: async () => [],
getUser: async (openid) => {
const record = users.get(openid)
return record ? { openid, createdAt: record.createdAt, lastSeenAt: record.lastSeenAt, isAdmin: Boolean(record.isAdmin) } : null
},
// 文件后端:想改文案就在 data/settings.json 里写 {"buy.title": "xx"}
getSettings: async () => {
if (!fs.existsSync(SETTINGS_FILE)) return {}
try {
return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
} catch (err) {
console.error('settings.json 解析失败:', err.message)
return {}
}
},
touchUser: async (openid, firstSeenAt) => {
const now = Date.now()
const existed = users.get(openid)
const record = {
openid,
createdAt: existed ? existed.createdAt : firstSeenAt || now,
lastSeenAt: now,
isAdmin: existed ? existed.isAdmin : false,
}
users.set(openid, record)
// 活跃时间最多每小时落一次盘,免得文件被刷爆
if (!persistedSeen.has(openid) || now - persistedSeen.get(openid) > 3600000) {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.appendFileSync(USERS_FILE, JSON.stringify(record) + '\n')
persistedSeen.set(openid, now)
}
return record
},
// 核对用:订单数、激活码数、未完成的单
stats: async () => ({
orders: orders.size,
codes: Array.from(orders.values()).filter((o) => o.code).length,
pending: Array.from(orders.values()).filter((o) => !o.refunded && (!o.code || !o.notified)).length,
users: users.size,
}),
}
+230
View File
@@ -0,0 +1,230 @@
// MySQL 后端:订单一张表,激活码单独一张表(唯一索引保证一单一码、码不重复)
const { getPool, initSchema } = require('./db.js')
const { GOODS } = require('./sign.js')
const { newActivationCode } = require('./code.js')
const ORDER_COLUMNS = [
'out_trade_no',
'openid',
'product_id',
'env',
'amount',
'status',
'wx_order_id',
'paid_fee',
'notified',
'refunded',
'refund_order_id',
'created_at',
'delivered_at',
'refunded_at',
]
const SELECT_ORDER = 'SELECT o.*, c.code FROM orders o LEFT JOIN codes c ON c.out_trade_no = o.out_trade_no'
const placeholders = (n) => Array(n).fill('?').join(', ')
// 库里存可读的 DATETIME,对外(接口、内存)还是毫秒时间戳,只在读写边界转换
const toDbDate = (ms) => (ms == null ? null : new Date(Number(ms)))
const toMs = (value) => (value == null ? null : value instanceof Date ? value.getTime() : Number(value))
function toRecord(row) {
return {
outTradeNo: row.out_trade_no,
openid: row.openid,
productId: row.product_id,
env: row.env,
amount: row.amount,
status: row.status,
code: row.code || '',
wxOrderId: row.wx_order_id || '',
paidFee: row.paid_fee,
notified: Boolean(row.notified),
refunded: Boolean(row.refunded),
refundOrderId: row.refund_order_id || '',
createdAt: toMs(row.created_at),
deliveredAt: toMs(row.delivered_at),
refundedAt: toMs(row.refunded_at),
}
}
async function saveOrder(record) {
const db = getPool()
const values = [
record.outTradeNo,
record.openid,
String(record.productId),
record.env || 0,
record.amount || 0,
record.status || 'created',
record.wxOrderId || null,
record.paidFee == null ? null : record.paidFee,
record.notified ? 1 : 0,
record.refunded ? 1 : 0,
record.refundOrderId || null,
toDbDate(record.createdAt || Date.now()),
toDbDate(record.deliveredAt),
toDbDate(record.refundedAt),
]
const updates = ORDER_COLUMNS.filter((c) => c !== 'out_trade_no').map((c) => `${c} = VALUES(${c})`).join(', ')
await db.query(
`INSERT INTO orders (${ORDER_COLUMNS.join(', ')}) VALUES (${placeholders(ORDER_COLUMNS.length)}) ON DUPLICATE KEY UPDATE ${updates}`,
values
)
if (record.code) {
// 一单一码:out_trade_no 唯一索引兜底,重复写不动已有的码
await db.query(
`INSERT INTO codes (code, product_id, out_trade_no, openid, env, status, created_at, bound_at)
VALUES (?, ?, ?, ?, ?, 'issued', ?, ?)
ON DUPLICATE KEY UPDATE code = code`,
[
record.code,
String(record.productId),
record.outTradeNo,
record.openid,
record.env || 0,
toDbDate(record.deliveredAt || Date.now()),
toDbDate(record.deliveredAt || Date.now()),
]
)
}
return record
}
module.exports = {
backend: 'mysql',
ready: () => initSchema(GOODS),
get: async (outTradeNo) => {
const [rows] = await getPool().query(`${SELECT_ORDER} WHERE o.out_trade_no = ?`, [outTradeNo])
return rows.length ? toRecord(rows[0]) : null
},
saveOrder,
// 发货:codes 表里已经有就直接返回(幂等);撞号或并发重复发就重来一次
assignCode: async (outTradeNo, extra) => {
for (let attempt = 0; attempt < 5; attempt++) {
const record = await module.exports.get(outTradeNo)
if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
if (record.code) return record
const updated = Object.assign(
{},
record,
{ status: 'delivered', code: newActivationCode(), deliveredAt: Date.now(), notified: false },
extra
)
try {
await saveOrder(updated)
return updated
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err
}
}
throw new Error('激活码连续撞号,请重试')
},
markNotified: async (outTradeNo) => {
await getPool().query('UPDATE orders SET notified = 1 WHERE out_trade_no = ?', [outTradeNo])
},
markRefunded: async (outTradeNo, refundOrderId) => {
const now = Date.now()
await getPool().query('UPDATE orders SET refunded = 1, refund_order_id = ?, refunded_at = ? WHERE out_trade_no = ?', [
refundOrderId,
toDbDate(now),
outTradeNo,
])
// 码作废但不删除:将来做核销时按 status 拒绝
await getPool().query(`UPDATE codes SET status = 'revoked', revoked_at = ? WHERE out_trade_no = ?`, [
toDbDate(now),
outTradeNo,
])
},
listByOpenid: async (openid) => {
const [rows] = await getPool().query(
`${SELECT_ORDER} WHERE o.openid = ? AND c.code IS NOT NULL AND o.refunded = 0 ORDER BY o.delivered_at DESC`,
[openid]
)
return rows.map(toRecord)
},
listPendingByOpenid: async (openid) => {
const [rows] = await getPool().query(
`${SELECT_ORDER}
WHERE o.openid = ? AND o.refunded = 0 AND (c.code IS NULL OR o.notified = 0)
ORDER BY o.created_at DESC LIMIT 3`,
[openid]
)
return rows.map(toRecord)
},
listPendingAll: async (since) => {
const [rows] = await getPool().query(
`${SELECT_ORDER}
WHERE o.refunded = 0 AND o.created_at >= ? AND (c.code IS NULL OR o.notified = 0)
ORDER BY o.created_at ASC`,
[toDbDate(since)]
)
return rows.map(toRecord)
},
listGoods: async () => {
const [rows] = await getPool().query(
'SELECT product_id, name, price, enabled, admin_only, note FROM goods ORDER BY price ASC, product_id ASC'
)
return rows.map((row) => ({
productId: row.product_id,
name: row.name,
price: row.price,
enabled: Boolean(row.enabled),
adminOnly: Boolean(row.admin_only),
note: row.note || '',
}))
},
// 用户信息(管理员标志就从这来)
getUser: async (openid) => {
const [rows] = await getPool().query('SELECT openid, created_at, last_seen_at, is_admin FROM users WHERE openid = ?', [
openid,
])
if (!rows.length) return null
return {
openid: rows[0].openid,
createdAt: toMs(rows[0].created_at),
lastSeenAt: toMs(rows[0].last_seen_at),
isAdmin: Boolean(rows[0].is_admin),
}
},
// 页面文案配置(settings 表里有什么就是什么,兜底在 index.js)
getSettings: async () => {
const [rows] = await getPool().query('SELECT cfg_key, cfg_value FROM settings')
const settings = {}
for (const row of rows) settings[row.cfg_key] = row.cfg_value
return settings
},
// 用户首次出现就插一行,之后每次只更新活跃时间(首次使用时间不会被覆盖)
touchUser: async (openid, firstSeenAt) => {
const now = new Date()
const created = firstSeenAt ? new Date(Number(firstSeenAt)) : now
await getPool().query(
`INSERT INTO users (openid, created_at, last_seen_at, is_admin) VALUES (?, ?, ?, 0)
ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at)`,
[openid, created, now]
)
},
// 核对用:订单数、激活码数、未完成的单
stats: async () => {
const db = getPool()
const [[orders], [codes], [pending], [users]] = await Promise.all([
db.query('SELECT COUNT(*) AS n FROM orders'),
db.query('SELECT COUNT(*) AS n FROM codes'),
db.query(`SELECT COUNT(*) AS n FROM orders o LEFT JOIN codes c ON c.out_trade_no = o.out_trade_no
WHERE o.refunded = 0 AND (c.code IS NULL OR o.notified = 0)`),
db.query('SELECT COUNT(*) AS n FROM users'),
])
return { orders: orders[0].n, codes: codes[0].n, pending: pending[0].n, users: users[0].n }
},
}
+5
View File
@@ -0,0 +1,5 @@
// 存储层入口:config.js 里配了 db(库名 + 账号)就走 MySQL,没配就继续用 JSONL 文件
// 两边实现同一套接口,切换只改配置,出问题能立刻切回来
const db = require('./db.js')
module.exports = db.useMysql ? require('./store-mysql.js') : require('./store-jsonl.js')
+107
View File
@@ -0,0 +1,107 @@
// MySQL 存储层自测:会往库里写几条 TEST- 开头的测试数据,跑完自动删掉
// 用法:node server/test-db.js(需要 config.js 里配好 db)
const assert = require('assert')
const store = require('./store.js')
const { getPool, settings } = require('./db.js')
const TEST_OPENID = 'oTestStoreUser'
async function main() {
if (store.backend !== 'mysql') {
throw new Error('现在走的是 JSONL 后端:请在 config.js 里配好 db(库名 + 账号)再跑这个自测')
}
await store.ready()
const outTradeNo = 'TEST' + Date.now()
const createdAt = Date.now()
try {
await store.saveOrder({
outTradeNo,
openid: TEST_OPENID,
productId: '10001',
env: 0,
amount: 3900,
status: 'created',
createdAt,
})
const created = await store.get(outTradeNo)
assert.strictEqual(created.openid, TEST_OPENID)
assert.strictEqual(created.amount, 3900)
assert.strictEqual(created.code, '', '刚下单时不该有激活码')
assert.strictEqual(created.createdAt, createdAt)
// 时间列要是可读的 DATETIME(不是时间戳),且毫秒能原样存取
const [columnTypes] = await getPool().query(
`SELECT DATA_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'codes' AND COLUMN_NAME = 'created_at'`,
[settings.database]
)
assert.strictEqual(columnTypes[0].DATA_TYPE, 'datetime', '时间列应该是 DATETIME,不该是时间戳')
const delivered = await store.assignCode(outTradeNo, { wxOrderId: 'wx-test-1', paidFee: 3900 })
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
assert.strictEqual(delivered.status, 'delivered')
// 幂等:同一单再发一次还是同一张码(唯一索引挡住)
const again = await store.assignCode(outTradeNo, {})
assert.strictEqual(again.code, delivered.code)
let mine = await store.listByOpenid(TEST_OPENID)
assert.deepStrictEqual(mine.map((o) => o.code), [delivered.code])
await store.markNotified(outTradeNo)
assert.strictEqual((await store.get(outTradeNo)).notified, true)
// 退款后码作废:从「我的」和待发货里都消失,但记录还在
await store.markRefunded(outTradeNo, 'R' + outTradeNo)
mine = await store.listByOpenid(TEST_OPENID)
assert.deepStrictEqual(mine, [], '退款后不该再出现在我的激活码里')
const [codeRows] = await getPool().query('SELECT status FROM codes WHERE out_trade_no = ?', [outTradeNo])
assert.strictEqual(codeRows[0].status, 'revoked')
// 价目表播种:至少有四个道具,价格和 MP 后台一致
const goods = await store.listGoods()
assert.ok(goods.length >= 4, 'goods 表应该有播种的价目表')
assert.strictEqual(goods.find((g) => g.productId === '10000').price, 9900)
assert.strictEqual(goods.find((g) => g.productId === '90001').price, 10)
// 文案配置表:默认值会播种进去,备注列告诉你在哪显示
const settings = await store.getSettings()
assert.strictEqual(settings['buy.title'], '选择套餐')
const [copyRows] = await getPool().query(`SELECT remark FROM settings WHERE cfg_key = 'buy.title'`)
assert.strictEqual(copyRows[0].remark, '开通页的大标题')
const stats = await store.stats()
assert.ok(stats.orders >= 1)
assert.ok(stats.users >= 1, '用户表应该有记录')
// 用户表:首次使用时间建一次就不该再被覆盖,管理员标志默认 0
const [userRows] = await getPool().query('SELECT * FROM users WHERE openid = ?', [TEST_OPENID])
assert.strictEqual(userRows.length, 1)
assert.strictEqual(userRows[0].is_admin, 0)
const firstSeen = userRows[0].created_at.getTime()
await store.touchUser(TEST_OPENID)
const [againUsers] = await getPool().query('SELECT created_at, last_seen_at FROM users WHERE openid = ?', [TEST_OPENID])
assert.strictEqual(againUsers[0].created_at.getTime(), firstSeen, '首次使用时间不该被覆盖')
assert.ok(againUsers[0].last_seen_at.getTime() >= firstSeen)
// 管理员标志从 users.is_admin 读
assert.strictEqual((await store.getUser(TEST_OPENID)).isAdmin, false)
await getPool().query('UPDATE users SET is_admin = 1 WHERE openid = ?', [TEST_OPENID])
assert.strictEqual((await store.getUser(TEST_OPENID)).isAdmin, true)
console.log(`数据库自测通过(当前库里 ${stats.orders} 笔单 / ${stats.codes} 个码 / ${stats.users} 个用户)`)
} finally {
// 收尾:把测试数据删掉,别脏了真库
await getPool().query('DELETE FROM codes WHERE out_trade_no = ?', [outTradeNo])
await getPool().query('DELETE FROM orders WHERE out_trade_no = ?', [outTradeNo])
await getPool().query('DELETE FROM users WHERE openid = ?', [TEST_OPENID])
await getPool().end()
}
}
main().catch((err) => {
console.error('数据库自测失败:', err.message)
process.exit(1)
})
+457
View File
@@ -0,0 +1,457 @@
// 用法:node server/test.js
// 1) 纯函数断言(HMAC 官方向量、价目表、signData 字段、单号格式、激活码格式)
// 2) 起一个假的微信接口 + 真的 server/index.js,走一遍下单、发货、我的
const assert = require('assert')
const crypto = require('crypto')
const fs = require('fs')
const http = require('http')
const os = require('os')
const path = require('path')
const { spawn } = require('child_process')
const { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig } = require('./sign.js')
const { newActivationCode } = require('./code.js')
const APP_KEY = 'test-appkey'
const SANDBOX_APP_KEY = 'test-sandbox-appkey'
const OFFER_ID = '1450663371'
const SESSION_KEY = 'sk-test'
const OPENID = 'oTestUser'
const PUSH_TOKEN = 'test-push-token'
// ---------- 1. 纯函数 ----------
assert.strictEqual(
hmacSha256(Buffer.alloc(20, 0x0b), 'Hi There'), // RFC 4231 官方向量
'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
)
assert.deepStrictEqual(
{ 90001: GOODS[90001].price, 10001: GOODS[10001].price, 10002: GOODS[10002].price, 10000: GOODS[10000].price },
{ 90001: 10, 10001: 3900, 10002: 6900, 10000: 9900 }
)
const sample = buildSignData({ offerId: OFFER_ID, productId: 10000, price: 9900, outTradeNo: 'T1700ABCDEF', attach: 'test-10000' })
assert.deepStrictEqual(JSON.parse(sample), {
offerId: OFFER_ID,
buyQuantity: 1,
env: 0,
currencyType: 'CNY',
productId: '10000',
goodsPrice: 9900,
outTradeNo: 'T1700ABCDEF',
attach: 'test-10000',
})
assert.notStrictEqual(
paySig(APP_KEY, 'requestVirtualPayment', sample),
paySig(APP_KEY, 'requestVirtualPayment', sample + ' '),
'签名必须认原文'
)
for (let i = 0; i < 500; i++) {
const no = newOutTradeNo(1700000000000 + i)
assert.ok(no.length >= 8 && no.length <= 32, '单号长度越界: ' + no)
assert.ok(/^[0-9A-Za-z_\-|*@]+$/.test(no), '单号含非法字符: ' + no)
assert.ok(!no.startsWith('_'), '单号不能以下划线开头: ' + no)
}
// 激活码:4 组 4 位、不含容易看错的 I/L/O/U、不重复
const codes = new Set()
for (let i = 0; i < 1000; i++) {
const made = newActivationCode()
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(made), '激活码格式不对: ' + made)
codes.add(made)
}
assert.strictEqual(codes.size, 1000, '激活码不应重复')
// ---------- 2. 起假微信接口 ----------
const seen = { notifyCalls: 0, unpaid: null, failNotify: false }
const fakeWx = http.createServer((req, res) => {
const [urlPath, query] = req.url.split('?')
let raw = ''
req.on('data', (chunk) => (raw += chunk))
req.on('end', () => {
res.setHeader('Content-Type', 'application/json')
const reply = (obj) => res.end(JSON.stringify(obj))
if (urlPath === '/sns/jscode2session') {
const jsCode = new URLSearchParams(query).get('js_code')
seen.code = jsCode
return reply({ openid: jsCode === 'code-other' ? 'oOtherUser' : OPENID, session_key: SESSION_KEY })
}
if (urlPath === '/cgi-bin/token') return reply({ access_token: 'tok-test', expires_in: 7200 })
if (urlPath === '/xpay/query_order') {
seen.queryBody = raw
seen.querySig = new URLSearchParams(query).get('pay_sig')
seen.queryToken = new URLSearchParams(query).get('access_token')
const { order_id: orderId, env } = JSON.parse(raw)
const status = orderId === seen.unpaid ? 1 : 2 // 1 = 已创建未支付
return reply({
errcode: 0,
errmsg: 'ok',
order: { order_id: orderId, status, paid_fee: 10, left_fee: 10, wx_order_id: `wx-${env}-${orderId}` },
})
}
if (urlPath === '/xpay/notify_provide_goods') {
seen.notifyCalls++
seen.notifyBody = raw
seen.notifyToken = new URLSearchParams(query).get('access_token')
seen.notifySig = new URLSearchParams(query).get('pay_sig')
if (seen.failNotify) return reply({ errcode: 44002, errmsg: 'empty post data' })
return reply({ errcode: 0, errmsg: 'ok' })
}
if (urlPath === '/xpay/refund_order') {
seen.refundBody = raw
seen.refundSig = new URLSearchParams(query).get('pay_sig')
return reply({ errcode: 0, errmsg: 'ok', refund_order_id: JSON.parse(raw).refund_order_id })
}
res.statusCode = 404
reply({ errcode: -1, errmsg: 'no such path: ' + urlPath })
})
})
function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
}
function fetchJson(port, urlPath, payload) {
return new Promise((resolve, reject) => {
const body = payload === undefined ? null : JSON.stringify(payload)
const req = http.request(
{ host: '127.0.0.1', port, path: urlPath, method: body ? 'POST' : 'GET', headers: body ? { 'Content-Type': 'application/json' } : {} },
(res) => {
let raw = ''
res.on('data', (chunk) => (raw += chunk))
res.on('end', () => resolve(JSON.parse(raw)))
}
)
req.on('error', reject)
req.end(body || undefined)
})
}
// 需要看原始响应的场合(推送接口回的是 XML / 纯文本)
function rawRequest(port, urlPath, options) {
const opts = options || {}
return new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path: urlPath, method: opts.method || (opts.body ? 'POST' : 'GET') },
(res) => {
let raw = ''
res.on('data', (chunk) => (raw += chunk))
res.on('end', () => resolve({ status: res.statusCode, body: raw }))
}
)
req.on('error', reject)
req.end(opts.body || undefined)
})
}
// 消息推送的签名:sha1(sort([token, timestamp, nonce]).join(''))
function pushQuery(token) {
const timestamp = '1700000000'
const nonce = 'abc123'
const signature = crypto.createHash('sha1').update([token || PUSH_TOKEN, timestamp, nonce].sort().join('')).digest('hex')
return `?signature=${signature}&timestamp=${timestamp}&nonce=${nonce}`
}
function startServer(env) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(__dirname, 'index.js')], {
env: Object.assign({}, process.env, env),
stdio: ['ignore', 'pipe', 'inherit'],
})
let out = ''
child.stdout.on('data', (chunk) => {
out += chunk
const port = (out.match(/http:\/\/localhost:(\d+)/) || [])[1]
if (port) resolve({ port: Number(port), child })
})
})
}
// 轮询等某个单号出现激活码(定时补发货是异步的)
async function waitForCode(port, outTradeNo, timeoutMs) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
const hit = mine.list.find((item) => item.outTradeNo === outTradeNo)
if (hit) return hit
await new Promise((resolve) => setTimeout(resolve, 100))
}
return null
}
async function main() {
const wxPort = await listen(fakeWx)
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-data-'))
// 先放一条管理员记录:沙箱、补单道具、退款这些管理员功能要靠它
fs.writeFileSync(
path.join(dataDir, 'users.jsonl'),
JSON.stringify({ openid: OPENID, createdAt: 1700000000000, lastSeenAt: 1700000000000, isAdmin: true }) + '\n'
)
// 文案配置:只覆盖标题,其它项应该退回代码里的默认值
fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ 'buy.title': '限时特惠', 'buy.button': '立即开通' }))
const serverEnv = {
PORT: '0',
PAY_POLL_MS: '0', // 主流程把定时任务关掉,免得跟断言的时序打架
PAY_STORE: 'jsonl', // 自测一律用文件后端,绝不碰你的数据库
WX_API_ORIGIN: `http://127.0.0.1:${wxPort}`,
DATA_DIR: dataDir,
PAY_APP_ID: 'wxtest',
PAY_APP_SECRET: 'secret-test',
PAY_OFFER_ID: OFFER_ID,
PAY_APP_KEY: APP_KEY,
PAY_SANDBOX_APP_KEY: SANDBOX_APP_KEY,
PAY_PUSH_TOKEN: PUSH_TOKEN,
}
const { port, child } = await startServer(serverEnv)
try {
assert.deepStrictEqual(await fetchJson(port, '/health'), {
ok: true,
missing: [],
sandbox: true,
goods: ['90001', '10001', '10002', '10000'], // 价格从低到高
store: 'jsonl',
routes: ['/pay/order', '/pay/deliver', '/pay/my', '/pay/pending', '/pay/refund', '/pay/me'],
})
// 下单:签名要能被独立重算出来
const paid = await fetchJson(port, '/pay/order', { productId: '10000', code: 'code-1' })
assert.strictEqual(paid.ok, true, JSON.stringify(paid))
assert.strictEqual(seen.code, 'code-1')
const { signData, paySig: gotPaySig, signature, mode } = paid.payData
assert.strictEqual(mode, 'short_series_goods')
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
assert.strictEqual(signature, hmacSha256(SESSION_KEY, signData))
const parsed = JSON.parse(signData)
assert.strictEqual(parsed.offerId, OFFER_ID)
assert.strictEqual(parsed.productId, '10000')
assert.strictEqual(parsed.goodsPrice, 9900)
assert.strictEqual(parsed.env, 0)
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
assert.strictEqual(paid.outTradeNo, parsed.outTradeNo)
// 页面重进后能找回还没发货的单(补发货用)
const pending = await fetchJson(port, '/pay/pending', { code: 'code-1' })
assert.strictEqual(pending.outTradeNo, paid.outTradeNo)
// 半路放弃、根本没付款的单,不该被当成「待发货」提示
seen.unpaid = paid.outTradeNo
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
seen.unpaid = null
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-other' }), { ok: true, outTradeNo: '' })
// 沙箱单:env=1、用沙箱 AppKey 签名,和现网签名不能一样
const sandbox = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: 1 })
assert.strictEqual(sandbox.ok, true, JSON.stringify(sandbox))
const sandboxSignData = sandbox.payData.signData
assert.strictEqual(JSON.parse(sandboxSignData).env, 1)
assert.strictEqual(JSON.parse(sandboxSignData).goodsPrice, 10)
assert.strictEqual(sandbox.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', sandboxSignData))
assert.notStrictEqual(sandbox.payData.paySig, paySig(APP_KEY, 'requestVirtualPayment', sandboxSignData))
// 前端万一把 env 传成字符串 '1',也必须按沙箱签,绝不能悄悄走现网真扣钱
const stringEnv = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: '1' })
assert.strictEqual(JSON.parse(stringEnv.payData.signData).env, 1)
assert.strictEqual(stringEnv.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', stringEnv.payData.signData))
// 未发布的道具 ID 不该能下单
const bad = await fetchJson(port, '/pay/order', { productId: '12345', code: 'code-1' })
assert.strictEqual(bad.ok, false)
assert.ok(/未知道具/.test(bad.error), bad.error)
// 我是谁 + 我能看到哪些道具:管理员才多看到「补单专用」(0.1 元测试价)
const me = await fetchJson(port, '/pay/me', { code: 'code-1' })
assert.strictEqual(me.isAdmin, true)
assert.deepStrictEqual(
me.goods.map((item) => [item.productId, item.price]),
[
['90001', 10],
['10001', 3900],
['10002', 6900],
['10000', 9900],
]
)
assert.ok(me.goods.find((item) => item.productId === '90001').note, '备注文案要能带回小程序')
// 页面文案:库里有就用库里的,没有就退回默认值
assert.strictEqual(me.copy['buy.title'], '限时特惠')
assert.strictEqual(me.copy['buy.button'], '立即开通') // 库里的覆盖生效
assert.strictEqual(me.copy['tab.buy'], '开通')
const normalMe = await fetchJson(port, '/pay/me', { code: 'code-other' })
assert.strictEqual(normalMe.isAdmin, false)
assert.ok(!normalMe.goods.some((item) => item.productId === '90001'), '普通用户不该看到补单专用')
// 普通用户直接调接口也绕不过去
const sneakyGoods = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-other' })
assert.strictEqual(sneakyGoods.ok, false)
assert.ok(/管理员/.test(sneakyGoods.error), sneakyGoods.error)
const sneakySandbox = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other', env: 1 })
assert.strictEqual(sneakySandbox.ok, false)
assert.ok(/沙箱/.test(sneakySandbox.error), sneakySandbox.error)
// 查单该用 pay_sig + access_token
seen.unpaid = paid.outTradeNo
const unpaid = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
assert.strictEqual(unpaid.ok, true)
assert.strictEqual(unpaid.paid, false, '没付款就不能发货')
assert.strictEqual(unpaid.code, undefined)
assert.strictEqual(seen.notifyCalls, 0, '没付款不该通知平台发货')
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 0, order_id: paid.outTradeNo })
assert.strictEqual(seen.querySig, paySig(APP_KEY, '/xpay/query_order', seen.queryBody))
assert.strictEqual(seen.queryToken, 'tok-test')
// 付款后发货:发一张码 + 通知平台
seen.unpaid = null
const delivered = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
assert.strictEqual(delivered.paid, true)
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
assert.strictEqual(delivered.productName, '永久激活码')
assert.strictEqual(delivered.notified, true)
assert.deepStrictEqual(JSON.parse(seen.notifyBody), { order_id: paid.outTradeNo, wx_order_id: 'wx-0-' + paid.outTradeNo, env: 0 })
assert.strictEqual(seen.notifyToken, 'tok-test')
assert.strictEqual(seen.notifyCalls, 1)
// 平台要求 pay_sig,缺了会回 268490002
assert.strictEqual(seen.notifySig, paySig(APP_KEY, '/xpay/notify_provide_goods', seen.notifyBody))
// 幂等:同一单再点一次,还是同一张码,也不再重复通知平台
const again = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
assert.strictEqual(again.code, delivered.code)
assert.strictEqual(seen.notifyCalls, 1)
// 别人的单不能领
const stolen = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-other' })
assert.strictEqual(stolen.ok, false)
assert.ok(/不属于当前用户/.test(stolen.error), stolen.error)
// 我的:本人能看到,别人看不到
const sandboxDelivered = await fetchJson(port, '/pay/deliver', { outTradeNo: sandbox.outTradeNo, code: 'code-1' })
assert.strictEqual(sandboxDelivered.paid, true, JSON.stringify(sandboxDelivered))
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 1, order_id: sandbox.outTradeNo })
assert.strictEqual(seen.querySig, paySig(SANDBOX_APP_KEY, '/xpay/query_order', seen.queryBody))
// 沙箱单不通知平台:通知次数还停在前面的现网单上
assert.strictEqual(seen.notifyCalls, 1, '沙箱单不该调 notify_provide_goods')
assert.strictEqual(sandboxDelivered.notified, true)
assert.strictEqual(sandboxDelivered.productName, '补单专用')
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
assert.deepStrictEqual(
mine.list.map((item) => [item.code, item.productName]).sort(),
[
[delivered.code, '永久激活码'],
[sandboxDelivered.code, '补单专用'],
].sort()
)
const others = await fetchJson(port, '/pay/my', { code: 'code-other' })
assert.deepStrictEqual(others.list, [])
// 发过货的单不再出现在待发货里
const pendingAfter = await fetchJson(port, '/pay/pending', { code: 'code-1' })
assert.notStrictEqual(pendingAfter.outTradeNo, paid.outTradeNo)
assert.notStrictEqual(pendingAfter.outTradeNo, sandbox.outTradeNo)
// 平台拒绝通知时:码照样给用户,但要把平台原话带回去,方便直接在手机上看到原因
// 先把试探 env 的那笔也发掉,保证后面只剩一笔未完成单,断言不被时序搅浑
await fetchJson(port, '/pay/deliver', { outTradeNo: stringEnv.outTradeNo, code: 'code-1' })
const failOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-1' })
seen.failNotify = true
const failed = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
assert.strictEqual(failed.paid, true)
assert.ok(failed.code, '平台通知失败也要把激活码给用户')
assert.strictEqual(failed.notified, false)
assert.ok(/44002/.test(failed.notifyError), failed.notifyError)
seen.failNotify = false
// 码发了但平台没确认的单,要能找回来继续重试通知
const pendingRetry = await fetchJson(port, '/pay/pending', { code: 'code-1' })
assert.strictEqual(pendingRetry.outTradeNo, failOrder.outTradeNo)
const retried = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
assert.strictEqual(retried.code, failed.code, '重试要给回同一张码')
assert.strictEqual(retried.notified, true)
// 通知成功后就不再提示了
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
// ---------- 发货推送 ----------
// 配置 URL 时的 GET 校验:签名对就原样回 echostr,签名错要 403
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery() + '&echostr=hello-123')).body, 'hello-123')
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery('wrong-token') + '&echostr=hello')).status, 403)
const pushOrder = await fetchJson(port, '/pay/order', { productId: '10002', code: 'code-1' })
const pushXml =
'<xml>' +
'<ToUserName><![CDATA[gh_test]]></ToUserName>' +
'<MsgType><![CDATA[event]]></MsgType>' +
'<Event><![CDATA[xpay_goods_deliver_notify]]></Event>' +
`<OpenId><![CDATA[${OPENID}]]></OpenId>` +
`<OutTradeNo><![CDATA[${pushOrder.outTradeNo}]]></OutTradeNo>` +
'<Env>0</Env>' +
'<WeChatPayInfo><MchOrderNo><![CDATA[wx-push-1]]></MchOrderNo></WeChatPayInfo>' +
'<GoodsInfo><ProductId><![CDATA[10002]]></ProductId><Quantity>1</Quantity></GoodsInfo>' +
'</xml>'
const notifyCallsBeforePush = seen.notifyCalls
const pushRes = await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })
assert.ok(/<ErrCode>0<\/ErrCode>/.test(pushRes.body), pushRes.body)
const afterPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
const pushCode = afterPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo)
assert.ok(pushCode && pushCode.productName === '高级激活码', '推送应该把货发出来')
// 推送已经回了 0,就不该再去调 notify_provide_goods
assert.strictEqual(seen.notifyCalls, notifyCallsBeforePush, '推送场景不用再手动通知平台')
// 同一个单再推一次:幂等,码不变
assert.ok(/<ErrCode>0<\/ErrCode>/.test((await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })).body))
const afterSecondPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
assert.strictEqual(afterSecondPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo).code, pushCode.code)
// ---------- 退款 ----------
const refunded = await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })
assert.strictEqual(refunded.ok, true, JSON.stringify(refunded))
const refundBody = JSON.parse(seen.refundBody)
assert.strictEqual(refundBody.order_id, pushOrder.outTradeNo)
assert.strictEqual(refundBody.left_fee, 10)
assert.strictEqual(refundBody.refund_fee, 10)
assert.strictEqual(refundBody.env, 0)
assert.ok(/^[\w-]{8,32}$/.test(refundBody.refund_order_id), refundBody.refund_order_id)
assert.strictEqual(seen.refundSig, paySig(APP_KEY, '/xpay/refund_order', seen.refundBody))
// 退完就不在「我的」里了,也不能重复退
const afterRefund = await fetchJson(port, '/pay/my', { code: 'code-1' })
assert.ok(!afterRefund.list.some((item) => item.outTradeNo === pushOrder.outTradeNo), '退款后激活码要作废')
assert.strictEqual((await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })).ok, false)
// 普通用户能正常买单,但退款是管理员功能
const normalOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other' })
assert.strictEqual(normalOrder.ok, true, JSON.stringify(normalOrder))
await fetchJson(port, '/pay/deliver', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
const normalRefund = await fetchJson(port, '/pay/refund', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
assert.strictEqual(normalRefund.ok, false)
assert.ok(/管理员/.test(normalRefund.error), normalRefund.error)
// ---------- 定时查单补发货 ----------
const cronDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-cron-'))
const cron = await startServer(Object.assign({}, serverEnv, { DATA_DIR: cronDataDir, PAY_POLL_MS: '200' }))
try {
const cronOrder = await fetchJson(cron.port, '/pay/order', { productId: '10001', code: 'code-1' })
const autoCode = await waitForCode(cron.port, cronOrder.outTradeNo, 5000)
assert.ok(autoCode, '付了钱但没进页面,定时任务也应该自动发货')
} finally {
cron.child.kill()
fs.rmSync(cronDataDir, { recursive: true, force: true })
}
// 落盘了:重启也能恢复
assert.ok(fs.existsSync(path.join(dataDir, 'orders.jsonl')), '订单文件没写出来')
// 用户表:首次使用会建行;这条是我们预置的管理员,标志要保持 true 不被覆盖
const userRows = fs
.readFileSync(path.join(dataDir, 'users.jsonl'), 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line))
assert.strictEqual(userRows[0].openid, OPENID)
assert.strictEqual(userRows[0].isAdmin, true)
assert.ok(userRows[0].createdAt > 0, '首次使用时间没记上')
console.log('服务端测试通过:下单签名、查单、发货幂等、归属校验、我的列表、发货推送、退款、定时补发货')
} finally {
child.kill()
fakeWx.close()
fs.rmSync(dataDir, { recursive: true, force: true })
}
}
main().catch((err) => {
console.error('测试失败:', err.message)
process.exit(1)
})