小程序接入支付功能
This commit is contained in:
1 parent
edf1e51846
commit
07a90e860d
38 files changed
+3644
-90
No files matched your search
+582
@@ -0,0 +1,582 @@
|
||||
// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
|
||||
// 启动: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)
|
||||
})
|
||||
Reference in new issue
Block a user