Files
order_site/docs/抖音-码速达/07-最小接入实现说明.md
yml2213 7bacb7d5eb docs: 重构项目文档并补充三块载体、成本中心与人工支出方案
合并本轮全部文档改动(原 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 约束)。
2026-10-17 22:03:03 +08:00

16 KiB
Raw Permalink Blame History

抖音码速达 · 最小接入实现说明

对应方案: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),三种改法:

  1. 后台接口(推荐,需要 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"]
}
  1. 前端面板(本期未做,见 §6)。
  2. 直接改库: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. 联调步骤

  1. 建表:重启后端(启动自动 up 迁移),确认 redeem_certificates 存在。
  2. 造推送:用 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):

  1. 同步成功后立即用 coupons.list(按 order_id)回查平台侧真实券码, 存进 platform_cert_no / platform_cert_id / platform_status;
  2. 核销时 resolveRedeemVerifyCertNo():平台侧券码优先,取不到才退回我方券码;
  3. 核销失败自愈:回查平台 → 若平台侧已核销则按对账结果标记本地已核销(修状态漂移); 若是券码被改名则用平台券码重试一次;
  4. 回查不到券 → 标记 sync 失败并告警(暴露"syncV2 成功但平台无券");
  5. 出网超时默认 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. 待联调确认(从文档里挑出来的实测点)

  1. message_type=2 发出去会不会改变抖店订单的发货状态(我们只想发链接)。
  2. 平台期望的成功响应体(当前返回 {"code":0,"message":"success"})。
  3. 平台重推策略(我们失败时返回 4xx,是否真会重推)。
  4. coupons.syncV2 的 order_id 到底认子订单号还是父订单号(我们按子单优先、父单兜底,联调打日志对比)。
  5. 同步成功后买家在哪里看到卡券(决定用户能不能走到 /redeem)。
  6. verifyV3 重复核销的返回、能否 cancelVerify 冲正。
  7. order.orderDetail 的 p_id/order_id 实际是字符串还是数字(决定 §4.1 的兜底是否必须,但已经做了不亏)。