feat: 微信统一消息推送入口——echostr握手+xpay事件接收(虚拟支付回调通道)
This commit is contained in:
@@ -34,6 +34,7 @@ services:
|
||||
- PORT=3001
|
||||
- MONGODB_URI=mongodb://${MONGO_USER:-wdkj}:${MONGO_PASS}@mongodb:27017/wdkj?authSource=admin
|
||||
- JWT_SECRET=${JWT_SECRET:?need JWT_SECRET}
|
||||
- WX_MSG_TOKEN=${WX_MSG_TOKEN:-}
|
||||
- SITE_URL=https://www.wdkj.yuzhiran.com.cn
|
||||
- INVITE_REWARD_CREDITS=${INVITE_REWARD_CREDITS:-100}
|
||||
- COST_PER_WORK=${COST_PER_WORK:-10}
|
||||
|
||||
@@ -10,6 +10,7 @@ const rateLimit = require('express-rate-limit')
|
||||
const connectDB = require('./config/database')
|
||||
const initRoutes = require('./routes')
|
||||
const seoRoutes = require('./routes/seo')
|
||||
const registerWxMsgRoutes = require('./routes/wxmsg')
|
||||
const logger = require('./utils/logger')
|
||||
|
||||
// 创建 Express 应用
|
||||
@@ -33,6 +34,17 @@ app.use(cors()) // 跨域支持
|
||||
app.use(compression()) // Gzip 压缩
|
||||
app.use(express.json({ limit: '1mb' })) // JSON 解析(普通接口无需 10mb 大包)
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' })) // URL 编码解析
|
||||
// 微信消息推送为 text/xml 体,透传为字符串供 /api/wx/msg 解析
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'POST' && (req.headers['content-type'] || '').includes('xml')) {
|
||||
let data = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', (c) => (data += c))
|
||||
req.on('end', () => { req.body = data; next() })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// 日志中间件
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -62,6 +74,7 @@ app.get('/health', (req, res) => {
|
||||
})
|
||||
|
||||
// API 路由
|
||||
registerWxMsgRoutes(app)
|
||||
initRoutes(app)
|
||||
|
||||
// 404 处理
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
/**
|
||||
* 微信小程序统一消息推送入口(虚拟支付回调走此通道)
|
||||
* GET = 平台校验握手(echostr)
|
||||
* POST = 事件推送(XML:xpay_coin_pay_notify 代币支付 / xpay_goods_deliver_notify 道具发货 / 退款等)
|
||||
*
|
||||
* 环境变量:
|
||||
* WX_MSG_TOKEN 消息推送 Token(与 MP 后台「开发管理-消息推送」一致)
|
||||
* WX_MSG_AESKEY 安全模式 EncodingAESKey(暂用明文/兼容模式可不填)
|
||||
*/
|
||||
module.exports = function registerWxMsgRoutes(app) {
|
||||
const TOKEN = process.env.WX_MSG_TOKEN || ''
|
||||
|
||||
function verifySignature(timestamp, nonce, signature) {
|
||||
if (!TOKEN) return false
|
||||
const sha1 = crypto.createHash('sha1')
|
||||
sha1.update([TOKEN, timestamp, nonce].sort().join(''))
|
||||
return sha1.digest('hex') === signature
|
||||
}
|
||||
|
||||
/** 轻量 XML 字段提取(xpay 推送为扁平结构,够用且无外部依赖) */
|
||||
function xmlFields(xml) {
|
||||
const out = {}
|
||||
const re = /<([A-Za-z0-9_]+)><!\[CDATA\[([\s\S]*?)\]\]><\/\1>|<([A-Za-z0-9_]+)>([^<]*)<\/\3>/g
|
||||
let m
|
||||
while ((m = re.exec(xml))) out[m[1] || m[3]] = m[2] ?? m[4] ?? ''
|
||||
return out
|
||||
}
|
||||
|
||||
app.get('/api/wx/msg', async (req, res) => {
|
||||
const { signature, timestamp, nonce, echostr } = req.query || {}
|
||||
if (verifySignature(timestamp, nonce, signature)) {
|
||||
return res.status(200).send(String(echostr || ''))
|
||||
}
|
||||
return res.status(403).send('forbidden')
|
||||
})
|
||||
|
||||
app.post('/api/wx/msg', async (req, res) => {
|
||||
try {
|
||||
const raw = typeof req.body === 'string' ? req.body : JSON.stringify(req.body || {})
|
||||
const fields = xmlFields(String(raw))
|
||||
const event = fields.Event || fields.event || ''
|
||||
console.log('[wx-msg] 收到推送 Event=' + event, JSON.stringify(fields).slice(0, 500))
|
||||
// TODO(vpay接入):按事件分发改写用户星尘余额/发货状态
|
||||
// xpay_coin_pay_notify 代币充值到账
|
||||
// xpay_goods_deliver_notify 道具发货
|
||||
// xpay_refund_notify 退款
|
||||
return res.status(200).json({ ErrCode: 0, ErrMsg: '' })
|
||||
} catch (e) {
|
||||
console.error('[wx-msg] 处理失败:', e.message)
|
||||
// 返回非0会在平台侧重推(最多15次)
|
||||
return res.status(200).json({ ErrCode: 0, ErrMsg: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user