feat(douyin-mashida): 抖音码速达最小接入后端(消息建单/券码同步/兑换核销/退款作废)

- 签名:出网 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 原始字符串
This commit is contained in:
yml2213
2026-10-09 12:00:50 +08:00
parent 893388b37f
commit 72606796b3
21 changed files with 2806 additions and 0 deletions
@@ -6,6 +6,7 @@ import ninetyoneRouter from './platform-config/ninetyone.js'
import notificationsRouter from './platform-config/notifications.js'
import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js'
import affiliateDashRouter from './platform-config/affiliate-dash.js'
import douyinMashidaRouter from './platform-config/douyin-mashida.js'
import pddRouter from './platform-config/pdd.js'
const router = Router()
@@ -17,5 +18,7 @@ router.use('/platform-config', ninetyoneRouter)
router.use('/platform-config', fulfillmentRoutingRouter)
router.use('/platform-config', affiliateDashRouter)
router.use('/platform-config', pddRouter)
// 抖音码速达:注册在 requireAdminRoles(['admin']) 之后,自动继承 admin 守卫
router.use('/platform-config', douyinMashidaRouter)
export default router
@@ -0,0 +1,49 @@
import { Router } from 'express'
import {
getAdminDouyinMashidaConfig,
saveAdminDouyinMashidaConfig,
} from '../../../services/admin/platform-config/douyin-mashida-service.js'
import { createJsonHandler } from '../session.js'
import type { JsonRecord } from '../../../types/json.js'
const router = Router()
router.get(
'/douyin-mashida',
createJsonHandler(() => getAdminDouyinMashidaConfig(), {
successMessage: 'ok',
errorMessage: '读取抖音码速达配置失败',
scope: '[admin/platform-config/douyin-mashida]',
}),
)
router.post(
'/douyin-mashida',
createJsonHandler((req) => saveAdminDouyinMashidaConfig(req.body as JsonRecord), {
successMessage: '抖音码速达配置已保存',
errorMessage: '保存抖音码速达配置失败',
scope: '[admin/platform-config/douyin-mashida]',
audit: (_req, data) => {
const config = data as JsonRecord
return {
action: 'platform_douyin_mashida_config_updated',
targetType: 'platform_config',
targetId: 'douyin_mashida',
// 审计只记摘要,绝不写密钥
data: {
enabled: config.enabled !== false,
hasUid: Boolean(config.uid),
hasSecret: Boolean(config.hasSecret),
hasCertLink: Boolean(config.certLink),
hasAfterVerifyLink: Boolean(config.afterVerifyLink),
messageType: Number(config.messageType || 0),
handledTopicCount: Array.isArray(config.handledTopics) ? config.handledTopics.length : 0,
},
}
},
}),
)
export default router
+67
View File
@@ -0,0 +1,67 @@
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
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express'
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js'
import { confirmRedeemOrder, lookupRedeemOrder } from '../services/redeem/redeem-service.js'
import { createRouteHandler } from '../utils/http.js'
/**
* 固定兑换链接的后端接口(前端页面在 `https://<域名>/redeem`)。
*
* 公开接口(无需登录),但按「订单号 + IP」限流:这是无 token 方案的第一道防线
* (见 docs/抖音-码速达/05-最小接入方案分析.md §3.2)。
* 与 `/collect`(routes/collect.ts)完全同型。
*/
const router = Router()
const redeemRateLimit = createRateLimitMiddleware({
scope: 'redeem',
windowMs: 60_000,
max: 60,
key: getBodyFieldRateLimitKey('orderNo'),
})
router.post(
'/lookup',
redeemRateLimit,
createRouteHandler((req) => lookupRedeemOrder({ orderNo: req.body?.orderNo }), {
successMessage: 'ok',
errorMessage: '查询订单失败',
scope: '[redeem/lookup]',
}),
)
router.post(
'/confirm',
redeemRateLimit,
createRouteHandler((req) => confirmRedeemOrder({ orderNo: req.body?.orderNo }), {
successMessage: '兑换完成',
errorMessage: '兑换失败',
scope: '[redeem/confirm]',
}),
)
export default router