// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单 // 启动: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 已发货 // 订单渠道:7 / 8 是苹果 iOS(内购)订单,开发者不能主动退款 const isAppleOrder = (order) => [7, 8].includes(Number(order && order.order_type)) // 从推送的 XML 里取字段,值可能在 CDATA 里 function xmlTag(xml, name) { const matched = new RegExp(`<${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, channel: isAppleOrder(detail) ? 'apple' : 'wxpay', }) 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, channel: record.channel || '', 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 = '0' 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(`1`) } 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) })