// 用法: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}×tamp=${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 = '' + '' + '' + '' + `` + `` + '0' + '' + '1' + '' const notifyCallsBeforePush = seen.notifyCalls const pushRes = await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' }) assert.ok(/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(/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) })