583 lines
22 KiB
JavaScript
583 lines
22 KiB
JavaScript
// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
|
||
// 启动: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)
|
||
})
|