diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 65e501b..a11a732 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/server/src/index.js b/server/src/index.js index ad4073f..5a2631f 100755 --- a/server/src/index.js +++ b/server/src/index.js @@ -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 处理 diff --git a/server/src/routes/wxmsg.js b/server/src/routes/wxmsg.js new file mode 100644 index 0000000..3c65050 --- /dev/null +++ b/server/src/routes/wxmsg.js @@ -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_]+)><\/\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: '' }) + } + }) +}