- 签名:出网 md5(secret+Σ(名+值)+secret) 与推送 md5(secret+data+pub_time+topic+secret) 两套独立实现,含文档 golden 向量单测(与 91 大写 / affiliate_dash HMAC 都不通用) - 进单:trade_TradePaid 推送 → 验签 → webhook_events 幂等 → order.orderDetail → upsertOrderFromSource(provider=douyin_mashida / platform=doudian) - 精度:19 位订单号超出 JS 安全整数,JSON.parse 会把 p_id 抹成 ...000; 故解析后从原始 JSON 文本用正则抓回精确数字(单测同时证明"不修会错") - 券码:迁移 094 redeem_certificates((provider,order_id) 唯一做发券幂等) + DYC 券码生成 + coupons.syncV2 同步(子单号优先、父单兜底) - 兑换:POST /api/v1/redeem/lookup|confirm,按 orderNo+IP 限流; 核销成功后 order.message 通知买家(best-effort,失败只记错误日志) - 退款/取消:作废未核销券;券已核销时打 WARN 资损告警(已消费不可回滚) - 后台:GET/POST /api/v1/admin/platform-config/douyin-mashida, secret 出参置空 + 掩码,掩码回写不覆盖原值 - 消息路由挂在全局 body parser 之前(app.ts),验签必须用 body 原始字符串
68 lines
2.5 KiB
TypeScript
68 lines
2.5 KiB
TypeScript
import express, { Router } from 'express'
|
||
|
||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||
import { handleDouyinMashidaMessage } from '../services/platforms/douyin-mashida/notify-service.js'
|
||
import { sendRouteError } from '../utils/http.js'
|
||
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||
|
||
/**
|
||
* 抖音码速达消息推送入口。
|
||
*
|
||
* ⚠️ 这个路由必须在 `app.ts` 的 `express.json()` / `express.urlencoded()` **之前**挂载:
|
||
* 平台签名用的是 `data` 的**原始 JSON 字符串**,任何全局 body parser 都会破坏它。
|
||
* 这里用 `express.text({ type: () => true })` 自己收原文(与快手/91 的入站不同,那两个不需要 rawBody)。
|
||
*
|
||
* 推送形态:`POST /api/v1/open/douyin-mashida/message?topic=&pub_time=&sign=`,body 为 data 的 JSON 字符串。
|
||
*/
|
||
const router = Router()
|
||
|
||
const messageRateLimit = createRateLimitMiddleware({
|
||
scope: 'douyinMashidaMessage',
|
||
windowMs: 60_000,
|
||
max: 300,
|
||
})
|
||
|
||
const captureRawBody = express.text({ type: () => true, limit: '2mb' })
|
||
|
||
router.all('/message', messageRateLimit, captureRawBody, async (req, res) => {
|
||
const requestId = createRequestId('dym')
|
||
const startedAt = Date.now()
|
||
const topic = String(req.query.topic || '').trim()
|
||
const pubTime = String(req.query.pub_time || req.query.pubTime || '').trim()
|
||
const sign = String(req.query.sign || '').trim()
|
||
const rawBody = typeof req.body === 'string' ? req.body : ''
|
||
|
||
logIntegration('[douyin-mashida/message]', '收到码速达消息推送', {
|
||
requestId,
|
||
method: req.method,
|
||
originalUrl: req.originalUrl,
|
||
ip: req.ip,
|
||
topic,
|
||
pubTime,
|
||
bodyLength: rawBody.length,
|
||
})
|
||
|
||
try {
|
||
const result = await handleDouyinMashidaMessage({ topic, pubTime, sign, rawBody })
|
||
|
||
logIntegration('[douyin-mashida/message]', '消息推送处理完成', {
|
||
requestId,
|
||
durationMs: Date.now() - startedAt,
|
||
...result,
|
||
})
|
||
|
||
// 平台侧只要求「处理成功」;具体期望的响应体在联调时确认(失败时用 4xx/5xx 触发平台重推)
|
||
res.status(200).json({ code: 0, message: 'success', data: { action: result.action } })
|
||
} catch (error) {
|
||
logIntegration(
|
||
'[douyin-mashida/message]',
|
||
'消息推送处理失败',
|
||
{ requestId, durationMs: Date.now() - startedAt, topic, error },
|
||
{ level: 'error' },
|
||
)
|
||
sendRouteError(res, error, '抖音码速达消息处理失败', '[douyin-mashida/message]')
|
||
}
|
||
})
|
||
|
||
export default router
|