// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
// 启动: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}>(?:)?${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 = '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)
})