docs(douyin-mashida): 接入分析、实现说明与公网联调文档

- 00-总览:进度索引(闭环落点 / 代码清单 / 配置项 / 上线步骤 / 缺口 / 待实测点)
- 04-结合本项目的深度分析:深读平台文档后与仓库既有实现的对应关系
- 05-最小接入方案分析:实施方案与取舍(本期只做抖音、不做打手、不做多平台)
- 06-待确认问题清单:已答复项与"边做边测"项
- 07-最小接入实现说明:实现要点、上线三步、两个必须记住的坑
  (19 位订单号 JSON.parse 丢精度 / 验签必须用 body 原文)
- 08-公网联调:ngrok 隧道 + 一键切地址 + 前置自检 + 联调 checklist + 排错表
This commit is contained in:
yml2213
2026-10-09 12:01:49 +08:00
parent e9a87194bd
commit d9848aaef3
6 changed files with 1756 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
# 抖音码速达 · 总览(进度 / 代码 / 配置 / 上线)
> 更新:2026-10-09 **状态:最小接入已实现完毕(含后台配置面板 + ngrok 公网联调工具链),两端自检全绿,已在真实公网与真实网关上验证,代码尚未提交(等确认)**
> 本文是这套内容的**入口**,具体细节在 04~09。
---
## 一、一句话现状
按 [05-最小接入方案分析.md](05-最小接入方案分析.md) 落地的**最小闭环六步全部完成**,后端 636 测试 / 前端 115 测试全绿,迁移 `094` 已在本地临时库实测通过(建表 + 幂等 + 唯一约束行为均验证)。
**没做的**:履约任务/工单接入、兑换后的实际交付物(卡密/充值)、`order.searchList` 对账。
**只走真实公网联调**(离线模拟已按要求删除):
1. `ngrok http 80` 起隧道拿公网地址
2. `douyin-mashida:set-public-url` 一键把 `certLink` 切过去 + 打印平台侧待配项 + 自检隧道
3. `douyin-mashida:preflight` 前置自检(配置就绪度 / **真实网关连通性** / 公网回调可达 / 域名一致性)
详见 [08-公网联调.md](08-公网联调.md)。
> ⚠️ localhost 永远收不到平台推送、也发不出买家能打开的链接 —— **必须挂隧道**。
---
## 二、闭环与落点
```text
① trade_TradePaid 推送
POST /api/v1/open/douyin-mashida/message
└─ 验签(MD5 小写)→ 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 上报核销
└─ order.message 把「去使用链接」发给买家(best-effort)
④ refund_RefundSuccess / trade_TradeCanceled 推送
└─ 该订单未核销的券码作废
```
| 环节 | 入口 | 核心实现 |
| --- | --- | --- |
| 消息接收 | `routes/douyin-mashida.ts`(挂在全局 body parser **之前**) | `notify-service.ts` |
| 签名 | — | `sign.ts`(出网 + 入站两套,含 golden 向量单测) |
| 拉详情/发券/核销/通知 | — | `gateway-client.ts` + `http-client.ts` |
| 建单 | 复用 `services/order/order-service.ts:upsertOrderFromSource` | `order-mapper.ts` |
| 券码 | 迁移 `094` | `redeem-certificate-repo.ts` + `certificate-service.ts` |
| 兑换 | `routes/redeem.ts` | `redeem-service.ts` |
| 后台配置 | `routes/admin/platform-config/douyin-mashida.ts` | `douyin-mashida-service.ts`(凭据脱敏)+ 前端面板 `DouyinMashidaPanel.tsx` |
| 本地联调 | `scripts/douyin-mashida-gateway-stub.ts` / `douyin-mashida-push.ts` | 网关桩 + 推送模拟,见文档 08 |
| 公网联调 | `scripts/douyin-mashida-set-public-url.ts` + `douyin-mashida-preflight.ts` + ngrok | 一键切地址 / 自检隧道 / 真实网关连通性自检,见文档 08 |
| 前端页面 | `pages/redeem/RedeemPage.tsx` | `App.tsx` 的独立 `BrowserRouter` 分支 |
---
## 三、代码清单(23 个文件 / 3107 行)
### 新增 · 后端
| 文件 | 行 | 作用 |
| --- | ---: | --- |
| `services/platforms/douyin-mashida/sign.ts` | 130 | 两套签名:出网 `md5(secret+Σ(名+值)+secret)`、推送 `md5(secret+data+pub_time+topic+secret)` |
| `services/platforms/douyin-mashida/source-config-service.ts` | 154 | 配置读写 + 默认值 + `assertDouyinMashidaConfig` |
| `services/platforms/douyin-mashida/http-client.ts` | 165 | 网关 POST、超时、报文日志、`code !== 10000` 抛 502 |
| `services/platforms/douyin-mashida/gateway-client.ts` | 109 | 4 个出网方法:`orderDetail` / `syncV2` / `verifyV3` / `order.message`(+`cancelVerify`) |
| `services/platforms/douyin-mashida/order-mapper.ts` | 214 | 消息+详情 → `SourceOrderEvent`;抖店状态映射;子单号入 item 快照 |
| `services/platforms/douyin-mashida/notify-service.ts` | 384 | 验签 → 幂等 → 分派(付款建单发券 / 退款作废) |
| `services/platforms/douyin-mashida/*.test.ts` | 407 | 3 个测试文件 / 24 用例 |
| `services/redeem/certificate-service.ts` | 312 | 发券、syncV2、核销上报、核销后通知、作废 |
| `services/redeem/redeem-service.ts` | 269 | 兑换状态机(7 态)+ 券码掩码 + 订单号校验 |
| `services/redeem/redeem-service.test.ts` | 69 | 6 用例 |
| `repositories/redeem-certificate-repo.ts` | 233 | DYC 券码生成(Crockford Base32)、发券幂等、状态流转 |
| `routes/douyin-mashida.ts` | 67 | 消息入口(`express.text` 收原文,兼容 `data=` 表单) |
| `routes/redeem.ts` | 43 | `/lookup` + `/confirm`,按 `orderNo`+IP 限流 |
| `routes/admin/platform-config/douyin-mashida.ts` | 49 | 后台 GET/POST + 审计 |
| `services/admin/platform-config/douyin-mashida-service.ts` | 75 | 配置读写,secret 出参置空 + 掩码,掩码回写不覆盖 |
| `db/migrations/094_redeem_certificates.sql` | 57 | `redeem_certificates` 表 |
| `scripts/douyin-mashida-gateway-stub.ts` | 320 | **本地网关桩**:假装码速达,校验签名并回 `code=10000` |
| `scripts/douyin-mashida-push.ts` | 300 | **推送模拟器**:造签名消息打本地后端 |
| `scripts/douyin-mashida-set-public-url.ts` | 290 | **公网联调切换**:改 `certLink` 为隧道地址 + 打印平台侧配置 + 自检隧道 |
| `scripts/douyin-mashida-preflight.ts` | 330 | **前置自检**:配置就绪度 + 真实网关连通性(可选验签名)+ 公网回调 + 域名一致性 |
### 新增 · 前端
| 文件 | 行 | 作用 |
| --- | ---: | --- |
| `pages/redeem/RedeemPage.tsx` | 166 | 兑换页(同 CollectPage 骨架,按 status 渲染) |
| `utils/redeem.ts` | 82 | 纯函数:`isRedeemPath` / 金额时间格式化 / 状态→文案色调 |
| `types/redeem.ts` | 39 | 与后端 `RedeemOrderView` 对齐 |
| `services/redeem.ts` | 16 | `lookupRedeemOrder` / `confirmRedeemOrder` |
| `tests/redeem.test.ts` | 67 | 7 用例 |
| `pages/admin/platform/DouyinMashidaPanel.tsx` | 346 | 后台「抖音码速达」配置面板(进度/脱敏密钥/本地桩提示) |
| `components/admin/SecretFieldControl.tsx` | 158 | 脱敏密钥输入组件(从 `AdminPlatformShopsPage` 抽出,两侧共用) |
| `utils/douyin-mashida.ts` | 64 | 纯函数:本地地址判定 / 推送地址拼接 / 缺失项提示 |
| `types` + `services/admin/platform-config/douyin-mashida.ts` | 49 | 配置读写服务 |
| `tests/douyin-mashida.test.ts` | 86 | 6 用例 |
### 修改
| 文件 | 改了什么 |
| --- | --- |
| `apps/backend/src/app.ts` | 挂载 `/api/v1/open/douyin-mashida`(**必须在 `express.json` 之前**)+ `/api/v1/redeem` |
| `apps/backend/src/config/app-config-keys.ts` | 新增 key `douyin_mashida` |
| `apps/backend/src/routes/admin/platform-config.ts` | 注册后台配置路由(自动继承 admin 守卫) |
| `apps/backend/package.json` | 新增 `douyin-mashida:set-public-url` / `douyin-mashida:preflight` |
| `apps/frontend/vite.config.ts` | dev server `allowedHosts` 放行常见隧道域名;**接上了此前从未被读取的 `VITE_ALLOWED_HOSTS`**(`.env`/compose 早就配了,白名单却是硬编码的) |
| `apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx` | 新增「抖音码速达」Tab;本地 `SecretFieldControl` 改为共享组件 import |
| `apps/frontend/src/App.tsx` | 新增 `isRedeemPath` 分支(早于 `isWorkerSite()`)+ `RedeemApplication` |
| `apps/frontend/src/styles/client.css` | `.redeem-page` / `.redeem-card` / `.redeem-order-input` |
---
## 四、配置清单
存在 `app_config_entries.douyin_mashida`(JSONB)。后台接口:`POST /api/v1/admin/platform-config/douyin-mashida`
| 字段 | 默认 | 说明 |
| --- | --- | --- |
| `enabled` | `true` | 关掉后推送接口返回 409 |
| `baseUrl` | `https://dy-openplatform-api.wanmahui.com` | 网关地址 |
| `uid` | 空 | 抖店商户号(必填) |
| `secret` | 空 | 用户秘钥(必填);**出参永不明文回显** |
| `certLink` | 空 | 卡券里的兑换链接,固定 `https://<域名>/redeem`(必填) |
| `messageType` | `2` | `order.message` 的类型:1-订单状态 / 2-订单发货 / 4-图片 / 5-视频 |
| `afterVerifyLink` | 空 | 兑换成功后发给买家的「去使用链接」 |
| `afterVerifyMessage` | `您的订单已兑换成功` | 通知正文 |
| `timeoutMs` | `10000` | 出网超时 |
| `handledTopics` | `trade_TradePaid` / `trade_TradeCanceled` / `refund_RefundSuccess` | 白名单外的 topic 只留痕 |
`GET` 会返回 `ready` 自查位(`enabled + uid + secret + certLink` 齐全才为 `true`)。
---
## 五、文档索引
| 文档 | 内容 | 是否入库 |
| --- | --- | --- |
| [README.md](README.md) | 抓取说明 + 目录 | ✅ 已提交(`5978e923`) |
| [01-计费规则.md](01-计费规则.md) | 平台计费 | ✅ |
| [02-接口调用规范与签名.md](02-接口调用规范与签名.md) | 出网签名规则 | ✅ |
| [03-消息推送规范与签名.md](03-消息推送规范与签名.md) | 推送与验签 | ✅ |
| `接口API/`、`消息推送/`、`data/` | 全量抓取快照(46 个文件 / 488K) | ✅ |
| [04-结合本项目的深度分析.md](04-结合本项目的深度分析.md) | 深读平台后结合仓库的分析 | ❌ 仅本地 |
| [05-最小接入方案分析.md](05-最小接入方案分析.md) | **实施方案**(决策与取舍都在这) | ❌ 仅本地 |
| [06-待确认问题清单.md](06-待确认问题清单.md) | 问题清单(Q1~Q4 已答,其余边做边测) | ❌ 仅本地 |
| [07-最小接入实现说明.md](07-最小接入实现说明.md) | 实现说明 + 联调步骤 + 坑 | ❌ 仅本地 |
| [08-公网联调.md](08-公网联调.md) | **真实公网联调**(ngrok + 一键切地址 + 前置自检 + checklist + 坑) | ❌ 仅本地 |
| 本文 `00-总览.md` | 进度索引 | ❌ 仅本地 |
---
## 六、提交范围建议
只提交本次抖音相关的 22 个文件(**不要 `git add -A`**,同一分支有并行会话在改寄售):
```bash
git add apps/backend/src/app.ts \
apps/backend/src/config/app-config-keys.ts \
apps/backend/src/routes/admin/platform-config.ts \
apps/backend/src/routes/admin/platform-config/douyin-mashida.ts \
apps/backend/src/routes/douyin-mashida.ts \
apps/backend/src/routes/redeem.ts \
apps/backend/src/db/migrations/094_redeem_certificates.sql \
apps/backend/src/repositories/redeem-certificate-repo.ts \
apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts \
apps/backend/src/services/platforms/douyin-mashida \
apps/backend/src/services/redeem \
apps/frontend/src/App.tsx \
apps/frontend/src/styles/client.css \
apps/frontend/src/pages/redeem \
apps/frontend/src/services/redeem.ts \
apps/frontend/src/types/redeem.ts \
apps/frontend/src/utils/redeem.ts \
apps/frontend/tests/redeem.test.ts
```
建议 commit message:`feat(douyin-mashida): 接入抖音码速达最小闭环(消息建单/券码同步/固定兑换页/核销回执)`
> 04~07 与本文档默认**不入库**(分析类内容)。若要留档,建议单独一次 `docs(douyin-mashida): ...` 提交。
---
## 七、上线步骤
1. **发版**:`bash deploy/ubuntu-deploy.sh`(启动时自动 up 迁移 `094`,建 `redeem_certificates` 表)
2. **配平台**:后台 `POST /api/v1/admin/platform-config/douyin-mashida` 填 `uid` / `secret` / `certLink=https://<域名>/redeem`;确认 `GET` 返回 `ready: true`
3. **配推送**:平台侧把消息地址设为 `https://<域名>/api/v1/open/douyin-mashida/message`,订阅 `trade_TradePaid`(建议加 `trade_TradeCanceled`、`refund_RefundSuccess`)
**Caddy 不用改**:已有 `try_files {path} /index.html`,`/redeem` 直接可用(这也是当初选路径式而非 `#` 的原因)。
---
## 八、缺口与待联调
### 没做的(按优先级)
| # | 缺口 | 影响 | 备注 |
| --- | --- | --- | --- |
| ~~1~~ | ~~后台前端配置面板~~ | **已完成**:`/admin/platform-shops?tab=douyinMashida` | — |
| 2 | 履约任务/工单分支 | 订单不进打手池,后台"任务"里看不到 | `product-resolution-service.ts` 未加分支(**故意**:本期不做打手) |
| 3 | 兑换后的实际交付物 | 当前只「上报核销 + 展示掩码券码 + 发链接」 | 等业务定(B2) |
| 4 | `order.searchList` 对账任务 | 消息丢了就漏单 | 二期 |
| 5 | `cancelVerify` 后台按钮 | 误核销只能手工 | 出网方法已就绪 |
| 6 | 兑换页二次校验(手机后 4 位) | 知道订单号即可兑换 | 看业务是否接受(B1) |
| 7 | `message.config` 自动订阅 | 需平台侧手配 | 可选做「一键订阅」 |
| 8 | 隧道域名变化需手工同步平台侧 | `set-public-url` 会打印待配项;ngrok 免费版建议保留静态域名 | 固定域名后基本不是问题 |
| 9 | ngrok 免费版对浏览器插警示页 | 买家点 `cert_link` 会先过一页英文提示 | 正式环境用正式域名;联调期间点一下即可 |
### 联调要实测的点
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` 实际是字符串还是数字
---
## 九、两个必须记住的坑
### 1)19 位订单号会被 `JSON.parse` 抹精度 ★
抖店订单号超过 JS 安全整数,`4837328765348567001` 经 `JSON.parse` 会变成 `...000`。
处理:`notify-service.ts:parseMessageData()` 解析后用正则从**原始 JSON 文本**把长数字原样抓回(字符串),
`applyRawBigIntegerOverrides` 覆盖 `p_id`/`order_id`/`s_id`/`s_ids`/`aftersale_id` 等。单测同时证明了"不修会错"和"修完精确"。
### 2)验签必须用 body 原文 → 路由挂在全局 parser 之前
`app.ts:42` 把 `/api/v1/open/douyin-mashida` 挂在 `express.json()` / `express.urlencoded()` **之前**,
路由内部用 `express.text({ type: () => true })` 自己收原文(并兼容 `data=<json>` 的表单形态)。
任何全局 body parser 都会重排/转义 JSON,签名原串就对不上。
---
## 十、自检结果(本次)
| 项 | 结果 |
| --- | --- |
| 后端 `npm run typecheck` | EXIT=0 |
| 新增单测(douyin-mashida + redeem) | **30 / 30 通过** |
| 后端 `npm run check`(format+sql+lint+typecheck+test+build) | EXIT=0,636 测试 / 633 通过 / 0 失败 |
| 前端 `npm run check` | EXIT=0,115 / 115 通过 |
| 迁移 `094` | 在本地 dev Postgres 临时库实测通过;重复 `(provider, order_id)` 插入返回 `INSERT 0 0`、重复券码触发唯一键 |
@@ -0,0 +1,358 @@
# 抖音码速达 × order_site 深度分析
> 分析对象:新平台「抖音码速达 - 开放平台」(下文简称 **码速达**),协议数据见同目录
> [README.md](README.md) / [02-接口调用规范与签名.md](02-接口调用规范与签名.md) / [03-消息推送规范与签名.md](03-消息推送规范与签名.md)。
> 分析主体:本仓库 `order_site`(快手自动发货与履约管理系统)。
> 写作日期:2026-10-08 | 分支:`dev` | 结论均标注「事实 / 推断」,「事实」都带代码位置。
---
## 0. 一句话结论
**码速达不是「又发一个链接的平台」,而是「抖店订单的进单口 + 履约结果的回执口 + 卡券的券码源」。**
它跟本系统现有能力是**互补**关系:本系统已有的「进单 → 建单 → 商品匹配 → 履约路由 → 履约执行 → 结果落库」主干**可以整体复用**(复用度约 70%),
但**协议层(两套 MD5 签名)、状态映射层(抖店 8 个状态 → 内部 4 个状态)、回执层(`topup.result` 出网)、卡券层(`coupons.*`)必须新写**。
仓库现状(事实):`apps/backend/src`、`apps/frontend/src` 中 **没有任何** 抖店 / 码速达 相关业务代码
(`grep -riE "码速达|doudian|抖店|jinritemai"` 仅命中本次新增的 `docs/抖音-码速达/`),属于全新接入,不存在「改造旧实现」的迁移风险。
初步规模判断(推断):协议层 + 进单 + 回执打通 **约 6~10 人日**;卡券全链路 + 售后 + 后台面板 **再加 8~12 人日**;
必须在联调前确认的**外部问题 6 条 + 业务口径 3 条**(见 §9),其中「抖店商户号 uid / 用户秘钥 secret 由谁分配」「我们扮演商家还是服务商」是**最高优先级**。
---
## 1. 本项目现状(可复用的主干)
### 1.1 主链路(事实)
```text
外部订单
→ 归一化成 SourceOrderEvent(services/order/order-service.ts:41-57,内存 DTO,无独立表)
→ upsertOrderFromSource()(order-service.ts:98)
├─ advisory lock:hashtextextended(provider|platform|platformOrderId)(order-service.ts:124-127)
├─ orders(唯一键 provider+platform+shop_id+platform_order_id)/ order_items
└─ mergeSourceOrderState()(order-service.ts:445,状态只升不降,见 :432-444 优先级表)
→ 商品/履约解析 resolveOrderItemForFulfillment(fulfillment/product-resolution-service.ts:165)
→ 履约任务 syncDeliveryTasksForOrder(order/delivery-task-service.ts:48)
→ 内部工单 syncWorkerOrdersForSourceOrder(worker-platform/sync-worker-orders-service.ts:37,工单号 WO+…随机)
→ 执行器 preparePaidFulfillmentTask(fulfillment/executors/registry.ts:26)
→ 结果落库 + 事件留痕(task_events / work_order_events)
```
入口挂载点:`apps/backend/src/app.ts:102-111`(`/api/v1/open/<平台>`、`/api/v1/claim`、`/api/v1/admin`);
`rawBody` 已在 `app.ts:37-45` 统一采集,**消息推送验签可以直接复用**。
### 1.2 现有「来源平台」对比(事实 + 推断)
| 平台 | provider / platform | 接入方式 | 入口 | 我方面向外部的能力 |
| --- | --- | --- | --- | --- |
| 91 卡券 | `91kaquan` / `kuaishou` | **推送**(对方调我方 HTTP) | `routes/open-91.ts:27` `POST /api/v1/open/91/orders/create` | 返回 `orderStatus 10/20/30` + AES 加密 cards + `claimUrl` |
| 拼多多 | `pdd` | **轮询**(我方定时拉单) | `services/scheduler/pdd-order-sync-job.ts:11` → `pdd-sync-service.ts` | 无对外接口;本地 `pdd_raw_orders` 暂存后转订单 |
| 快手行业电子凭证 | 复用 `91kaquan`/`kuaishou` | 推送(补全/兜底源) | `routes/kuaishou-industry.ts:24/62/100/138` | 出网调快手 OpenAPI 回执(发码/核销/销毁/冲正) |
| affiliate_dash | — | **不是来源,是履约通道** | `routes/affiliate-dash.ts:16`(只同步任务状态) | 无 |
> 关键事实:**目前只有 91 卡券一条「推送式进单 → 建单 → 自动履约」的完整通路**。
> 码速达与它形态最像,是最值得照抄的模板;差别在于码速达**同时要求我们出网回执结果**(topup.result),这点更像快手行业的回执 worker。
### 1.3 扩展点地图(事实)
| 层 | 扩展点 | 位置 | 新增平台要做什么 |
| --- | --- | --- | --- |
| 路由 | 平台入口挂载 | `app.ts:102-111` | 加一行 `app.use('/api/v1/open/douyin-mashida', …)` |
| 进单 | `upsertOrderFromSource` | `order/order-service.ts:98` | 无需改(复用);**要改的是下面「商品解析」** |
| 商品/履约解析 | `resolveConfiguredItemCandidate` | `fulfillment/product-resolution-service.ts:165` | **必须加分支**:目前只有 `isOpen91KuaishouOrder`(:175,判定 :346)一个入口,其他 provider 一律 `isConfigured=false`(:214-225)→ 不 auto 履约 |
| 执行器契约 | `FulfillmentExecutor` | `fulfillment/executors/types.ts:53-57` | 实现 `preparePaidTask` / `resolveDeliveryLink` 并注册进 `registry.ts:15-20` |
| 执行器白名单 | `ROUTABLE_EXECUTOR_KEYS` | `fulfillment/routing-config-service.ts:64-69` | **必须加**,否则 `normalizeExecutorKey`(:470)会把新 key 清空,后台路由规则选不中 |
| 履约档案 | `CORE_PROFILES` | `bootstrap/fulfillment-bootstrap-service.ts:22-39` | 加一个 profile(key/executor_key/requires_claim) |
| 入站幂等 | `insertWebhookEventOnce` | `repositories/webhook-event-repo.ts:9-43`,表 `webhook_events(provider,event_id)` 唯一(迁移 `013_*.sql:12`);**全仓只有 affiliate_dash 一个调用点**(`notify-service.ts:70`) | 直接用,但**码速达推送没有 `event_id` 字段**,需自行派生(见 §3.4) |
| 可观测 | `logIntegration` / `logExternalHttpPacket` / `task_events` / `admin_notifications`(dedupe_key) / `publishRealtimeEvent`(SSE) | `utils/logger.ts:96/122`、`repositories/task-event-repo.ts:16`、`repositories/admin-notification-repo.ts:25`、`services/realtime/realtime-event-service.ts:92` | 消息流水 + 回执失败 + 仲裁告警都要往这里写 |
| 数据保留 | `data-retention-job.ts:21/39-48` | 定期清理 `webhook_events`/`task_events`/原始报文 | 新平台的报文留痕自动纳入保留策略,无需额外处理 |
| 配置 | `app_config_entries(config_key, config_json JSONB)` | 表 `001_init.sql:45-62`;`config/app-config-keys.ts:1-15`、`services/config/app-config-store.ts:20/43`、`repositories/app-config-repo.ts:20/32` | 加配置键 + `platforms` 块;**凭据脱敏照抄快手**(见 G8) |
| 环境变量 | `config/env-overrides.ts:61-101` | DB 配置 + env 覆盖双通道 | 加 `DOUYIN_MASHIDA_*` |
| 审计 | `admin_audit_logs`(`001_init.sql:23-33`)+ `createJsonHandler({ audit })` | `routes/admin/session.ts:30/138` | action `platform_douyin_mashida_*`、targetType `platform_config` |
| 定时任务 | `scheduler/scheduler-service.ts:204`(job type 分发) | 现有:工单超时、押金解冻、PDD 同步、数据保留 | 兜底对账/补偿任务挂这里 |
| 后台配置 | `routes/admin/platform-config.ts:13-19` + 前端 `pages/admin/platform/` | RBAC `requireAdminRoles` | 加平台配置子路由 + 前端面板 |
| 数据库 | `db/migrations/0XX_*.sql` | 当前最新 `093_clear_internal_consignment_last_worker.sql` | 新表从 `094` 起编号 |
---
## 2. 码速达在业务链路里的定位(推断 + 文档依据)
码速达的每条接口文档都带 `dop_path` / `dop_url` 指向 `op.jinritemai.com`(抖店开放平台),说明它是一层**代理/中转网关**:
它把抖店开放平台的「订单消息推送 + 商家 API」封装成一套统一签名(用户秘钥 + 商户号 uid + MD5)供我们调用。
因此 —— **我们在这条链路里的角色是「抖店商家 / 履约方」**,而不是 91 卡券场景里的「发链接方」。
```text
抖店买家下单付款
→ 码速达推送 trade_TradePaid(或 trade_TradeCreate)
→ order_site 建单 / 匹配商品规则 / 建履约任务(打手或自动通道)
→ 履约完成
→ order_site 回执:topup.result(充值类)或 coupons.syncV2 + coupons.verifyV3(卡券类)
→ 平台侧完成发货/发码
售后:refund_* 消息 → order_site 处理 → afterSale.operate 回执审核结果
```
对照现有链路,**「买家自己打开领取页 claimUrl」这一步在码速达场景里是可选的**:
充值类订单的履约对象是买家在 `buyer_words` / 订单详情里给的游戏账号(现有 PDD 映射已有同样注释:`pdd-order-mapper.ts:216`「买家留言通常为充值游戏账号」),
链路是「我们替买家充值 → 回报平台」,买家无需打开我们的页面。**是否仍然生成 claimUrl 作为兜底/留痕,需要业务确认(见 §9 Q3)。**
---
## 3. 能力映射
### 3.1 14 条消息 → 本系统动作
| 消息 (topic) | 分级 | 本系统应做什么(建议) | 现有可复用 |
| --- | --- | --- | --- |
| `trade_TradePaid` 买家已付款 | 基础 | **主进单事件**:`order.orderDetail` 拉详情 → 建单(`paid/paid`)→ 建履约任务 | 91 的 `POST /orders/create` 全流程 |
| `trade_TradeCreate` 订单创建 | 基础 | 只落「待付款」影子单或忽略(推荐**先落单不派单**) | `upsertOrderFromSource`(`payStatus=unpaid`) |
| `trade_TradeSellerShip` 卖家发货 | 基础 | 发货后**停止/关闭**履约任务(避免重复发货) | `syncAffiliateDashTaskStatus` 式状态合并 |
| `trade_TradePartlySellerShip` 卖家部分发货 | 基础 | 同上,按子订单粒度关闭 | 同上 |
| `trade_TradeMemoModify` 卖家备注变更 | 基础 | 同步备注(→ `order.addOrderRemark` 反向/本地留痕) | — |
| `trade_TradeSuccess` 订单完成 | 基础 | 订单置 `completed`,结束超时计时 | `mergeSourceOrderState` |
| `trade_TradeCanceled` 订单取消 | 基础 | 订单置 `closed`,**终止未完成履约任务** | `mergeSourceOrderState`(优先级表) |
| `refund_RefundCreated` 买家发起售后 | 基础 | 建售后跟踪(**内部 `worker_after_sales_cases` 是打手履约售后,不是这套**) | 需新写电商售后模块(或先只落事件) |
| `refund_RefundAgreed` 同意退款 | 基础 | 同上 + 提示客服 | — |
| `refund_RefundRefused` 拒绝退款 | 高级 | 同上 | — |
| `refund_RefundSuccess` 退款成功 | 高级 | 订单置 `refunded`,**回收/中止履约** | `mergeSourceOrderState`(refunded 优先级最高) |
| `refund_RefundClosed` 售后关闭 | 高级 | 售后跟踪关闭 | — |
| `refund_ArbitrateApplied` 发起仲裁 | 高级 | 高危告警(客服必须介入) | `notifyInternalSafely`(`services/notification/domain-notifications.ts:58`) |
| `refund_ArbitrateCancelled` 取消仲裁 | 高级 | 关闭告警 | 同上 |
**覆盖度判断**:14 条里 **7 条(订单生命周期)** 可完全复用现有订单状态机;**5 条(售后/仲裁)** 本系统没有对应模型,需要新增或先 degrade 成「事件留痕 + 后台通知」。
### 3.2 16 个接口 → 本系统能力
| 接口 (method) | 方向 | 分级 | 本系统用途 | 现状 |
| --- | --- | --- | --- | --- |
| `topup.result` 充值结果提交 | 我方→平台 | 基础 | **履约完成回执**(充值类核心出口) | ❌ 全新;需出网 + 重试 |
| `order.orderDetail` 订单详情 | 我方→平台 | 高级 | 收到消息后补全订单(商品/金额/买家账号) | ❌ 全新(可对标 `affiliate-dash/http-client.ts`) |
| `order.searchList` 订单列表 | 我方→平台 | 未标注 | 定时兜底对账 / 补漏单(近 90 天) | ❌ 全新 + 挂调度 |
| `order.addOrderRemark` 卖家备注 | 我方→平台 | 基础 | 把内部处理进度写回抖店备注 | ❌ 全新 |
| `coupons.syncV2` 卡券同步 | 我方→平台 | 基础 | 生成券码后同步给平台(`order_id` + `cert_list[{cert_no,cert_link,grant_time}]`) | ⚠️ 券码生成可复用 `KSV`+CrockfordBase32(`kuaishou-industry-voucher-repo.ts:49`) |
| `coupons.verifyV3` 卡券核销 | 我方→平台 | 高级 | 上报核销(`cert_no`) | ⚠️ 状态机可复用 `UNUSED/CONSUMED/DESTROYED`(`voucher-service.ts:500`) |
| `coupons.list` 券码查询 | 我方→平台 | 基础 | 核销前查平台侧状态,防重复核销 | ❌ 全新 |
| `coupons.cancelVerify` 卡券取消核销 | 我方→平台 | 基础 | 误核销冲正 | ❌ 全新 |
| `coupons.CertSyncFailedCancelOrder` 商家主动取消订单 | 我方→平台 | 高级 | 券同步失败时取消抖店订单 | ❌ 全新 |
| `afterSale.operate` 售后审核 | 我方→平台 | 高级 | 回执同意/拒绝售后 | ❌ 全新 |
| `afterSale.rejectReasonCodeList` 拒绝原因列表 | 我方→平台 | 免费 | 拒绝售后的原因下拉数据 | ❌ 全新(可缓存) |
| `message.config` 消息设置 | 我方→平台 | 免费 | 配置推送地址与订阅主题 | ❌ 全新(上线时调一次) |
| `product.detail` / `product.list` / `sku.list` | 我方→平台 | 基础/高级/高级 | 拉商品与 SKU,用于建立码速达商品 ↔ 内部接单模板的映射 | ⚠️ 可对标 `affiliate-dash/product-service.ts`、`sku-mapping.ts` |
| `order.message` 订单消息测试 | 我方→平台 | 高级 | 联调期模拟消息(**仅支持特定商户**) | ❌ 全新(联调工具) |
### 3.3 字段级映射(进单:抖店 → `SourceOrderEvent`)
| `SourceOrderEvent` | 来源 | 说明 |
| --- | --- | --- |
| `provider` | 常量 `'douyin_mashida'` | 建议值,避免与 `91kaquan`/`pdd` 混淆 |
| `platform` | 常量 `'doudian'` | 抖店 |
| `shopId` / `shopName` | `shop_id` / `order.orderDetail.shop_name` | 消息里只有 `shop_id`,店名需 `orderDetail` 补 |
| `platformOrderId` | `p_id`(父订单) | ⚠️ 卡片字段叫 `p_id`;`s_ids` 是子订单数组,**必须决定按父单还是子单建单**(见 §5 D2) |
| `orderStatus` / `payStatus` | 映射见下 | |
| `buyerId` | `doudian_open_id` | 加密串,注意脱敏与「不暴露给打手」的既有约定 |
| `buyerName` / `receiverContact` | `buyer_words`(充值账号常在此)/ `post_tel`+`post_receiver` | **充值账号的取值位置需与业务确认**(`buyer_words` 还是商品字段) |
| `totalAmount` | `pay_amount`(分) | 与内部金额单位一致(分) |
| `paidAt` | `pay_time`(秒) | 统一转 ISO |
| `items[]` | `order.orderDetail.sku_order_list[]`(消息本身**不含商品明细**) | `product_name` / `sku_id` / `out_sku_id` / `code`(商家编码)/ `item_num` |
| `skuCode` | `code`(商家后台商品编码)优先,其次 `out_sku_id` | 用于匹配 `work_product_rules` |
| `rawPayload` | 原始 body / 接口响应 | 落 `orders.raw_payload_json` |
**重要事实**:`trade_TradePaid` 消息本身**不含商品与买家账号**(只有 `pay_amount/s_ids/order_status/order_type/pay_time/p_id/shop_id/pay_type/biz`)。
→ **必须再调 `order.orderDetail` 补全**,否则无法建履约任务。这是与 91 卡券(报文里直接带商品号)最大的工程差异。
### 3.4 幂等键怎么定(码速达没有 `event_id`)
现有幂等闸门 `insertWebhookEventOnce({provider, eventId, …})` 要求一个稳定 `event_id`(表唯一键 `(provider,event_id)`),
但码速达推送只有 `topic` + `pub_time` + `sign`,**没有消息 ID**。可选方案:
| 方案 | `event_id` | 评价 |
| --- | --- | --- |
| A | `sign` | **不可用**:`sign` 由 secret + 参数派生,secret 轮换后同一消息的 sign 变化,且理论上不同消息 sign 必不同但不可读 |
| B | `topic:pub_time` | 简单;同一秒同一 topic 的两条不同消息会**误判重复**(低频场景可接受) |
| C | `topic:pub_time:p_id:s_id` | 推荐;订单维度天然唯一,且与业务键对齐 |
| D | `sha256(原始 body)` | 最严格(内容级去重),但要保证 body 字节稳定(不能重新序列化) |
**建议 C 为主、D 为兜底**;`event_type` 写 topic。注意:秒级 `pub_time` + topic 的误判风险在「同一订单同一秒两次推送」时真实存在,
所以**不要只用 B**。文档另需标注:抖音协议**没有 nonce**(对比 PDD 插件有 nonce 防重放),
防重放只能靠 `pub_time` 时间窗(参考 `open-91/payload.ts:67 assertOpen91Timestamp` 的写法)+ 幂等表。
### 3.5 状态映射建议(事实:内部只有 4 态)
内部优先级表(`order-service.ts:432-444`):`created(0) < paid(1) < closed(2) < refunded(3)`;`unpaid(0) < paid(1) < refunded(2)`。
| 抖店 `order_status` | 含义 | 建议内部 `orderStatus/payStatus` |
| --- | --- | --- |
| `1` | 待确认/待支付 | `created` / `unpaid` |
| `105` | 已支付 | `paid` / `paid` |
| `2` | 备货中 | `paid` / `paid` |
| `101` | 部分发货 | `paid` / `paid`(+ 关闭对应子单履约) |
| `3` | 已发货(全部发货) | `paid` / `paid`(+ 关闭履约任务) |
| `4` | 已取消 | `closed` / `unpaid` |
| `5` | 已完成 | `completed` / `paid` ⚠️ **`completed`/`shipped` 不在优先级表内,按 -1 处理**(与 PDD 同样的问题,见 §5 D3) |
| `after_sale_status` | 13/28/51/53 等 | 建议 `refunded` / `refunded`(仅在退款成功/售后成立时) |
---
## 4. 差距分析(Gap)
> 规模:S ≈ 0.5~1 人日,M ≈ 2~4 人日,L ≥ 5 人日(推断,按现有同类实现复杂度类比)。
| # | 缺口 | 现状(事实) | 影响 | 落点(建议文件) | 规模 |
| --- | --- | --- | --- | --- | --- |
| **G1** | 出网客户端 + 签名(两套 MD5) | 现有三套签名互不兼容:91 = `MD5(secret + k=v&… + secret)` **大写**(`open-91/crypto.ts:8-30`);affiliate_dash = HMAC-SHA256(`sign.ts:22`);快手 = MD5/HMAC(`crypto.ts:28`)。码速达 = `MD5(secret + Σ(name+value) + secret)` **小写**(无 `=`/`&` 分隔) | 无此层则一个接口都调不通 | `services/platforms/douyin-mashida/sign.ts`、`http.ts`、`openapi-client.ts` | M |
| **G2** | 消息接收端点 + 验签 + 幂等 | 现有最完整模板:`routes/affiliate-dash.ts:16` + `notify-service.ts:21-120`(验签 → 找本地任务 → `webhook_events` 去重 → 任务事件 → 状态合并);`rawBody` 已在 `app.ts:37-45` 采集 | 不能收单 | `routes/douyin-mashida.ts`、`services/platforms/douyin-mashida/notify-service.ts`;复用 `webhook-event-repo.ts:9` | M |
| **G3** | 进单商品解析分支 | **硬编码**:`product-resolution-service.ts:175/346` 只认 `91kaquan`+`kuaishou`,其他来源 `isConfigured=false`(:214-225)→ 不自动履约 | 码速达订单会「建单但永远不派单」 | 改 `product-resolution-service.ts`(加 `isDouyinMashidaOrder` 分支 + 候选来源) | S~M |
| **G4** | 状态映射层 | 内部只有 4 个订单态;抖店 8 个状态无映射;且 `completed`/`shipped` 不在优先级表(PDD 已有同样坑) | 状态乱跳/不更新 | `order-service.ts:432-444` 扩表 或 在 mapper 里降级(推荐后者,改动面小) | S |
| **G5** | 履约完成 → 回执(`topup.result`) | 现有回执参考:快手发码回调 worker(`send-code-service.ts:166/400/456`,退避 `<3s,10s,30s,60s,120s,300s>`、≤8 次、15s 扫描);affiliate_dash 回调靠对方重试 | 平台不知道我们充值完了 → 业务不闭环 | `services/platforms/douyin-mashida/topup-service.ts` + 任务完成钩子 + 重试 worker(挂 `startup/bootstrap.ts:47-49`) | M~L |
| **G6** | 卡券能力(syncV2/verifyV3/list/cancelVerify) | 现有券模型:`kuaishou_industry_vouchers`(`001_init.sql:272-297`,`(oid,unit_index)` 唯一)+ 状态机 `UNUSED/CONSUMED/DESTROYED`(`voucher-service.ts:500`),但语义是「快手电子凭证」,字段带 `oid/token/seller_id` | 卡券类订单无法交付 | 新表 `douyin_mashida_certificates`(语义不同,**不建议复用**快手表,见 §5 D4) | L |
| **G7** | 电商售后链路 | 内部售后 `worker_after_sales_cases`(`052_*.sql:3`)是**打手履约售后**(`awaiting_worker_response/under_review/recovery_pending/closed`),与抖店售后无关 | 售后消息只能留痕,无法回执审核 | 先做「事件留痕 + 后台通知」,二期再做 `afterSale.operate` 回执 | M~L |
| **G8** | 配置与后台面板 | 配置体系完备:`app_config_entries(config_key, config_json JSONB)`(`001_init.sql:45-62`)为**唯一配置中心**,`app-config-store.ts:20/43` 读写 + 旧 JSON 文件回退,`APP_CONFIG_KEYS`(`app-config-keys.ts:1-15`)登记键,env 覆盖(`env-overrides.ts:61-101`),后台子路由(`routes/admin/platform-config.ts:13-19`),前端面板走 `/admin/platform-shops` 的 `?tab=`(`AdminPlatformShopsPage.tsx:72/85/164-203`,**新增 Tab 不用改路由与菜单**)。**凭据脱敏只有快手一条链路实现了**:`services/admin/platform-config/kuaishou-industry-service.ts:17 SECRET_FIELDS` + `:216 isMaskedSecretValue` + `:233 resolveSecretPatch`,配 `utils/masking.ts:10 maskSecret`(首6尾6);affiliate-dash 与 PDD 都是**明文回传前端**(`affiliate-dash-service.ts:17`、`routes/admin/platform-config/pdd.ts:92`) | 无配置 = 无法联调;照抄明文模式会把 secret 暴露给前端与审计日志 | `services/platforms/douyin-mashida/source-config-service.ts`、`routes/admin/platform-config/douyin-mashida.ts`、`services/admin/platform-config/douyin-mashida-service.ts`、前端 `services/admin/platform-config/douyin-mashida.ts` + `types/admin/platform-config/douyin-mashida.ts` + `AdminPlatformShopsPage.tsx` 加 Tab | M |
| **G9** | 对账/补偿(订单可能丢消息) | 现有兜底:PDD 轮询(`scheduler/pdd-order-sync-job.ts:11`,默认 20s);快手有券—工单绑定补全(`sync-work-orders-from-send-code.ts`) | 消息丢了就漏单 | `services/scheduler/douyin-mashida-order-sync-job.ts`(调 `order.searchList` 按时间窗对账) | M |
| **G10** | 成本与可观测性 | 平台按功能分级扣**金豆**:免费/基础 1/高级 2/增值 4(0.001~0.004 元/次)。现有设施:`logIntegration` / `logExternalHttpPacket`(`utils/logger.ts:96/122`,自带密钥脱敏正则 `:65-70`)、`task_events`(`task-event-repo.ts:16`)、后台待办 `admin_notifications`(`027_*.sql:2`,`dedupe_key` 唯一)+ SSE `publishRealtimeEvent`(`realtime-event-service.ts:92`)、`data-retention-job.ts:39-48` 定期清理报文 | 高频轮询烧钱;出问题无法定位;异常没人跟 | 对账窗口设宽 + 增量拉;低频接口本地缓存(`afterSale.rejectReasonCodeList`/`product.*`);**异常分流**:未匹配订单/密钥失效/回执连续失败 → `admin_notifications` + `publishRealtimeEvent`;出网日志复用 `logExternalHttpPacket` | S |
| **G11** | 环境变量登记(容易被忽略的硬约束) | `config/env-documentation.test.ts:63` 断言 `.env.server.example` / `.env.mac-docker.example` / compose 中出现的变量名**必须**在白名单 `KNOWN_ENV_NAMES`(`:57`)内 | 加了 env 不登记 → **测试直接红**,CI/hook 拦截推送 | 若走 env:`types/runtime-config.ts` + `config/defaults.ts:74` + `config/env-overrides.ts:24` + 两个 `.env*.example` + 白名单。**若走后台可改型(推荐)则完全绕开这个约束** | S |
---
## 5. 必须拍板的设计决策
| # | 问题 | 选项 | 建议 |
| --- | --- | --- | --- |
| **D1** | 履约完成以什么为准? | ①打手提交资料即回执 ②验收通过才回执 ③发货动作完成即回执 | **②验收通过**(有留痕、可追责);充值类需要「充值平台返回值」作为凭证,建议引入「履约凭证」字段 |
| **D2** | 主键粒度:父订单 `p_id` 还是子订单 `s_ids`? | ①父单建 `orders`,子单建 `order_items` ②每个子单独立建单 | **①**,与现有 `orders`/`order_items` 结构一致;但 `topup.result` / `coupons.syncV2` 需要**子订单号**,回执时按 item → `s_id` 映射 |
| **D3** | 抖店 `completed`/`shipped` 状态怎么落? | ①扩 `ORDER_STATUS_PRIORITY` ②mapper 降级为 `paid` | **②先降级**(S 改动),长期 ① 统一修(PDD 也有同样坑) |
| **D4** | 卡券:复用快手券表还是新建表? | ①复用 `kuaishou_industry_vouchers` ②新建 `douyin_mashida_certificates` | **②新建**:语义(抖店券 vs 快手电子凭证)、字段(无 `oid/token`)、唯一键(`order_id+cert_no`)都不同;复用会污染既有状态机与重试逻辑 |
| **D5** | 消息订阅范围 | 14 条全订 vs 只订关键 7 条 | **先订 5 条**:`trade_TradePaid`、`trade_TradeCanceled`、`refund_RefundCreated`、`refund_RefundSuccess`、`trade_TradeSellerShip`;其余留观(**每条消息按分级扣金豆**) |
| **D6** | 买家隐私边界 | 抖店会给我们 `post_tel`/`post_receiver`/`doudian_open_id` | **必须遵守既有约定**:`doudian_open_id` 只留内部,不写进工单 `material`、不下发打手;充值账号单独用专用字段承载(现有项目已有「退库后不再带出上一单买家资料」等约束,见 commit `250c456a`/`eae78aae`) |
| **D7** | 密钥存储 | 现有 affiliate_dash 的 `appSecret`/`callbackSecret` 是**明文存在 `app_config_entries.config_json`**(`source-config-service.ts:16-24`,事实);**只有快手链路做了脱敏 + 只写不读**(`kuaishou-industry-service.ts:17/216/233` + `utils/masking.ts:10`) | **照抄快手模式**(出参 `secret:''` + `secretMasked` + `hasSecret`,掩码回写不覆盖真值),不要照抄 affiliate-dash 的明文回传;全库加密改造另立优化项 |
| **D8** | 命名约定(避免各写各的) | `provider` / config key / 目录名 / 路由前缀 / 前端 Tab key 需统一 | 建议:`provider = 'douyin_mashida'`、`APP_CONFIG_KEYS.douyinMashida = 'douyin_mashida'`、后端目录 `services/platforms/douyin-mashida/`、路由 `/api/v1/open/douyin-mashida`、前端 Tab key `douyinMashida`(**统一用 `douyin_mashida`,不要混用 `codespeed`/`masuda`/`doudian`**) |
| **D9** | 入站响应格式 | 现有三种并存:affiliate-dash `{code:0}`、91 `{message,code,data}`、快手行业 `result` 风格;内部管理端是 `buildSuccessPayload`(`utils/http.ts:21`,`{code,msg,time,data}`) | **按码速达规范返回**(文档只要求「返回成功即可」,实际以联调为准);**绝不要**套用内部 `buildSuccessPayload`;建议统一返回 HTTP 200 + `{code:0}` 之类的简单体,且「未能处理」时按 §8.1 第 8 条(幂等优先于必成功:未匹配返 4xx 让上游重推)决定状态码 |
---
## 6. 分期实施建议
### Phase 0 · 联调准备(0.5~1 人日)
- 向平台方确认 §9 的 5 个阻塞项;拿到 `uid` + `secret` + 测试店铺。
- 本地起一个 mock 推送服务,用 [03-消息推送规范与签名.md](03-消息推送规范与签名.md) 的签名算法造请求,验证我们的验签实现。
- 交付物:`docs/抖音-码速达/05-接入开发设计.md`(对齐 `docs/91卡券/6.开发设计.md` 的写法)+ 联调清单。
### Phase 1 · 只读接入(2~3 人日)
- 新增 `routes/douyin-mashida.ts`(`POST /api/v1/open/douyin-mashida/message`,query + body 混合取值可参照 `routes/kuaishou-industry.ts:176 mergeRequestParams`)+ `notify-service.ts`:验签(**用 `req.rawBody` 原始串**,`app.ts:41`)+ `insertWebhookEventOnce` 去重(§3.4 的 event_id 方案)+ 原始报文落库。
- 新增出网层 `services/platforms/douyin-mashida/{sign,http,openapi-client}.ts`(`AbortSignal.timeout` + `logExternalHttpPacket` + `createHttpError`,**不要**改 `affiliate-dash/http-client.ts`),实现 `order.orderDetail`。
- `trade_TradePaid` → 拉详情 → `upsertOrderFromSource`(**先不派单**,观察数据质量)。
- 后台配置:`app-config-keys.ts` 加 key → `routes/admin/platform-config/douyin-mashida.ts`(挂在 `routes/admin/platform-config.ts:14` 的 admin 守卫**之后**)+ 出参脱敏(照抄 `kuaishou-industry-service.ts`)+ `audit`(action `platform_douyin_mashida_config_updated`、targetType `platform_config`)+ 前端 `AdminPlatformShopsPage.tsx` 加 Tab(`:72 PlatformTab`、`:85 platformTabs`、`:164-203 Tabs items`、`:1541 normalizePlatformTab`)。
- 验收:真实/模拟消息能在后台看到完整订单,字段与抖店后台一致;重复推送只处理一次。
### Phase 2 · 履约闭环(3~5 人日)
- 打通 G3(商品解析分支)+ G4(状态映射)+ 执行器注册,让订单能自动派单/人工兜底。
- 建「码速达商品 ↔ 接单模板」的映射(可复用 `work_product_rules` 体系;`product.list`/`sku.list` 拉取、`code` 作 `skuCode`)。
- 实现 `topup.result` 回执 + 重试 worker(照抄快手发码回调 worker 的退避策略与扫描机制)。
- 验收:一笔真实小额充值订单全链路闭环(下单 → 派单 → 充值 → 回执 → 平台侧显示成功)。
### Phase 3 · 卡券能力(3~4 人日)
- 新表 + `coupons.syncV2`(券码生成与同步)、`coupons.verifyV3`(核销上报)、`coupons.list`(查)、`coupons.cancelVerify`(冲正)。
- 与 91 卡券的差异对照测试:同一批券码在两条链路的幂等与状态一致性。
### Phase 4 · 售后与运维(3~5 人日)
- `order.searchList` 兜底对账任务(建议 5~15 分钟窗口、按 `update_time` 增量):新增 job 要改**三处** —— `services/scheduler/config-service.ts`(normalize + 默认任务 + job type 分派 `:103-121`)、`services/scheduler/scheduler-service.ts:194 dispatchJob`(新分支)、新建 `services/scheduler/douyin-mashida-order-sync-job.ts`(参照 `pdd-order-sync-job.ts:11`:分页上限、熔断跳过、店铺间隔 500ms、日志降噪)。手动触发可直接复用 `POST /api/v1/admin/platform-config/scheduled-jobs/:id/run`(`routes/admin/platform-config/notifications.ts:118`)。
- 售后:`afterSale.operate` 回执 + `rejectReasonCodeList` 本地缓存 + 仲裁(`refund_ArbitrateApplied`)走 `admin_notifications` + `publishRealtimeEvent` 告警。
- 后台:消息流水、回执失败重发(参照快手「手动重发」`resendKuaishouIndustryVoucherSendCallback:186` 与 PDD「暂存单重放」`/pdd/raw-orders/replay` 两种范式)、金豆消耗统计。
---
## 7. 测试方案
| 层 | 用例 |
| --- | --- |
| 签名单测 | 用文档里的占位秘钥/时间戳算出 `e4a08602d21966e99d2fa0921d7456b0`(订单详情)与 `1f4e1e6fc7e2ebad3368abfc2e07f2d1`(TradePaid 推送),断言字节级一致 — 这两条 golden vector 已写进 [接口API/01-订单详情.md](接口API/01-订单详情.md)、[消息推送/01-买家已付款.md](消息推送/01-买家已付款.md) |
| 消息入口 | 重复推送同一 `event_id`(§3.4 方案)→ 只处理一次;坏签名 → 4xx 且不落库;`data` 非法 JSON → 不 500;`pub_time` 超出时间窗 → 拒绝 |
| 状态映射 | 表驱动测 8 个 `order_status` × 是否售后 → 内部态;验证 `mergeSourceOrderState` 不回退 |
| 回执 | `topup.result` 失败重试退避与上限;重复回执幂等;平台返回 `code≠10000` 的处理 |
| 商品映射 | `code` / `out_sku_id` / `product_name` 三种键的匹配优先级与歧义处理 |
| 配置与合规 | 后台读配置不返回 secret(`hasSecret` + `Masked`);掩码回写不覆盖真值(照抄 `kuaishou-industry-service.test.ts:7/17`);审计 payload 无密钥;买家隐私不出现在 `work_orders.material_json` |
| 端到端 | 内网 mock 平台 + 沙箱店铺;覆盖「付款→履约→回执」与「付款→取消→终止履约」两条主线 |
---
## 8. 风险清单
| 级别 | 风险 | 说明 | 缓解 |
| --- | --- | --- | --- |
| 🔴 高 | **业务角色未确认** | 我们到底是「抖店商家」还是「服务商代运营」?决定 uid/secret 归谁、订单归属谁 | Phase 0 必答(§9 Q1) |
| 🔴 高 | **消息不含商品明细** | 只靠 `trade_TradePaid` 无法建单,必须回调 `order.orderDetail`(高级功能,扣 2 金豆/次) | 进单即调一次详情并缓存;对账不重复调 |
| 🟠 中 | 出网回执丢失 | 平台不重试我们的回执(文档未说明) | 自建重试 worker + 后台可手动重发(照抄快手券回调 worker) |
| 🟠 中 | 状态语义错配 | 抖店 8 态 vs 内部 4 态;售后台与打手售后混淆 | mapper 层统一降级 + 表驱动单测;**绝不**把抖店售后写进 `worker_after_sales_cases` |
| 🟠 中 | 金豆成本 | 轮询 `order.searchList` + 每条消息都扣费 | 只订关键 5 条消息;对账窗口尽量宽、按 `update_time` 增量 |
| 🟡 低 | 密钥明文 | 沿用了现有 affiliates 的明文配置模式 | 中期加密/脱敏改造(独立优化项) |
| 🟡 低 | 命名冲突 | `provider` 若取 `douyin` 之类易与未来抖音原生开放平台混淆 | 见 §5 D8(统一 `douyin_mashida`) |
| 🟡 低 | 环境变量白名单测试 | `env-documentation.test.ts:63` + `KNOWN_ENV_NAMES:57`:**新增 env 不登记会让测试失败**,pre-push hook 直接拦 | 走「后台可改型」配置即可绕开;若必须走 env,四处一起改(见 G11) |
| 🟡 低 | 文档自身瑕疵 | `coupons.verifyV3` 的 `dop_path` 写成 `/coupons/verifyV2`(抓取原文如此);个别数值返回字段无示例值 | 已在本目录文档中标注,联调时以平台方为准 |
### 8.1 必须遵守的既有约定(对接第三方时的「红线」)
这些是仓库里已经成型、且在代码里有对应实现的约束,新平台接入**必须**沿用:
| # | 约定 | 代码位置 |
| --- | --- | --- |
| 1 | 密钥只读不回显:出参 `secret: ''` + `secretMasked` + `hasSecret`,掩码回写**不得覆盖**真实值 | `kuaishou-industry-service.ts:113-136/216/233`、`utils/masking.ts:10` |
| 2 | 审计只记布尔摘要(`hasSecret`/`enabled`),**不落密钥** | `routes/admin/platform-config/kuaishou-industry.ts:46-53` |
| 3 | 日志强制脱敏(sign/token/cookie/authorization/card…) | `utils/logger.ts:65-70`、`:362`、`:407` |
| 4 | **买家隐私不进打手工单素材**:`work_orders.material_json.source` 里只有 orderId/sku/金额/店铺,没有 buyer/receiver | `sync-worker-orders-service.ts:206-220`(对照 `orders` 表可存 `buyer_name`/`receiver_contact`:`sync-work-orders-from-send-code.ts:285-286`) |
| 5 | 买家信息仅在「匹配成立」后对卖方/客服可见 | `consignment-worker-service.ts:469/509`、`consignment-admin-service.ts:218` |
| 6 | 卡密/核销码在后台出参掩码 | `kuaishou-industry-admin-service.ts:495-496/518`、`write/task-actions.ts:395 deliveredCredentialMasked` |
| 7 | 入站接口一律限流 | `routes/affiliate-dash.ts:10`、`routes/open-91.ts:10`、`routes/kuaishou-industry.ts:15` 等 |
| 8 | 幂等优先于「必成功」:重复回调 200 跳过、未匹配 404 让上游重推 | `affiliate-dash/notify-service.ts:70-88`、`:48-57` |
| 9 | 报文与事件有保留期,到期由 `data-retention-job` 清理 | `scheduler/data-retention-job.ts:21/39-48` + `*_RETENTION_DAYS` env |
| 10 | 回调返回体按**对方**规范,不要套内部 `buildSuccessPayload` | `utils/http.ts:21`(内部格式)对比 `routes/affiliate-dash.ts:45`(`{code:0}`) |
> 对码速达的直接含义:`doudian_open_id`、`post_tel`、`post_receiver`、`buyer_words`(常含充值账号)
> 只能进 `orders`/原始报文,**不得**下发到打手工单素材;券码(`cert_no`)在后台展示要掩码。
---
## 9. 待确认清单
**给平台方(码速达)**
1. `uid`(抖店商户号)与用户秘钥如何分配?我们属于**商家**还是**服务商**?一个 uid 能否绑定多店铺?
2. 消息推送**失败重试策略**是什么?是否要求我们在 N 秒内返回 200?超时/失败会重推几次?
3. `order_status=5`(已完成)与售后状态,平台期望我们在什么时机做最终回执?
4. 充值类订单的**买家账号**标准取值位置(`buyer_words`?商品 SKU 字段?还是 `order.orderDetail` 的某字段)?
5. `topup.result` 的 `topup_biz` 取值枚举有哪些(示例只给了 `MOBILE_TOPUP`)?
6. 测试环境/沙箱店铺是否可用?`order.message` 测试接口是否对我们这类商户开放(文档写「仅支持特定商户」)?
**给业务方**
7. 抖店这条线是**现有业务的延伸**(用同一批打手/同一套接单模板)还是**独立品类**?决定商品映射与履约通道。
8. 是否仍要给买家生成 `claimUrl`(去使用链接)?`topup.result` 的 `url` 字段要不要填我们的领取页?
9. 售后(拒绝/同意退款)由**客服在后台点**还是自动按规则回执?
---
## 10. 附录:本次分析的关键代码索引
| 主题 | 文件(相对仓库根) | 关键行 |
| --- | --- | --- |
| 进单归一化 | `apps/backend/src/services/order/order-service.ts` | 41-57(DTO)、98(upsert)、124-127(advisory lock)、432-476(状态合并) |
| 入站 webhook 模板 | `routes/affiliate-dash.ts:16`、`services/platforms/affiliate-dash/notify-service.ts:21-120` | 验签→查任务→去重→事件→同步 |
| 幂等闸门 | `repositories/webhook-event-repo.ts:9-43`(表 `webhook_events`,迁移 `013_*`) | `(provider,event_id)` 唯一 |
| 执行器契约/注册 | `services/fulfillment/executors/types.ts:53-57`、`registry.ts:15-55` | |
| 履约解析硬编码 | `services/fulfillment/product-resolution-service.ts:165/175/214-225/346` | 仅 91 来源 |
| 路由白名单 | `services/fulfillment/routing-config-service.ts:64-69`、`:132`、`:470` | |
| 券码状态机/重试 | `services/platforms/kuaishou-industry/voucher-service.ts:500`、`send-code-service.ts:166/400/456` | 退避 ≤8 次 |
| 出网 HTTP | `services/platforms/affiliate-dash/http-client.ts:14`、`kuaishou-industry/http.ts:4` | 超时/错误码归一 |
| 配置体系 | `services/config/app-config-store.ts:20/43`、`config/app-config-keys.ts:1-15`、`config/env-overrides.ts:61-101`、表 `app_config_entries`(`001_init.sql:45-62`) | DB + env 双通道;**明文 JSONB** |
| 凭据脱敏(唯一正确范本) | `services/admin/platform-config/kuaishou-industry-service.ts:17/113-136/216/233`、`utils/masking.ts:10` | 置空 + Masked + hasSecret,掩码回写不覆盖 |
| 审计 | `routes/admin/session.ts:30/138`、`repositories/admin-audit-log-repo.ts:42`、表 `admin_audit_logs`(`001_init.sql:23-33`) | action `platform_<x>_<y>`、targetType `platform_config` |
| 后台面板(前端) | `pages/admin/platform/AdminPlatformShopsPage.tsx:72/85/164-203/1541`、`services/admin/platform-config/*`、`types/admin/platform-config/*` | `?tab=` 内部 Tab,不用改路由/菜单 |
| 调度 | `services/scheduler/scheduler-service.ts:13/51/194`、`config-service.ts:103-121`、`pdd-order-sync-job.ts:11` | job type 三处登记;手动触发路由 `notifications.ts:118` |
| 可观测性 | `utils/logger.ts:96/122`、`repositories/task-event-repo.ts:16`、`repositories/admin-notification-repo.ts:25`、`services/realtime/realtime-event-service.ts:92` | 日志 / 任务事件 / 后台待办 + SSE |
| 环境变量白名单(硬约束) | `config/env-documentation.test.ts:57/63`、`.env.server.example`、`.env.mac-docker.example` | 新增 env 必须登记,否则测试红 |
| 隐私红线 | `services/worker-platform/sync-worker-orders-service.ts:206-220` | 打手工单素材不含买家信息 |
| 平台接入设计范本 | `docs/91卡券/6.开发设计.md`(来源建模/配置/签名/代码落点/测试范围)、`docs/履约配置/多发货平台与电子凭证整体链路.md` | 本项目的「新平台接入」方法论 |
---
## 11. 下一步(建议按序执行)
1. **确认 §9 的 Q1/Q2/Q4**(阻塞项,拿到答案才能定协议细节与数据模型)。
2. 写 `docs/抖音-码速达/05-接入开发设计.md`(对齐 `docs/91卡券/6.开发设计.md`:来源建模 → 路由 → 配置 → 签名 → 下单 → 回执 → 商品匹配 → 代码落点 → 测试范围)。
3. 从 **G1 + G2**(签名 + 消息入口)开工 —— 这两块不依赖任何业务口径,且是后续一切联调的前提。
@@ -0,0 +1,468 @@
# 抖音码速达 · 最小接入方案分析
> 目标:**用最小代价跑通一条真实闭环**(收单 → 发券 → 用户兑换 → 回执核销),先不做对账轮询、售后、商品映射后台、充值回执。
> 协议依据:[README.md](README.md)、[02-接口调用规范与签名.md](02-接口调用规范与签名.md)、[03-消息推送规范与签名.md](03-消息推送规范与签名.md)、[接口API/](接口API)。
> 本仓库依据:[04-结合本项目的深度分析.md](04-结合本项目的深度分析.md)(下称「深度分析」)。
> 日期:2026-10-08 | 分支:`dev`
---
## 0. 结论速览
**最小闭环成立**,且比标准方案少掉约 2/3 的工作量:不需要 `topup.result`、不需要商品映射后台、不需要对账轮询、不需要售后回执、不需要新建履约执行器(**人工兜底即可**)。
四步闭环:
```text
① 收单 码速达推 trade_TradePaid → 我们调 order.orderDetail 补全商品/买家/金额 → 建单
② 发券 本地生成 DYC 券码 → 调 coupons.syncV2 把(券码 + 固定兑换链接)交给平台
③ 兑换 买家打开【固定链接】→ 输入订单号 → 校验状态 → 兑换(发货/展示卡密)
④ 回执 调 coupons.verifyV3 上报核销 → 调 order.message 把「去使用链接」发给买家
```
**平台方答复(2026-10-08)** —— 四个阻塞项已解开:
| # | 事项 | 结论 |
| --- | --- | --- |
| Q1 | `order.message` 能否使用 | ✅ **可以用**,按文档用即可(`message_type` 具体取值联调时试) |
| Q2 | `coupons.syncV2` 订单号粒度 | ✅ **按单个订单处理**(业务上一般没有子订单)。实现仍以 `order.orderDetail` 的 `sku_order_list[].order_id` 为准,父单号兜底(见 §2.2 提醒) |
| Q3 | 券码与链接格式 | ✅ 券码**无硬性约束**,先照快手(`DYC` + Base32);`cert_link` **新建一个固定链接**(本期只服务抖音,形态保持通用)→ 见 §3.4 |
| Q4 | `coupons.verifyV3` 核销语义 | ⏳ 以文档为准,联调时实测 |
**仍然存在的风险(不阻塞开工,但要按最小防护做)**:
1. 固定链接 + 订单号 = **知道订单号就能兑换** → §3.2 的 6 层防护 + 一次性核销;业务若不接受残余风险,加「手机号后 4 位」。
2. 退款/取消后券码还活着 → **最小集必须带上** `refund_RefundSuccess` → 券码作废(成本极低,见 §6)。
3. `cert_link` 以后可能要服务多平台 → 本期只保证**链接形态通用**(路径式 `/redeem` + 表带 `provider` 列),**不为快手写代码**(见 §3.4)。
其余问题(Q5~Q8、B1~B8)按「**本地边做边测**」处理,不再阻塞开工。
> ⚠️ 术语纠正:平台**只有** `coupons.syncV2`(卡券同步)和 `coupons.verifyV3`(卡券核销),**没有 syncV3**。
> 需求里说的「卡券同步 / 卡券同步v3」应理解为「同步用 V2、核销用 V3」。
---
## 1. 最小闭环时序(每步的接口与关键参数)
```text
抖店买家付款
│
│ ① trade_TradePaid(topic + pub_time + sign 走 URL,data 走 POST body)
▼
码速达 ──────────────────────────► 我们 /api/v1/open/douyin-mashida/message
│ 验签 → 幂等去重 → 解析
│ 只有 p_id / s_ids / pay_amount,没有商品
▼
order.orderDetail(shop_order_id = p_id)
│ 拿到 sku_order_list[](子订单号/商品名/编码/数量)
│ + post_tel / post_receiver / buyer_words
▼
建单(orders + order_items,复用 upsertOrderFromSource)
建履约任务(人工兜底:manual_dispatch)
生成 DYC 券码(表:douyin_mashida_certificates)
│
│ ② coupons.syncV2(order_id = 子订单号,
│ cert_list = [{cert_no: "DYC…",
│ cert_link: 固定兑换链接,
│ grant_time}])
▼
平台把卡券展示给买家
│
│ ③ 买家点卡券 → 打开【固定兑换链接】
│ https://<域名>/redeem
▼
输入订单号 → 查状态 → 兑换(发货/展示卡密)
│
│ ④ coupons.verifyV3(cert_no) ← 上报核销(幂等)
│ order.message(order_id, content=去使用链接, message_type=2)
▼
平台把「去使用链接」推给买家
```
**每一步的对外接口**(全部走同一个网关 `POST /api/openplatform/gateway`,公共参数 `uid/method/timestamp/sign`):
| 步骤 | method | 关键参数 | 备注 |
| --- | --- | --- | --- |
| ① | `order.orderDetail` | `shop_order_id`(父订单号) | 高级功能(2 金豆/次) |
| ② | `coupons.syncV2` | `order_id`(**子订单ID**)、`cert_list`(Json 数组) | `cert_link` ≤ 255 字符 |
| ④ | `coupons.verifyV3` | `cert_no` | 只认券码,**不带订单号** → 券码必须能反查订单 |
| ④ | `order.message` | `order_id`、`content`、`message_type`(1/2/4/5) | **测试接口,需确认可用性** |
| 兜底 | `coupons.cancelVerify` | `cert_no`、`batch_no`(幂等号)、`cancel_verify_count` | 发错/误核销时冲正 |
---
## 2. 四步拆解
### 2.1 收单:消息 + 订单详情
**事实**:`trade_TradePaid` 的 data 只有 `pay_amount / s_ids / order_status / order_type / pay_time / p_id / shop_id / pay_type / biz` —— **没有商品、没有买家账号**。
所以必须再调一次 `order.orderDetail`(深度分析 §2.2 已论证这是与 91 卡券最大的差异)。
最小字段映射:
| 来源 | 字段 | 落到哪 |
| --- | --- | --- |
| 消息 | `p_id` | `orders.platform_order_id`(**按父单建 order**) |
| 消息 | `pay_amount`(分) | `orders.total_amount` |
| 消息 | `pay_time`(秒) | `orders.paid_at` |
| 消息 | `order_status=2` | `orderStatus=paid` / `payStatus=paid`(深度分析 §3.5 映射表) |
| 详情 | `sku_order_list[].order_id` | `order_items` 的**子订单号**(②④都要用,务必存下来) |
| 详情 | `sku_order_list[].product_name` / `code` / `out_sku_id` / `item_num` | `order_items.sku_name` / `sku_code` / 数量 |
| 详情 | `post_tel` / `buyer_words` | **只进 orders / 原始报文**(深度分析 §8.1 红线:不进打手工单素材) |
**关键动作**:把**子订单号 `s_id`** 持久化到 `order_items`(或 `orders.raw_payload_json`,但强烈建议落列/落 snapshot,因为后面 syncV2 与 order.message 都要它)。
> 若一次回调里 `s_ids` 有多个(多商品订单):最小版建议**每个子单一条 order_item**,每个子单**各发一张券**(`cert_list` 支持数组)。
### 2.2 发券:DYC 券码 + `coupons.syncV2`
**券码规则**(照抄现有快手券码生成器:`repositories/kuaishou-industry-voucher-repo.ts:26/27/49-52` 用 10 字节随机 → Crockford Base32,`:546 encodeCrockfordBase32`,前缀 `KSV` 改 `DYC`):
```text
DYC + <Crockford Base32(10 字节随机 → 16 位,字母表去掉易混字符 I/L/O/U)> 例:DYC7K3M9QF2XR5TB
```
- 全局唯一:`cert_no` 建 UNIQUE(现有快手表同样做法:`voucher_code UNIQUE`)。
- 券码本身不带订单信息(`verifyV3` 只回传 `cert_no`,所以必须靠本地表反查)。
**同步参数**:
```json
{
"order_id": "<子订单号 s_id>",
"cert_list": "[{\"cert_no\":\"DYC…\",\"cert_link\":\"https://<域名>/redeem\",\"grant_time\":\"2026-10-08 15:30:00\"}]"
}
```
- `cert_list` 要**以 JSON 字符串**作为单个参数值提交(与文档示例一致)。
- `cert_link` 就是**我们的固定兑换链接**,长度上限 255(固定常量,天然满足)。
- `grant_time` 用发券当时的本地时间(`YYYY-MM-DD HH:MM:SS`)。
**幂等**:以「订单号」为键,一个订单只同步一次券(表上加 `UNIQUE(provider, order_id, cert_no)` 或状态标记 `sync_status`),失败可重试、成功不重发。
> ⚠️ **平台方说"一般没有子订单"≠ 父订单号可以直接当 `order_id` 用**:`syncV2` 文档把 `order_id` 定义为「**子订单ID**」,
> 而 `order.orderDetail` 返回的 `sku_order_list[].order_id` 才是子订单号 —— 抖店即使只有一个商品,这个值也不保证等于 `p_id`。
> **实现要求**:两个号都落库(`platform_order_id` = `p_id`,`source_order_id` = 子订单号),
> 调 `syncV2` / `order.message` 时**子订单号优先、父单号兜底**,联调第一件事就是把两个值打出来对比。
> 🔁 **为将来留一点余地(零成本)**:`cert_link` 用同一个固定链接(§3.4)、券表带 `provider` 列;本期只有抖音,**不写多平台逻辑**。
### 2.3 固定兑换链接 + 订单号闸门 ★ 本次核心变化
**需求**:一个固定链接,用户输入订单号,按订单状态决定下一步。
**与旧 token 链接的对比**:
| 维度 | 旧:`/claim/:token`(现状) | 新:固定链接 + 订单号 |
| --- | --- | --- |
| 链接 | 每单一个(token 24 位随机) | **全平台一个常量** |
| 凭据 | token 即凭证(`claim_tokens.status/expired_at/max_use_count`) | **订单号 + 服务端状态校验** |
| 发链接成本 | 建单时生成 token、写 `claim_tokens`、随 91 查询接口下发 | 建单即可发(甚至可写死在卡券 `cert_link` 里) |
| 领错人风险 | 低(token 不可猜) | **中**(订单号可枚举,见 §3.2) |
| 复用 | `buildClaimUrl` / `resolveClaimTokenExpiration`(`claim-service.ts:11-52`) | 复用 **`/collect` 的「输入订单号 → 查状态 → 下一步」模式** |
**可复用的现成资产(重要)**:仓库里已经有一个**无 token、按订单号查询的公开页**:
| 资产 | 位置 | 说明 |
| --- | --- | --- |
| 公开路由 + 按订单号限流 | `routes/collect.ts:11-27` | `POST /api/v1/collect/lookup`,`key: getBodyFieldRateLimitKey('orderNo')`,60 次/分钟 |
| 查询服务(含状态门禁) | `services/worker-platform/worker-collect-service.ts:22` | 只查「待完善」状态的工单,查不到就 404 |
| 对外最小投影 | `work-order-display-mappers.ts:514 mapWorkOrderPublic` | **只暴露 5 个字段**(工单号/订单号/商品名/状态)—— 隐私边界范本 |
| 前端两步式页面 | `pages/collect/CollectPage.tsx`(186 行) | 「输入订单号 → 查询 → 按结果渲染下一步」,可直接照抄骨架 |
| 短链(可选) | `routes/short-links.ts`、`buildShortLinkUrl` | 若想把兑换链接再缩短,可复用(注意该路由注释里写的是「历史短链只读」) |
**固定链接**:`https://<APP_DOMAIN>/redeem`(路径式、不带 `#`,实现方式见 §3.4;无需 token)。
**建议的接口**(与 collect 完全同型):
```text
POST /api/v1/redeem/lookup { orderNo } → 订单摘要 + 下一步动作
POST /api/v1/redeem/confirm { orderNo } → 执行兑换 + 上报核销 + 返回结果
```
**为什么分两步**:第一步只返回**脱敏摘要**,让用户确认「这是我的订单」;确认后才执行兑换(防误领、也防脚本直接打 confirm)。
### 2.4 回执:`coupons.verifyV3` + `order.message`
**核销时机(需要拍板)**:建议 **「我们的履约动作确认成功之后」** 才调 `verifyV3`——
- 若兑换页直接返回卡密/链接 → 展示成功即核销;
- 若兑换后还要人工处理(人工兜底)→ 兑换页只置「处理中」,等后台确认发货后再核销。
**理由**:`verifyV3` 上报后平台侧显示「已核销」,如果这时我们还没真正发货,用户会拿着「已核销」来投诉。发错了还有 `coupons.cancelVerify`(支持 `batch_no` 幂等号)可以冲正。
**发链接**:
```json
{ "order_id": "<子订单号>", "content": "您的订单已核销,请点击链接使用:https://…", "message_type": 2 }
```
- `message_type`:1-订单状态,2-订单发货,4-图片,5-视频 → 建议 `2`(发货语义最贴)。
- ⚠️ **该接口文档标注「测试接口,仅支持特定商户」**,必须确认;若不可用 → 退路是 `topup.result` 的 `url` / `url_type` 字段(去使用链接)承载同样的链接。
---
## 3. 固定兑换链接专项分析
### 3.1 状态分流表(输入订单号之后「下一步」怎么判)
| 订单号查询结果 | 页面提示 | 下一步 | 是否调 verifyV3 |
| --- | --- | --- | --- |
| 查不到(未同步/输错) | 「订单号有误,或订单尚未同步,请 1 分钟后再试」 | 停留输入页 | 否 |
| 未支付 / 已取消 | 「该订单尚未支付」/「订单已取消」 | 不可兑换 | 否 |
| 已退款 | 「订单已退款,无法兑换」 | 不可兑换 + **券码作废** | 否 |
| 已付款、未发券 | 「正在为该订单发券,请稍后再试」 | 重试(后台补偿发券) | 否 |
| 已付款、已发券、未核销 | 展示**脱敏摘要**(商品名/金额/下单时间) | 「确认兑换」按钮 | 确认后调 |
| 已核销 | 展示上次兑换结果(卡密/链接)或「已兑换」 | 不能重复兑换 | 否(幂等) |
| 已发货未核销(人工兜底场景) | 「处理中,请稍后查看」 | 轮询 | 否 |
### 3.2 安全分析:订单号可枚举,必须分层防护
**残余风险(必须写清)**:知道**别人的订单号**即可兑换 → 盗领。抖店订单号是 18~19 位数字,虽然不可穷举,但订单号会出现在**买家聊天记录、截图、客服工单**里,泄露面不小。
6 层防护(按成本从低到高,建议前 5 层在最小版就做):
| 层 | 措施 | 落点 |
| --- | --- | --- |
| 1 | **接口限流**:按 `orderNo` + IP 双维度(复用 `getBodyFieldRateLimitKey('orderNo')`) | `middleware/rate-limit.ts:31`、`routes/collect.ts:11-17` |
| 2 | **状态门禁**:只允许「已付款 + 已发券 + 未核销」三种前置状态进入兑换 | 查询服务(同 `worker-collect-service.ts:22` 的写法) |
| 3 | **两步确认**:lookup 只给脱敏摘要,confirm 才执行(脚本无法一步打穿) | `POST /redeem/lookup` / `/confirm` |
| 4 | **一次性核销**:DB 唯一约束 + 状态机(`ISSUED → VERIFIED`),重复请求幂等返回原结果 | 券表 `cert_no UNIQUE` + 状态字段 |
| 5 | **访问留痕**:记录 IP/UA/订单号到 `webhook_events` 或独立表;同 IP 高频查不同订单号 → `admin_notifications` 告警 | `repositories/admin-notification-repo.ts:25` + `publishRealtimeEvent` |
| 6 | **二次身份校验**(可选):订单号 + 手机号后 4 位 / 下单日期 | 需确认 `order.orderDetail.post_tel` 在虚拟商品场景是否**真的有值**(很可能被充值账号占用,见待确认 Q3) |
**如果业务不接受残余风险**:第 6 层必须做,但会牺牲「只输订单号」的体验 → 与需求冲突,需要业务拍板。
### 3.3 页面交互(3 屏)
```text
屏1 输入订单号 [订单号 __________] [查询]
屏2 确认 商品:XX会员月卡 金额:¥19.90 下单:2026-10-08 15:20
[确认兑换] [不是我的订单]
屏3 结果 ✅ 兑换成功 → 卡密:XXXX-XXXX / 去使用链接
或 ⏳ 处理中,请稍后刷新
```
- 屏 2 的摘要要**脱敏**:商品名可全显(买家自己知道),**不要**显示买家手机号/地址/open_id。
- 屏 3 的卡密按深度分析 §8.1 第 6 条做**掩码 + 复制按钮**,不要明文铺在页面上被截图外传。
### 3.4 固定兑换链接(路径式 `/redeem`,本期只服务抖音)
> 平台方提到「链接以后要支持多平台、抖音先做实验」,但**快手等本期不接入**,所以只保证**链接形态是多平台可复用的**,不为多平台写代码。
**链接形态(确定:路径式,不带 `#`)**
```text
https://<APP_DOMAIN>/redeem
```
**为什么不能直接挂在现有主站路由上**:主站(后台 + 领取页 + 补资料页)用的是 **HashRouter**(`apps/frontend/src/App.tsx:100`),
路由都活在 `#` 后面;`https://域名/redeem` 到浏览器里 hash 为空,会被主站当成 `/` → 跳去 `/admin/dashboard` → 落到后台登录页。
所以**必须另起一个 BrowserRouter 分支**。
**实现方式(照抄 worker 站的做法,改动很小)**
```tsx
// apps/frontend/src/App.tsx —— 在 isWorkerSite() 判断之前拦截
export default function App() {
const legacyWorkerPath = resolveLegacyWorkerPath(window.location.hash)
if (legacyWorkerPath) return <WorkerSiteRedirect path={legacyWorkerPath} />
if (isRedeemPath()) return <RedeemApplication /> // ← 新增:路径式独立入口
if (isWorkerSite()) return <WorkerApplication /> // 现有:worker 子域也是 BrowserRouter
return <AdminApplication /> // 现有:主站 HashRouter(不动)
}
function RedeemApplication() {
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/redeem" element={<RedeemPage />} />
<Route path="*" element={<Navigate to="/redeem" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
```
- **部署不用改**:Caddy 已有 SPA 回退 `try_files {path} /index.html`(`deploy/caddy/Caddyfile`);`@documentResponse` 缓存规则已覆盖非 `/assets` 路径 → index.html 是 `no-cache`。
- `isRedeemPath()` 用 `window.location.pathname` 判断,放在 `isWorkerSite()` **之前**,主域和 worker 子域用同一个链接都能打开。
- 页面**中性文案**:不出现「抖音」字样,标题用「兑换中心 / 卡券兑换」。
**查询策略(本期最简)**
按订单号查本地订单,**provider/platform 直接固定**(`douyin_mashida` / `doudian`):
- 能直接命中现有索引 `orders(provider, platform, platform_order_id, id DESC)`(`001_init.sql:118`)→ **不需要新建索引**。
- 命中 0 条 → 「订单号有误或订单尚未同步」;命中 1 条 → 进确认页。
- 「同一订单号在多个平台都存在」的情况**本期不会发生**(只有一个平台),不做多条选择 UI;但表留 `provider` 列、查询函数留 `provider` 参数 —— 以后接第二个平台时只补一个分支,不用改表。
**⚠️ `cert_link` 必须做成配置项**(`app_config` 里的 `certLink`),不要硬编码:链接一旦通过 `syncV2` 发给平台就**收不回来**(老券身上永远挂着老链接)。以后换域名时,**老域名必须继续可跳**(或用 301 兜住),否则老券全部点不开。
- 备注:现有领取链接仍是 hash 式 `https://域名/#/claim`(`CLAIM_BASE_URL` 配置)—— 两套并存,本次**不动老链路**。
---
## 4. 最小数据模型
只新增 **1 张表**(其余全部复用):
```sql
-- 094_redeem_certificates.sql —— 兑换券表(本期只服务抖音;provider 列预留)
CREATE TABLE IF NOT EXISTS redeem_certificates (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL, -- douyin_mashida / kuaishou_xxx …
cert_no TEXT NOT NULL, -- DYC + Base32(前缀区分平台)
order_id TEXT NOT NULL, -- 回执给平台用的订单号(syncV2 的 order_id,子单优先)
parent_order_id TEXT, -- 平台父订单号(p_id,便于排查)
local_order_id BIGINT, -- 关联 orders.id
order_item_id BIGINT, -- 关联 order_items.id
cert_link TEXT NOT NULL, -- 通用固定兑换链接
status TEXT NOT NULL DEFAULT 'issued', -- issued / synced / verified / invalid
sync_status TEXT NOT NULL DEFAULT 'pending', -- pending / success / failed
sync_error TEXT,
grant_time TIMESTAMPTZ,
synced_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
raw_payload_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_cert_no ON redeem_certificates(cert_no);
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_cert_order ON redeem_certificates(provider, order_id, cert_no);
```
- **幂等 / 核销**:`cert_no` 全局唯一 + `status` 状态机(`issued → synced → verified`,异常态 `invalid`)。
- **不要与快手表混用**:`kuaishou_industry_vouchers` 是快手电子凭证(走快手 OpenAPI 核销),语义不同,本期只是**不复用、也不动它**。
- **查询走现有索引**:兑换页查订单时固定 `provider/platform`,命中 `orders(provider, platform, platform_order_id, id DESC)`(`001_init.sql:118`)→ **不用新建索引**。
- **`provider` 列是为将来留的**:本期只会有 `douyin_mashida` 一个值;接第二个平台时表结构不用改。
- **复用**:`orders` / `order_items` / `fulfillment_tasks` / `webhook_events`(消息幂等)/ `app_config_entries`(凭据配置)。
- 数量多的商品(`item_num > 1`):最小版建议**只发 1 张券**(整单核销),避免券码数量与兑换次数对不上;要按件发券则二期再说。
---
## 5. 代码落点(严格最小集)
> 命名统一按深度分析 §5 D8:**`douyin_mashida`**。
**新增(后端)**
| 文件 | 作用 | 参照 |
| --- | --- | --- |
| `services/platforms/douyin-mashida/sign.ts` | 两套 MD5 小写签名(出网 + 推送验签) | 独立实现,**不能复用** `open-91/crypto.ts`(大写且带 `=`) |
| `services/platforms/douyin-mashida/http-client.ts` | 网关 POST(超时 + 报文日志) | `kuaishou-industry/http.ts:4` |
| `services/platforms/douyin-mashida/gateway-client.ts` | `orderDetail` / `syncV2` / `verifyV3` / `orderMessage` 四个方法 | `kuaishou-industry/openapi-client.ts:33` |
| `services/platforms/douyin-mashida/source-config-service.ts` | `uid` / `secret` / `certLink` / `enabled` 配置 | `affiliate-dash/source-config-service.ts` |
| `services/platforms/douyin-mashida/notify-service.ts` | 推送验签 + 幂等 + 分派(只处理 `trade_TradePaid`、`refund_RefundSuccess`) | `affiliate-dash/notify-service.ts:21-120` |
| `services/platforms/douyin-mashida/order-mapper.ts` | 消息 + 详情 → `SourceOrderEvent` | `pdd/pdd-order-mapper.ts:135` |
| `services/redeem/certificate-service.ts` | 生成 DYC 券码 / 同步 / 核销 / 作废 | `kuaishou-industry/voucher-service.ts` |
| `services/redeem/redeem-service.ts` | 订单号查询 + 状态分流 + 兑换编排(provider 固定抖音) | `worker-collect-service.ts:22` |
| `repositories/redeem-certificate-repo.ts` | 通用券表读写 | `kuaishou-industry-voucher-repo.ts` |
| `routes/douyin-mashida.ts` | 推送入口 | `routes/affiliate-dash.ts:16` |
| `routes/redeem.ts` | `/lookup` `/confirm` | `routes/collect.ts` |
| `db/migrations/094_redeem_certificates.sql` | 兑换券表 | 迁移规范:三位序号 + `IF NOT EXISTS` + `COMMENT ON` |
| `services/admin/platform-config/douyin-mashida-service.ts` + `routes/admin/platform-config/douyin-mashida.ts` | 后台配置(**带脱敏**) | `kuaishou-industry-service.ts:17/216/233` |
**修改(后端,按依赖顺序)**
| 文件 | 改动 |
| --- | --- |
| `config/app-config-keys.ts:1-15` | 加 `douyinMashida: 'douyin_mashida'` |
| `app.ts:102-111` | 挂 `/api/v1/open/douyin-mashida` + `/api/v1/redeem` |
| `services/admin/platform-config/...` | 按现有模式注册(admin 守卫之后) |
| `services/fulfillment/product-resolution-service.ts:165/346` | ⚠️ **必须加分支**:否则订单 `isConfigured=false` 永远不建任务(深度分析 G3)。最小版直接给「人工兜底」候选即可 |
**新增(前端)**
| 文件 | 作用 |
| --- | --- |
| `pages/redeem/RedeemPage.tsx` | 固定兑换页(照抄 `CollectPage.tsx` 骨架:输入 → 查询 → 结果) |
| `services/redeem.ts` | `lookupRedeemOrder` / `confirmRedeemOrder` |
| `App.tsx` | 新增 `RedeemApplication`(独立 `BrowserRouter`)并在 `isWorkerSite()` 之前拦截 `/redeem`;**主站 HashRouter 不动** |
| `services/admin/platform-config/douyin-mashida.ts` + `types/...` + `AdminPlatformShopsPage.tsx` Tab | 后台配置面板 |
**明确不做(最小版边界)**:多平台适配器(快手等本期不接入,只保留 `provider` 列与通用链接形态)、`topup.result`、`order.searchList` 对账轮询、`afterSale.*` 售后、`product.*/sku.list` 商品管理、`coupons.list` 券码查询、`coupons.cancelVerify`(先留接口不接后台)、`message.config`(手动在平台后台配推送地址即可)、商品映射后台(先按商品名/编码人工对一次)、定时任务、多商品拆分。
---
## 6. 最小版必须带上的一件事:退款作废
虽然最小版「不做售后」,但**必须**订一条消息:`refund_RefundSuccess`(退款成功)。
- 收到后:订单置 `refunded`、**券码置 `invalid`**、兑换页显示「订单已退款,无法兑换」。
- 成本:一个分支 + 一次 UPDATE(**不到 0.5 人日**)。
- 不做的话:买家退款之后**仍然能拿券兑换** → 直接资损,而且这类单一定会有。
另外建议顺带订上(同样便宜):`trade_TradeCanceled`(订单取消)→ 同上作废。其余 11 条消息**先不订**(每条都扣金豆)。
---
## 7. 与「后续对接抖音官方」的兼容设计
需求里提到「后面需要对接抖音官方的」,最小版要留好接口,避免返工:
| 设计点 | 现在的做法(兼容官方) |
| --- | --- |
| 命名 | `provider = 'douyin_mashida'`(**通道**)、`platform = 'doudian'`(**业务平台**)。以后直连官方只需改 provider,业务语义不动 |
| 出网层 | `gateway-client.ts` 只暴露 `orderDetail / syncV2 / verifyV3 / orderMessage` 四个**业务方法**,URL/签名在内部;以后换成官方网关只改这一个文件 |
| 券码 | `DYC` 前缀 + 独立券表;官方接入后券码规则不变 |
| 订单来源 | 走标准 `upsertOrderFromSource`,官方推送/轮询都只是换一个 mapper |
| 配置 | `app_config_entries` 存 `uid/secret/baseUrl`;官方通道只需再加一个配置键 |
---
## 8. 风险与待确认
**已答复(2026-10-08,平台方)**
| # | 问题 | 答复 |
| --- | --- | --- |
| Q1 | `order.message` 能否使用 | ✅ 可以用,按文档使用(`message_type` 具体值联调时试) |
| Q2 | `syncV2` 的订单号粒度 | ✅ 业务上一般没有子订单,按单个订单处理;实现仍以 `orderDetail` 的 `sku_order_list[].order_id` 为准、父单兜底 |
| Q3 | 券码 / 链接格式 | ✅ 券码无硬性约束(先照快手);`cert_link` 用**新的固定链接** `https://<域名>/redeem`(§3.4) |
| Q4 | `verifyV3` 核销语义 | ⏳ 以文档为准,联调实测 |
**转为「本地边做边测」**(不再阻塞开工):`post_tel` 取值、券码字符集实测、`cancelVerify` 约束、推送重试策略、账号环境与计费、以及 B 组业务问题(可兑换风险、核销时机、兑换后发什么)。
**技术风险**
| 级别 | 风险 | 缓解 |
| --- | --- | --- |
| 🔴 高 | 固定链接被盗刷/枚举 | §3.2 前 5 层 + 告警;业务不接受则加二次校验 |
| 🟢 低 | ~~`order.message` 不可用~~(已确认可用) | 仍建议保留 `topup.result.url` 作为二期备选 |
| 🟠 中 | 发券失败(syncV2 报错)导致用户看不到卡券 | `sync_status` + 后台手动重发按钮;错误写 `admin_notifications` |
| 🟠 中 | 消息丢失导致漏单 | 最小版**先接受**(人工按订单号补单);二期加 `order.searchList` 对账 |
| 🟢 低 | 多平台订单号撞号(本期只有一个平台,不会发生) | 查询固定 `provider/platform`,命中 0/1 条;将来接第二平台时再补选择 UI |
| 🟡 低 | 券码枚举(DYC+16 位 Base32 = 32^16,实际不可枚举) | 唯一约束 + 查询限流 |
---
## 9. 联调与验收步骤
1. **本地 mock 平台**:用 [03-消息推送规范与签名.md](03-消息推送规范与签名.md) 的算法造 `trade_TradePaid`,验证验签 + 幂等(同一单推 3 次只处理 1 次)。
2. **真单验证收单**:拿一笔真实小额订单,核对 `order.orderDetail` 落库字段(子订单号、商品名、金额)与抖店后台一致。
3. **验证发券**:调 `coupons.syncV2` 后在抖店订单里确认能看到卡券 + `cert_link` 跳转正常(**注意:这步要确认卡券是否真展示给买家**)。
4. **验证兑换**:打开固定链接 → 输订单号 → 确认 → 拿到结果;重复兑换 → 返回原结果不重复发放。
5. **验证回执**:`verifyV3` 后平台侧显示已核销;`order.message` 后买家能收到消息。
6. **验证作废**:对一笔已发券订单发起退款 → 券变 `invalid` → 兑换页正确拒绝。
7. **验收口径**:一笔真实订单从付款到「买家收到链接」全程 ≤ 1 分钟(人工兜底场景除外)。
---
## 10. 工作量估算(最小版)
| 模块 | 规模 |
| --- | --- |
| 签名 + 出网客户端(4 个方法) | M(1.5~2 人日) |
| 推送入口 + 验签 + 幂等 + mapper | M(1.5 人日) |
| 券码生成 + syncV2 + 券表 | M(1 人日) |
| 兑换页 + `/redeem` 接口 + 状态分流 | M(1.5 人日) |
| verifyV3 + order.message + 幂等 | S(0.5 人日) |
| 退款作废 | S(0.5 人日) |
| 后台配置(脱敏)+ 测试推送 | M(1 人日) |
| 联调与修 bug | M(1~2 人日) |
| **合计** | **约 8~10 人日** |
> 相比深度分析里「进单+回执 6~10 人日 + 卡券/售后/面板 8~12 人日」的标准方案,最小版**砍掉了约一半**:省掉 `topup.result`、商品映射后台、对账任务、售后回执、`coupons.list/cancelVerify` 的后台接入、多商品拆券。
@@ -0,0 +1,287 @@
# 抖音码速达 · 接入待确认问题清单
> 用途:把「最小接入」开工前必须问清楚的问题**按对象拆好**,可直接复制给对方;每条都标注了「为什么问 / 卡住什么 / 什么算回答了」。
> 上下文:[05-最小接入方案分析.md](05-最小接入方案分析.md)(最小闭环:收单 → 发券 → 固定链接兑换 → 核销 + 发链接)。
> 整理日期:2026-10-08 | 分支:`dev`
---
## 0. 一页速览
| 对象 | 条目 | 状态 | 说明 |
| --- | --- | --- | --- |
| 平台方(码速达) | 8 组、约 40 条子问题 | **Q1~Q4 已答复**(2026-10-08);Q5~Q8 转本地自测 | 券码无约束、链接要建成多平台通用、`order.message` 可用 |
| 业务方 / 产品 | 8 条 | 全部转「本地边做边测」 | 兑换风险/核销时机/发什么,先按最小假设实现,跑通再定 |
| 内部技术(我们自己拍) | 6 条 | 待拍板 | 域名/路径、幂等键、履约由谁做 |
**平台方答复(2026-10-08)—— 4 个阻塞项已解开**:
| # | 结论 |
| --- | --- |
| Q1 `order.message` | ✅ **可用**,按文档使用即可 |
| Q2 `syncV2` 订单号粒度 | ✅ **按单个订单处理**(业务上一般没有子订单) |
| Q3 券码 / 链接格式 | ✅ 券码**无硬性约束**(先照快手);`cert_link` **新建一个通用链接**,抖音先实验,后续快手等复用 |
| Q4 `verifyV3` 核销语义 | ⏳ 以文档为准,联调时实测 |
**剩下的都转为「本地边做边测」**(Q5~Q8、B1~B8),不再阻塞开工。
---
## 1. 给平台方(码速达)
### Q1 ★阻塞 · 消息发送接口能不能用
**问题**:文档里 `order.message`(订单消息测试)标注「测试接口,仅支持特定商户」。我们的商户号(uid)**能否使用**?如果不能,是否有正式的消息下发接口(把链接/文案推给买家)?
**为什么问**:最小方案第④步「核销后给买家发去使用链接」完全依赖它。
**还要问清楚**:
- `message_type` 各值语义:`1-订单状态`、`2-订单发货`、`4-图片`、`5-视频`;**发 `2` 会不会把抖店订单置为「已发货」**(我们不想意外改变订单状态)?
- `content` 是否支持 URL、支持换行吗、长度上限多少?
- 调用频率限制?单订单能否重复发送(我们不想骚扰买家)?
- 返回里没有业务数据(只有 `code/message`),**如何确认消息真的送达买家**?
**答复(2026-10-08)**:✅ **可以使用**,按文档使用就行;`message_type` 等细节联调时再试。
---
### Q2 ★阻塞 · `coupons.syncV2` 的订单号粒度
**问题**:`syncV2` 的 `order_id` 文档写「子订单ID」——确认是**子订单号(`s_ids` 里的值)**而不是父订单号(`p_id`)吗?
**为什么问**:`trade_TradePaid` 消息只给 `p_id` 和 `s_ids`,我们要决定按哪个粒度落库和发券。
**还要问清楚**:
- 一个父订单有**多个子订单**时:能不能分别同步(每个子单一张券)?还是必须一次把整个父单的券在一个 `cert_list` 里发?
- `cert_list` 单次最大条数?
- 同一个 `order_id` **重复同步**会覆盖、报错、还是追加?(决定我们的重试策略)
- 同步成功后**买家在哪里看到这张卡券**(抖店订单详情 / 抖店「卡券」入口 / 短信 / 站内消息)?进入路径是什么?
⚠️ 这个如果买家找不到卡券,整个最小方案的第③步就断了。
- 券的**有效期**由谁定(我们发券时给 `grant_time`,有效期是平台默认还是可配)?
**答复(2026-10-08)**:✅ 业务上**一般没有子订单,只有一个订单,按一个处理即可**。
> ⚠️ 实现提醒:文档把 `order_id` 定义为「子订单ID」,而 `order.orderDetail` 的 `sku_order_list[].order_id` 才是子订单号 ——
> 即使只有一个商品也不保证等于 `p_id`。**两个号都落库**,调用时「子订单号优先、父单号兜底」,联调第一步先打印对比。
> 「买家在哪里看到卡券」「重复同步语义」转为联调实测项。
---
### Q3 ★阻塞 · 券码与链接的格式约束
**问题**:`cert_no` 和 `cert_link` 有什么硬性限制?
**具体要问**:
1. `cert_no`:长度上下限?字符集(**能不能带字母**?我们计划用 `DYC` + Crockford Base32 十六位,共 19 位)?是否必须全局唯一?是否要求纯数字?
2. `cert_link`:
- 长度上限确认是 **255** 吗?
- 必须 HTTPS 吗?
- **是否支持带 `#` 的 hash 路由**(例如 `https://order.khhao.com/#/redeem`)?还是必须用 path 形式(`https://order.khhao.com/redeem`)?—— 这条直接决定我们的固定链接写法。
- 是否支持带 query 参数(以后可能要带订单号后 6 位做防伪)?
- 链接是**给买家点击跳转**,还是仅做展示?
3. `grant_time`:格式(`YYYY-MM-DD HH:MM:SS`?)、必填还是可空、时区是否按北京时间?
4. `coupons.list` 返回里的 `cert_id` / `out_cert_id` 是什么?我们同步时只给 `cert_no`,会不会导致这两个值缺失、影响买家侧展示?
**答复(2026-10-08)**:✅ 券码**类似快手即可,没有硬性约束**,后面慢慢测试;
`cert_link` **要新做一个通用链接**,需要支持多平台(抖音先做实验,后续快手等可能接入)。
> 由此产生的设计变化:兑换入口必须**平台无关**(跨平台按订单号查、命中多条让用户选、`RedeemProvider` 适配器抽象),
> 券表加 `provider` 列、券码用前缀区分平台。详见 [05-最小接入方案分析.md](05-最小接入方案分析.md) §3.4。
---
### Q4 ★阻塞 · `coupons.verifyV3` 的核销语义
**问题**:核销上报后的行为与限制。
**具体要问**:
- `cert_no` 必须是**我们通过 syncV2 同步过**的券吗?没同步过直接核销会怎样?
- 核销**是否不可逆**(只能靠 `cancelVerify` 冲正)?
- 有无**核销时效**(发券后 N 天内必须核销?超时会怎样)?
- **重复核销**返回什么(幂等成功 / 报错)?
- 核销后买家侧/平台侧会发生什么:券状态变化?订单状态变化?**是否触发我们的结算/分账**?
- ⚠️ 文档里 `coupons.verifyV3` 的「抖店原始路径」写的是 `/coupons/verifyV2`(与 method 不一致),确认一下是文档笔误还是真有两个版本?
**答复(2026-10-08)**:⏳ 先**以文档为准**,联调时实测(核销是否不可逆、重复核销返回、是否触发结算)。
---
### Q5 · `coupons.cancelVerify` 与异常兜底
**问题**:
- `cancel_verify_count`(取消核销总次数)是否必填?语义是「总共可取消几次」还是「本次取消的是第几次」?
- `batch_no` 幂等号的规则(谁生成、多久内有效、重复提交的行为)?
- 核销后**多久内**可以取消?次数上限?
- 如果我们的券发错了(比如商品对不上),除了 `cancelVerify` 还有别的处理方式吗(作废/换券)?
**算回答**:取消核销的完整约束。
---
### Q6 · 消息推送的可靠性约定
**问题**:
- 推送**失败重试策略**:重试几次、间隔多久、单次超时多少秒、要求我们返回什么(HTTP 200?特定 body?)
- 我们处理失败(比如拉订单详情失败)时应该返回什么?**返回 4xx/5xx 会不会触发重推**?(我们倾向于「未处理成功就返回失败,让平台重推」)
- `trade_TradePaid` 到达时,**`order.orderDetail` 能立刻查到完整商品明细吗**?会不会有时间差(我们怕查到空明细)?
- 推送有没有**顺序保证**?(例如 `trade_TradePaid` 与 `refund_RefundSuccess` 的先后)
- 一个 uid 绑定多个店铺时,消息里的 `shop_id` 如何区分?我们要不要为每个店铺单独配置?
**算回答**:给一份「推送可靠性说明」(重试/超时/应答规范/时序保证)。
---
### Q7 · 数据与隐私
**问题**:
- `order.orderDetail` 的 `post_tel` / `post_receiver` 在**虚拟商品、卡券类订单**里**是否有值**?(决定我们能不能做「订单号 + 手机号后 4 位」的二次校验)
- `buyer_words`(买家留言)在充值/卡券类订单里,是否就是**买家要充值的账号**?这是我们发货的关键输入。
- `doudian_open_id` 是加密串,我们只做内部留存、不对外展示,是否符合平台要求?
- 有没有关于**买家信息留存时长**的合规要求?
**算回答**:明确 `post_tel` / `buyer_words` 在目标品类下的实际取值。
---
### Q8 · 账户、环境与计费
**问题**:
- `uid`(抖店商户号)与「用户秘钥」的分配流程?**一个 uid 能否绑定多个抖店店铺**?
- 有没有**测试环境 / 沙箱店铺**?`order.message` 能否在沙箱里给我们发测试消息?
- 密钥**轮换**流程(多密钥并存?切换窗口?)
- 各接口的金豆消耗确认:`order.orderDetail` 高级(2 金豆)、`coupons.syncV2` 基础(1)、`coupons.verifyV3` 高级(2)、`order.message` 高级(2)—— 与文档一致吗?有没有**包量/折扣**?
- 有没有**调用频控**(QPS / 日限额)?超限返回什么错误码?
- 消息推送配置(`message.config`)是我们自己调接口设置,还是你们后台代配?`message_url` 是否要求备案域名/HTTPS?
**算回答**:账号与环境开通清单 + 计费与频控书面确认。
---
## 2. 给业务方 / 产品
### B1 ★阻塞 · 能否接受「知道订单号即可兑换」
**背景**:新的固定兑换链接不带 token,用户输入订单号即可进入兑换流程。这意味着**任何人只要拿到别人的订单号,就能兑换别人的商品**。订单号会出现在买家聊天记录、订单截图、客服工单里。
**问**:
- 能否接受这个风险(我们会做限流 + 状态校验 + 一次性核销 + 异常告警)?
- 如果不能接受,是否接受**加一道校验**(订单号 + 手机号后 4 位 / 下单日期),代价是用户要多填一个字段?
**影响**:决定兑换页是「1 个输入框」还是「2 个输入框」,以及要不要先确认平台侧能提供手机号。
---
### B2 ★阻塞 · 兑换成功后给买家发什么
**问**:
- 兑换成功后,买家应该看到什么:卡密(我们自己发的码)?第三方链接(充值页/网盘)?还是只提示「已为您充值成功」?
- 第④步的通知走哪条路:`order.message`(测试接口)还是 `topup.result` 的 `url` 字段(正式)?
- 通知文案谁来定?
**影响**:决定第④步的实现方式,以及兑换结果页的设计。
---
### B3 ★阻塞 · 核销时机
**问**:用户在我们页面点「兑换」的瞬间就上报核销,还是等**实际履约完成**(人工发货/自动发货成功)后再上报?
**背景**:核销上报后平台侧显示「已核销」。如果这时我们还没真正发货,买家会拿「已核销」来投诉;反过来说,如果等人工确认,买家在页面上会看到「处理中」。
**建议**:人工兜底场景下,等确认发货后核销(页面上显示「处理中,请稍后查看」)。
**影响**:决定兑换页第 3 屏是「立即出结果」还是「处理中 + 轮询」。
---
### B4 · 兑换背后的履约由谁做
**问**:兑换之后,商品由谁交付?
- 打手接单(走现有接单平台 + 接单模板)?
- 客服人工处理(后台任务列表)?
- 自动(调用第三方接口)?
**影响**:决定最小版是不是「人工兜底」就够,以及要不要接通 `product-resolution-service` 的商品匹配分支。
---
### B5 · 退款/取消后怎么处理
**问**:订单退款或取消后:
- 券码作废 + 兑换页拒绝,是否足够?
- 需要主动通知客服/联系买家吗?
- 如果买家**已经兑换**了才退款,怎么处理(追缴?作废?不处理?)?
**影响**:决定最小版要不要做「已兑换后退款」的处置流程。
---
### B6 · 最小版先上哪些商品
**问**:先拿哪几个商品/店铺跑通?一件商品发**一张券**(整单核销)还是按 `item_num` **按件发券**?
**建议**:最小版先「一单一张券」,避免券数与兑换次数对不上。
---
### B7 · 老的 token 领取链接怎么办
**问**:现有 `/claim/:token` 的领取链接(91 卡券链路)继续保留,还是也要切到新的固定兑换链接?两套并存期间的客服口径是什么?
---
### B8 · 兑换页的文案与品牌
**问**:页面标题、logo、客服入口(微信/电话)、异常提示文案谁来定?是否需要与抖店店铺风格一致?
---
## 3. 内部技术待拍板
| # | 问题 | 建议 |
| --- | --- | --- |
| T1 | 兑换页地址与形态 | `https://<APP_DOMAIN>/#/redeem`,前端新增 `/redeem` 路由,与 `/collect` 同级;若平台不支持 hash 链接,改为 `https://<APP_DOMAIN>/redeem`(Caddy 侧加 rewrite) |
| T2 | 幂等键 | 消息:`topic:pub_time:p_id:s_id`(body 哈希兜底);券:`order_id + cert_no` 唯一 |
| T3 | 履约通道 | 最小版直接用 `manual_dispatch`(人工兜底),**但仍必须**在 `product-resolution-service.ts:165/346` 加 `douyin_mashida` 分支,否则订单 `isConfigured=false` 不会建任务 |
| T4 | 券码格式 | `DYC` + Crockford Base32 16 位(照抄 `kuaishou-industry-voucher-repo.ts:49-52`),等平台方 Q3 确认后定稿 |
| T5 | 配置存放 | 走 `app_config_entries`(后台可改 + 脱敏),不走 env(可绕开 `env-documentation.test.ts:57/63` 的白名单约束) |
| T6 | 买家信息留存 | `post_tel` / `buyer_words` / `doudian_open_id` **只进 `orders` 与原始报文**,不进 `work_orders.material_json`、不下发打手(沿用现有隐私红线) |
---
## 4. 可直接转发给平台方的一段话
> 你好,我们准备接入贵平台,先做一个最小闭环:接收 `trade_TradePaid` 消息 → 调 `order.orderDetail` 获取商品信息 → 生成我们自己的券码(前缀 DYC)→ 调 `coupons.syncV2` 同步给贵平台 → 买家在我们固定兑换页输入订单号兑换 → 调 `coupons.verifyV3` 上报核销。
>
> 开工前想确认几件事:
> 1. `order.message`(订单消息测试)我们的商户号能否使用?`message_type=2`(订单发货消息)会不会改变抖店订单的发货状态?content 是否支持 URL、长度上限多少?
> 2. `coupons.syncV2` 的 `order_id` 是子订单号还是父订单号?多子订单能否分别同步?重复同步是覆盖还是报错?同步成功后买家在**哪里**能看到这张券(抖店订单详情?卡券入口?)?
> 3. `cert_no` 的格式限制(长度/字符集,能否用 DYC + 16 位 Base32 字母数字)?`cert_link` 是否支持带 `#` 的 hash 路由、是否必须 HTTPS、长度上限是否 255?
> 4. `coupons.verifyV3` 核销是否不可逆?重复核销返回什么?核销是否会触发结算?另外文档里 verifyV3 对应的抖店路径写的是 `/coupons/verifyV2`,请确认。
> 5. 消息推送的失败重试策略、超时时间、对我们应答的要求;`trade_TradePaid` 到达时 `order.orderDetail` 能否立即查到完整商品明细?
> 6. 我们的 uid 能否绑定多个店铺?有没有测试环境?各接口的计费与频控能否书面确认?
>
> 另外麻烦确认:`order.orderDetail` 的 `post_tel`、`buyer_words` 在虚拟商品/卡券类订单里是否有值(我们要判断能否用手机号后 4 位做二次校验)。
---
## 5. 答复跟踪表(拿到答案后回填)
| # | 问题 | 优先级 | 状态 | 答案 / 结论 | 影响的实现 |
| --- | --- | --- | --- | --- | --- |
| Q1 | `order.message` 可用性 | ★阻塞 | ✅ 已答(10-08) | **可用**,按文档使用 | 第④步用 `order.message` 发链接 |
| Q2 | `syncV2` 订单号粒度 | ★阻塞 | ✅ 已答(10-08) | 按单个订单处理;实现仍取 `sku_order_list[].order_id`、父单兜底 | 落库粒度 |
| Q3 | 券码 / 链接格式 | ★阻塞 | ✅ 已答(10-08) | 券码无约束(照快手);`cert_link` 新建**多平台通用**链接 | 券表通用化 + 兑换入口平台无关(05 §3.4) |
| Q4 | `verifyV3` 核销语义 | ★阻塞 | ⏳ 联调实测 | 以文档为准 | 核销时机与幂等 |
| Q5 | `cancelVerify` 约束 | 一般 | 待问 | | 异常兜底 |
| Q6 | 推送可靠性 | 重要 | 待问 | | 应答与重试策略 |
| Q7 | `post_tel`/`buyer_words` 取值 | 重要 | 待问 | | 二次校验可行性 |
| Q8 | 账号/环境/计费 | 重要 | 待问 | | 联调准备 |
| B1 | 订单号可枚举风险 | ★阻塞 | 待定 | | 兑换页交互 |
| B2 | 兑换后发什么 | ★阻塞 | 待定 | | 第④步与结果页 |
| B3 | 核销时机 | ★阻塞 | 待定 | | 结果页形态 |
| B4 | 履约由谁做 | 重要 | 待定 | | 是否接打手 |
| B5 | 退款处置 | 重要 | 待定 | | 作废流程 |
| B6 | 先上哪些商品 | 重要 | 待定 | | 联调范围 |
| B7 | 老链接是否保留 | 一般 | 待定 | | 并存期策略 |
| B8 | 页面文案品牌 | 一般 | 待定 | | 前端设计 |
@@ -0,0 +1,181 @@
# 抖音码速达 · 最小接入实现说明(本地文档)
> 对应方案:[05-最小接入方案分析.md](05-最小接入方案分析.md)。本文记录**已经落地了什么、怎么配、怎么联调、有哪些坑**。
> 状态:后端已实现并通过 `npm run check`(630 测试 / 627 通过 / 0 失败);数据库迁移 `094` 未执行过(本地/生产启动时自动 up)。
> 文档本身**未入库**,与 04/05/06 一样只保留在本地。
---
## 1. 闭环与落点
```text
① 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|confirm`,按 `orderNo`+IP 限流 60/分钟 |
| 后台配置 | `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 会话):
```bash
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"]
}
```
2. 前端面板(**本期未做**,见 §6)。
3. 直接改库:`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`:
```bash
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 文本**里把长数字原样抓回来(存成字符串):
```ts
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 退款/取消消息文档没列字段
`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 的兜底是否必须,但已经做了不亏)。
+212
View File
@@ -0,0 +1,212 @@
# 抖音码速达 · 公网联调(ngrok)
> 目标:让**真实码速达平台**把消息推到你的机器,同时买家能打开你的 `/redeem`。
> **离线模拟(网关桩 / 推送模拟器)已按要求删除** —— 只走真实平台。
> 实现细节见 [07-最小接入实现说明.md](07-最小接入实现说明.md)。
---
## 0. 为什么必须公网
抖音码速达是双向的:
| 方向 | 谁发起 | localhost |
| --- | --- | --- |
| **出站** `order.orderDetail` / `syncV2` / `verifyV3` / `order.message` | 我们 | ✅ 行(`baseUrl` 指真平台且能出网即可) |
| **进站** `trade_TradePaid` / 退款推送 | **平台** | ❌ **平台必须能主动访问你** |
| 买家打开 `cert_link`(`/redeem`) | 买家手机 | ❌ 手机打不开你的 localhost |
隧道必须转发到 **Caddy 的 80 端口**(`deploy/caddy/Caddyfile.dev` 是 `:80 {}`,任意 Host 都匹配,不用改 Caddy):
```text
ngrok → Caddy:80 ─┬─ /api/* → backend:3000 ← 平台推消息打这里
├─ /health* → backend:3000
├─ /s/* → backend:3000
└─ 其余 → frontend:5173 ← 买家打开 /redeem 打这里
```
---
## 1. 起 ngrok
```bash
ngrok http 80
# 输出里的 https://xxxx.ngrok-free.app 就是公网地址
```
已验证可用(ngrok v3.39.10,authtoken 已配置)。
### 强烈建议固定域名
免费版**每次重启域名都会变**,一变更就要重配平台 message_url,而**已发给买家的 `cert_link` 不可撤回**。
```bash
# 1) 在 ngrok 后台 Domains 里保留一个免费静态域名(免费额度 1 个)
# 2) 用固定域名启动
ngrok http 80 --domain=<你的静态域名>.ngrok-free.app
```
### ⚠️ 免费版的浏览器警示页(影响买家)
**实测**:平台推送不受影响(服务端 POST,正常进我们的路由);
但**浏览器**访问会被插一页全英文 ngrok 警示页:
```text
模拟 iPhone 浏览器访问 /redeem →
HTTP 200, 2781 字节, <html ... ngrok assets ...> ← 不是我们的页面
同一地址用 curl(Accept: */*)→ 200, 561 字节, 正常 vite 页面
```
也就是说:**买家点开兑换链接会先看到 "You are about to visit ..." 并要求点一下**,这对真实用户体验是硬伤。
对策(按推荐度):
1. **联调期间接受**:自己和测试同学点一下即可;平台回调完全不受影响。
2. **换付费/带自定义域名的 ngrok**:警示页会消失(生产环境本来也不该用隧道,应该用正式域名)。
3. 我们的脚本自检时会带 `ngrok-skip-browser-warning: 1` 头,不受影响。
> 备注:如果哪天回头用 Cloudflare Tunnel,它**没有**这个警示页;当时连不上的原因是本机代理拦了 QUIC
> (拨到 fake-IP `198.18.x.x` 一直 timeout),配置里加 `protocol: http2` 即可解决。
---
## 2. 一条命令切换地址 + 自检
```bash
cd apps/backend
npm run douyin-mashida:set-public-url -- --public-url https://xxxx.ngrok-free.app
```
它会把 `certLink` / `afterVerifyLink` 改成 `<公网地址>/redeem`,
打印**平台侧要配的三项**,并自检隧道(假签名打公网地址,期望 401)。
---
## 3. 真实联调前置自检
```bash
npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app
# 有真实密钥时(这一步才能验签名):
npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --secret <真实用户秘钥>
```
四步:① 后台配置就绪度 ② **真实网关连通性** ③ 公网回调可达性 ④ certLink 与公网域名是否一致。
实测输出(配置未填完时):
```text
[1/4] 后台配置
enabled ✅ 开启
uid ❌ 未填
certLink ✅ https://xxxx.ngrok-free.app/redeem
baseUrl https://dy-openplatform-api.wanmahui.com
ready ❌ false
[2/4] 真实网关连通性
POST https://dy-openplatform-api.wanmahui.com/api/openplatform/gateway
HTTP 200
平台返回 code=10014 message=uid格式异常
✅ 网关连通、域名/路径/form 编码/参数形态都被接受
[3/4] 公网回调自检
HTTP 401 errorCode=douyin_mashida_message_invalid_signature
✅ 平台能推到你
[4/4] 域名一致性
✅ 一致
```
> **②的解读**:平台**先校 uid 再校签名**。不带 `--secret` 时只能证明"网络+路径+参数形态"没问题;
> **带上 `--secret` 后如果平台报的不是签名错误(而是"订单不存在"之类业务错),就说明签名/密钥也对了** ——
> 这一步能在正式推送前把密钥问题排掉,最值钱。
> 平台文档只给了 `code=10000` 表示成功、"其余均表示错误",**没有完整错误码表**;
> 脚本不翻译码值,只原样打印,并对**实测观测到**的码加说明(如 `10014`)。
---
## 4. 联调 checklist
```text
1. 隧道起来 ngrok http 80 --domain=<静态域名>
2. 切公网地址 npm run douyin-mashida:set-public-url -- --public-url https://<域名>
3. 填真实凭据 面板:uid = 抖店商户号,secret = 真实用户秘钥
(baseUrl 保持 https://dy-openplatform-api.wanmahui.com)
4. 前置自检 npm run douyin-mashida:preflight -- --public-url https://<域名> --secret <真密钥>
全 ✅ 才能开始
5. 平台侧配推送 message_url = https://<域名>/api/v1/open/douyin-mashida/message
topics = trade_TradePaid(建议加 trade_TradeCanceled、refund_RefundSuccess)
6. 平台侧下一笔小额真单 → 日志看:
POST /api/v1/open/douyin-mashida/message 收到推送
[douyin-mashida/certificate] 券码已同步 syncV2 成功
7. 手机打开 cert_link 走一遍兑换(首次要过 ngrok 警示页)→ 本地看到 verifyV3 + order.message
8. 对账 orders / order_items / redeem_certificates 三张表
```
### 必须实测的 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` 实际是**字符串还是数字**(数字会触发 19 位精度问题)
---
## 5. 坑
### 5.1 签名覆盖**原始 body**,中间层必须字节透传 ★
推送签名 = `md5(secret + "data" + data + "pub_time" + pubTime + "topic" + topic + secret)`,
`data` 是 body 里的**原始 JSON 字符串**。任何重排/转义/压缩 body 的中间层都会让验签失败。
- ngrok 是透传,没问题;
- 中间若再加别的网关 / WAF / "统一 JSON 美化",就会 401。**报 401 先怀疑中间层改过 body。**
- 我们也因此把消息路由挂在 `app.ts` 全局 body parser **之前**(`app.ts:42`),用 `express.text` 收原文。
### 5.2 ngrok 免费版:域名会变 + 浏览器警示页
见 §1。域名一变,平台 message_url 和已发出的 `cert_link` 都会失效;浏览器访问会先过警示页。
### 5.3 Vite 的 `allowedHosts` 会拦隧道域名
隧道域名经 Caddy 转发到前端 dev server 时 Host 是隧道域名,不在白名单里 Vite 直接拒绝
(`Blocked request. This host is not allowed.`),**表现为页面打不开、后端日志却完全正常**。
已修:`vite.config.ts` 默认放行 `.ngrok-free.app` / `.ngrok.app` / `.ngrok.io` / `.trycloudflare.com`,
并真正接上了 `VITE_ALLOWED_HOSTS`(`.env` 里那个 `221329.cc.cd` 现在才真正生效 —— 之前白名单是硬编码的,变量从没被读过)。
### 5.4 配置是内存缓存,只有后台接口能立刻生效
`readAppConfigEntry` 读的是启动时从 DB 载入的**内存缓存**(`app-config-store.ts:8`)。
直接 `UPDATE app_config_entries` 不生效,必须走 `POST /api/v1/admin/platform-config/douyin-mashida`
(面板和两个脚本都走接口),否则要重启后端。
### 5.5 占位密钥会让"已配置"骗过你
早期本地联调存进库里的 `dev_douyin_mashida_secret` 会让 `hasSecret=true`,看起来配好了,
实际联调必然签名失败。`preflight` 会识别这个占位值并直接报阻塞。
### 5.6 certLink 域名容易漏改
`certLink` 是**发给买家**的链接,必须指向当前公网地址。`preflight` 第 ④ 步专门比对
`certLink` 与 `--public-url` 的 host,不一致会给提醒。
---
## 6. 排错表
| 现象 | 原因 / 处理 |
| --- | --- |
| 自检第 ③ 步 404 | 隧道没转发到 Caddy 80(可能指到了静态站点) |
| 自检第 ③ 步拿到 HTML | ngrok 浏览器警示页(自检脚本已带 skip 头);或隧道指错服务 |
| 页面能开但白屏 + `Blocked request` | Vite `allowedHosts` 未放行隧道域名(§5.3) |
| 平台推送返回 **401 验签失败** | `secret` 与平台不一致;或中间层改了 body(§5.1) |
| 平台推送后本地无日志 | 隧道挂了 / message_url 配错 / 平台没订阅 topic |
| 收到推送但券同步失败 | 真实 `uid`/`secret` 不对;或 `baseUrl` 没指向真平台 |
| 买家点链接看到英文警示页 | ngrok 免费版特性(§5.2),点一下即可;正式环境用正式域名 |
| 域名突然全失效 | ngrok 重启换了域名(§5.2)→ 重跑 `set-public-url` 并改平台配置 |
| 改了配置行为没变 | 没走后台接口保存(§5.4) |
| `preflight` 报"占位密钥" | 把面板里的 secret 换成真实用户秘钥(§5.5) |