合并本轮全部文档改动(原 5 个 docs 提交压成 1 个)。 口径与索引: - 新增 docs/README.md 文档总索引:三来源口径(91 + 快手 send-code = kuaishou-91)、 「一个订单 = 骨架 + 买家凭证 + 一个执行载体」、成本只改一处、文档状态机 - 过期阶段文档移入 docs/archive/2026-10-08/(保留内容,不删除) 分析报告: - docs/项目全量分析报告-2026-10-17.md:架构、五条业务链路、P0/P1/P2 风险与改进路线 - docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:生产只读克隆量化、上一轮结论复核与 4 处修正 方案文档: - docs/成本中心与三块履约模型.md:三块执行载体、标准商品、成本版本与快照、人工支出登记 - docs/财务系统集成方案.md:复式记账模型、分录表、分期落地 - docs/影子总账说明.md:影子期与转正期的边界、对账与转正标准 SQL 草案(docs/drafts/,未接入迁移): - 载体判定与人工块量化.sql:量化查询、carrier_type 判定与回填、守恒校验 - 成本中心与销售事实-DDL草案.sql:product_catalog / channel_product_mappings / cost_items / cost_versions / cost_snapshots / cost_exceptions / sales_daily_facts - 人工支出登记-DDL草案.sql:manual_fulfillment_cases / manual_expense_records / manual_expense_events 业务口径确认(2026-10-17): - 收入 = 用户实付(orders.total_amount),与快手 paymentFen 数据核验 100% 一致 - 打手报酬 = 支出/成本(最终提现打款),按总额法 - 人工块以「支出可追溯」为先,SLA 监管延后 - 退款/仲裁暂未接入,先预留字段与反向冲正关系 三份草案已在一次性沙箱库按依赖顺序零错误应用(10 张新表、27 个 CHECK 约束)。
16 KiB
抖音码速达 · 最小接入实现说明
对应方案:05-最小接入方案分析.md。本文记录已经落地了什么、怎么配、怎么联调、有哪些坑。 状态:已实现;2026-10-17 复核后端 740 项测试(737 pass / 3 skipped)、前端 143 项全绿,生产构建通过。迁移
094/095已入库并在本地临时库验证,生产以启动时自动迁移记录为准。 本文已入库;04/05/06 属接入前历史分析,现行结论以 00-总览.md 与本文为准。
1. 闭环与落点
① trade_TradePaid 推送
→ POST /api/v1/open/douyin-mashida/message(验签 + webhook_events 幂等)
→ order.orderDetail 拉详情
→ upsertOrderFromSource 建单(provider=douyin_mashida / platform=doudian)
→ 每个订单项生成 DYC 券码 → coupons.syncV2 交给平台
② 买家打开 https://<域名>/redeem → 输订单号
→ POST /api/v1/redeem/lookup(状态分流)→ POST /api/v1/redeem/confirm
→ coupons.verifyV3 上报核销
③ refund_RefundSuccess / trade_TradeCanceled 推送 → 未核销券码作废
| 层 | 文件 | 说明 |
|---|---|---|
| 签名 | apps/backend/src/services/platforms/douyin-mashida/sign.ts |
两套 MD5 小写:出网 secret+Σ(名+值)+secret、推送 secret+data+pub_time+topic+secret;含 2 条 golden 向量单测 |
| 配置 | .../source-config-service.ts、config/app-config-keys.ts |
key douyin_mashida,字段 enabled/baseUrl/uid/secret/certLink/messageType/timeoutMs/handledTopics |
| 出网 | .../http-client.ts、.../gateway-client.ts |
网关 POST form-urlencoded、超时、报文日志、code!=10000 抛 502;4 个方法:orderDetail/syncV2/verifyV3/order.message(另附 cancelVerify) |
| 进单映射 | .../order-mapper.ts |
消息 + 详情 → SourceOrderEvent;抖店状态 → 内部 4 态;子单号写进 item 快照 |
| 推送入口 | .../notify-service.ts、routes/douyin-mashida.ts |
验签 → 幂等 → 分派;路由挂在全局 body parser 之前 |
| 券 | db/migrations/094_redeem_certificates.sql、repositories/redeem-certificate-repo.ts、services/redeem/certificate-service.ts |
DYC + Crockford Base32;(provider, order_id) 唯一做发券幂等;核销成功后一并调 order.message 通知买家(best-effort,失败只记错误日志) |
| 兑换 | services/redeem/redeem-service.ts、routes/redeem.ts |
`/api/v1/redeem/lookup |
| 后台配置 | services/admin/platform-config/douyin-mashida-service.ts、routes/admin/platform-config/douyin-mashida.ts |
GET/POST /api/v1/admin/platform-config/douyin-mashida,secret 出参置空 + 掩码,掩码回写不覆盖真值 |
| 前端 | apps/frontend/src/pages/redeem/RedeemPage.tsx 等 |
https://<域名>/redeem 路径式入口(独立 BrowserRouter 分支) |
2. 配置方式
配置存在 app_config_entries.douyin_mashida(JSONB),三种改法:
- 后台接口(推荐,需要 admin 会话):
POST /api/v1/admin/platform-config/douyin-mashida
{
"enabled": true,
"baseUrl": "https://dy-openplatform-api.wanmahui.com",
"uid": "<抖店商户号>",
"secret": "<用户秘钥>", // 不填/填掩码值 = 保留原值
"certLink": "https://ks.khhao.com/redeem",
"messageType": 2,
"afterVerifyLink": "https://...", // 可选:兑换成功后发给买家的「去使用链接」
"afterVerifyMessage": "您的订单已兑换成功", // 可选:通知正文,留空用默认文案
"timeoutMs": 10000,
"handledTopics": ["trade_TradePaid", "trade_TradeCanceled", "refund_RefundSuccess"]
}
- 前端面板(本期未做,见 §6)。
- 直接改库:
UPDATE app_config_entries SET config_json = ... WHERE config_key = 'douyin_mashida'(改完重启后端或触发配置缓存重载)。
GET返回里的ready字段是联调自查位:uid + secret + certLink三项齐了才是true。
平台侧要配:消息推送地址 https://<域名>/api/v1/open/douyin-mashida/message,订阅至少 trade_TradePaid(建议加 trade_TradeCanceled、refund_RefundSuccess)。
按文档调 message.config(status=1、message_url、topics)即可,也可以让平台后台代配。
3. 联调步骤
- 建表:重启后端(启动自动 up 迁移),确认
redeem_certificates存在。 - 造推送:用
docs/抖音-码速达/消息推送/01-买家已付款.md的签名算法 + 配置里的 secret 造一条请求打/api/v1/open/douyin-mashida/message:
curl -X POST 'https://<域名>/api/v1/open/douyin-mashida/message?topic=trade_TradePaid&pub_time=1780000000&sign=<md5>' \
-H 'content-type: text/plain' \
--data-raw '{"pay_amount":1990,"s_ids":[123],"order_status":2,"pay_time":1630373504,"p_id":"1234567890123456789","shop_id":77977}'
期望:第一次 200 + {code:0},重复推同一条返回 duplicate;验签错返回 401。
3. 验证建单与发券:查 orders(provider=douyin_mashida)、order_items、redeem_certificates(sync_status=success、cert_no 以 DYC 开头)。
4. 验证兑换:打开 https://<域名>/redeem 输订单号 → 看到脱敏摘要 → 确认 → 状态变 verified,同时平台侧显示已核销。
5. 验证作废:再推一条 refund_RefundSuccess(带 p_id)→ 券 status=invalid,兑换页提示已退款。
6. 真实小额单:走一遍真单,核对 order.orderDetail 落库字段与抖店后台一致(尤其子订单号,见 §4.1)。
4. 实现中的坑(都已在代码里处理)
4.1 19 位订单号会被 JSON.parse 抹掉精度 ★
抖店订单号是 18~19 位数字,超过 JS 安全整数(2^53-1)。平台消息里 p_id/s_ids 声明为 Integer,
JSON.parse('{"p_id":4837328765348567001}') 会得到 4837328765348567000 —— 拿它去建单、发券、回执会全部错位。
处理:notify-service.ts:parseMessageData() 在解析后,用正则从原始 JSON 文本里把长数字原样抓回来(存成字符串):
applyRawBigIntegerOverrides(parsed, rawText) // p_id / order_id / s_id / s_ids / aftersale_id / shop_order_id
- 单测里同时证明了「不修会错」(
JSON.parse对比)与「修完精确」。 - 本地所有订单号一律按字符串处理;测试 fixture 也不允许写数字字面量(lint
no-loss-of-precision会拦)。
4.2 验签必须用 body 原始字符串 → 路由挂在全局 parser 之前
app.ts 里 express.json() / express.urlencoded() 会重排/转义 JSON,签名原串就对不上了。
所以 app.use('/api/v1/open/douyin-mashida', douyinMashidaRouter) 挂在两个全局 parser 之前(app.ts:41),
路由内部用 express.text({ type: () => true }) 自己收原文,并兼容 data=<json> 的 urlencoded 形态。
4.3 幂等:平台没有 event_id
webhook_events(provider, event_id) 的唯一键要我们派生:
- 有订单号:
topic:pub_time:p_id:s_ids - 没有:
topic:pub_time:<data 的 sha256 前 16 位>
券本身还有一层幂等:redeem_certificates(provider, order_id) 唯一 → 重复推送不会重复发券。
4.4 核销的三道前置校验
verifyCertificate() 会依次拦:券不存在(404) → 已作废(409) → 尚未同步成功(409)。
已核销直接返回(幂等,不再打平台)。这样避免「平台侧根本没这张券却上报核销成功」。
4.5 兑换接口不抛错
POST /redeem/confirm 在不可兑换时返回当前状态(200 + status),而不是 4xx/500 ——
否则用户会看到报错页,而正确做法是提示「已退款 / 尚未支付 / 券准备中」。
4.6 order.orderDetail 没有商品明细时
抛 409 让平台重推(不造空单)。等联调确认是否真有这种时序问题;如果平台不重推,就要挂对账任务补。
4.7 兑换成功后的通知是 best-effort
order.message 在核销成功之后发送(正文 = afterVerifyMessage,配了 afterVerifyLink 就拼上「请点击使用:<链接>」)。
它失败不会让兑换失败(用户那边已经核销成功、页面也显示了结果),只打 level: 'error' 的集成日志,便于事后补发。
这样处理的原因:通知渠道是「测试接口」,可用性未知;而核销一旦上报不可逆,不该被通知故障拖累。
4.8 pay_time 有两种形态(实测才发现)★
- 消息推送里
pay_time是秒级 Integer(文档如此); order.orderDetail返回的却是格式化字符串"2026-10-09 12:28:02"(北京时间),create_time/finish_time同样是字符串,且"无"用"1970-01-01 08:00:00"(epoch 0)表示。
只按文档实现会把真实支付时间解析成空、退化成"收到时间"。
order-mapper.ts:parseDouyinMashidaTimeIso() 两种都认(纯数字按秒、字符串按 +08:00)。
4.9 文档里的示例值不能照抄
order.orderDetail 真实返回的 shop_id 是商户自己的(本例 305760014),
不是文档示例里的 77977;sku_order_list[].order_id 是字符串,本例与父单号相同。
联调时以真实响应为准。
4.10 商品展示口径:内部 SKU 才是商品
平台 sku_order_list[].code 是商家维护的内部 SKU(如「幸运币2个」),
product_name 是店铺里用来引流的长标题(如「【测试专用】和平精英皮肤小店入口…」)。
商家按内部 SKU 认货(后续履约匹配也用它),所以三个界面统一以内部 SKU 为主:
| 界面 | 展示 |
|---|---|
| 后台订单列表「商品 / 任务」 | 内部 SKU(主) + 引流标题(副,蓝色 tag) |
| 后台订单详情「商品明细」 | 商品 = 内部 SKU;SKU/规格 = 引流标题 |
兑换中心 /redeem |
商品 = 内部 SKU(粗体) + 引流标题(灰色小字) |
实现落在两个共享解析器(admin-read-shared-helpers.ts 的 resolveOrderItemTitle /
resolveOrderItemSkuNick)和 redeem-service.ts:buildSummary,
只对 source === 'douyin_mashida' 生效,其它平台展示逻辑不变。
4.11 ★ 平台会改写我们提交的券码(最坑的一条)
coupons.syncV2 返回 10000 成功,不代表平台沿用了你提交的 cert_no。
实测 3 笔订单里有 2 笔,平台把我们的 DYC… 券码换成了它自己生成的券码:
| 订单 | 我们提交的 cert_no | 平台侧实际 cert_no |
|---|---|---|
6930318258716179555 |
DYCM7HXC1TEJGB3VC4A |
沿用我们的 |
6930292726686514275 |
DYCAEPGNV5W4KDW2GJW |
ExsweiKSgejRcM3Z06(平台生成) |
6930310175251856483 |
DYC05EJH7G66VG0EVFN |
S683sWRJXaY74DZkrT(平台生成) |
连带后果:拿自己的券码去 coupons.verifyV3,平台报
10017 卡券核销失败[isv.business-failed:010021|核销码与订单不匹配] ——
而且这个错误要 ~10.5 秒才返回,越过 10 秒超时后前端只看到
douyin_mashida_gateway_timeout,真实原因完全被掩盖。
处理(迁移 095 + certificate-service):
- 同步成功后立即用
coupons.list(按order_id)回查平台侧真实券码, 存进platform_cert_no/platform_cert_id/platform_status; - 核销时
resolveRedeemVerifyCertNo():平台侧券码优先,取不到才退回我方券码; - 核销失败自愈:回查平台 → 若平台侧已核销则按对账结果标记本地已核销(修状态漂移); 若是券码被改名则用平台券码重试一次;
- 回查不到券 → 标记 sync 失败并告警(暴露"syncV2 成功但平台无券");
- 出网超时默认 10s → 20s。
4.12 平台真实推送的形态(实测抓到的原文)
订阅配好后,一笔真实付款的推送约 6 秒到达,来源 IP 是平台服务器(118.31.56.230),
body 原文(注意 p_id/s_ids 是裸数字、pay_time 是秒级):
{"biz":11,"order_status":2,"order_type":5,"p_id":6930292726686514275,"pay_amount":100,
"pay_time":1791521705,"pay_type":8,"s_ids":[6930292726686514275],"shop_id":305760014}
pay_type=8文档里没有(文档只列了 0/1/2),代码不依赖该字段,仅记录备查;p_id是 19 位裸数字 → 正好验证了 §4.1 的精度修复(落库/回执全程精确);- 端到端耗时:付款 12:55:05 → 推送 12:55:11 → 建单+发券+
syncV2完成 12:55:12。
4.13 平台推送是订阅制(不配就永远收不到)
填好 uid/secret 只代表我们能调平台,平台不会自动推消息过来:
必须调「消息设置」message.config 配置 message_url + topics + status=1。
而且不补推订阅之前的历史订单。详见 08-公网联调.md §2。
4.14 退款/取消消息文档没列字段
refund_*、trade_TradeCanceled、trade_TradeSuccess 这几条文档里 props 是空的(很可能只是文档缺失)。
代码按「有 p_id 就作废券、没有就留痕等人工」处理 —— 不抛错,避免平台无意义重推。
5. 与既有约定的对齐
| 约定 | 落实 |
|---|---|
| 密钥只读不回显 | 后台出参 secret: '' + secretMasked + hasSecret;掩码回写不覆盖(douyin-mashida-service.ts) |
| 审计不落密钥 | audit platform_douyin_mashida_config_updated,只记 hasUid/hasSecret/hasCertLink 等布尔 |
| 买家隐私不进工单素材 | post_tel/post_receiver/buyer_words/doudian_open_id 只进 orders 与 item 快照,不进打手工单素材 |
| 入站一律限流 | 推送 300/分钟(douyinMashidaMessage)、兑换 60/分钟(按 orderNo) |
| 复用而非重写 | 进单走 upsertOrderFromSource、幂等走 webhook_events、配置走 app_config_entries、审计走 createJsonHandler |
| 不碰老链路 | 快手电子凭证(kuaishou_industry_vouchers / KSV)与 91 领取链接一行未改 |
6. 本期没做的(已知缺口)
| # | 缺口 | 影响 | 后续 |
|---|---|---|---|
| 1 | 后台前端面板(/admin/platform-shops 加 Tab) |
只能在后台接口/DB 改配置 | 照抄 KuaishouIndustryPanel(含脱敏输入框) |
| 2 | 履约任务人工兜底(product-resolution-service.ts:165/346 未加分支) |
订单不会生成 fulfillment_tasks/工单,后台看不到「任务」 |
需要接打手时再加分支(方案 §5/G3) |
| 3 | 兑换成功后的实际交付物(卡密/充值) | 当前只做「上报核销 + 展示掩码券码 + order.message 发链接」,链接内容由 afterVerifyLink 配置 |
业务定了 B2 再挂卡密;或改走 topup.result |
| 4 | order.searchList 对账任务 |
消息丢了就漏单(人工按订单号补) | 二期加调度 job |
| 5 | coupons.cancelVerify 的后台按钮 |
误核销只能走代码/手工 | 已留出网方法 cancelDouyinMashidaCouponVerify |
| 6 | 兑换页二次校验(手机号后 4 位) | 残余风险:知道订单号即可兑换 | 看业务是否接受(问题清单 B1) |
| 7 | message.config 自动配置 |
需在平台侧手工配推送地址 | 可选:后台加「一键配置订阅」按钮 |
7. 待联调确认(从文档里挑出来的实测点)
message_type=2发出去会不会改变抖店订单的发货状态(我们只想发链接)。- 平台期望的成功响应体(当前返回
{"code":0,"message":"success"})。 - 平台重推策略(我们失败时返回 4xx,是否真会重推)。
coupons.syncV2的order_id到底认子订单号还是父订单号(我们按子单优先、父单兜底,联调打日志对比)。- 同步成功后买家在哪里看到卡券(决定用户能不能走到
/redeem)。 verifyV3重复核销的返回、能否cancelVerify冲正。order.orderDetail的p_id/order_id实际是字符串还是数字(决定 §4.1 的兜底是否必须,但已经做了不亏)。