Files
mini-viewer/server/index.js
T
2026-09-29 01:14:24 +08:00

583 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
// 启动: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)
})