From d9848aaef3bb35e70d16012da7e2922f1c88090f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 12:01:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(douyin-mashida):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=88=86=E6=9E=90=E3=80=81=E5=AE=9E=E7=8E=B0=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E4=B8=8E=E5=85=AC=E7=BD=91=E8=81=94=E8=B0=83=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 00-总览:进度索引(闭环落点 / 代码清单 / 配置项 / 上线步骤 / 缺口 / 待实测点) - 04-结合本项目的深度分析:深读平台文档后与仓库既有实现的对应关系 - 05-最小接入方案分析:实施方案与取舍(本期只做抖音、不做打手、不做多平台) - 06-待确认问题清单:已答复项与"边做边测"项 - 07-最小接入实现说明:实现要点、上线三步、两个必须记住的坑 (19 位订单号 JSON.parse 丢精度 / 验签必须用 body 原文) - 08-公网联调:ngrok 隧道 + 一键切地址 + 前置自检 + 联调 checklist + 排错表 --- docs/抖音-码速达/00-总览.md | 250 +++++++++++ docs/抖音-码速达/04-结合本项目的深度分析.md | 358 +++++++++++++++ docs/抖音-码速达/05-最小接入方案分析.md | 468 ++++++++++++++++++++ docs/抖音-码速达/06-待确认问题清单.md | 287 ++++++++++++ docs/抖音-码速达/07-最小接入实现说明.md | 181 ++++++++ docs/抖音-码速达/08-公网联调.md | 212 +++++++++ 6 files changed, 1756 insertions(+) create mode 100644 docs/抖音-码速达/00-总览.md create mode 100644 docs/抖音-码速达/04-结合本项目的深度分析.md create mode 100644 docs/抖音-码速达/05-最小接入方案分析.md create mode 100644 docs/抖音-码速达/06-待确认问题清单.md create mode 100644 docs/抖音-码速达/07-最小接入实现说明.md create mode 100644 docs/抖音-码速达/08-公网联调.md diff --git a/docs/抖音-码速达/00-总览.md b/docs/抖音-码速达/00-总览.md new file mode 100644 index 00000000..3bd032a9 --- /dev/null +++ b/docs/抖音-码速达/00-总览.md @@ -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=` 的表单形态)。 +任何全局 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`、重复券码触发唯一键 | diff --git a/docs/抖音-码速达/04-结合本项目的深度分析.md b/docs/抖音-码速达/04-结合本项目的深度分析.md new file mode 100644 index 00000000..ffa518f4 --- /dev/null +++ b/docs/抖音-码速达/04-结合本项目的深度分析.md @@ -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__`、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**(签名 + 消息入口)开工 —— 这两块不依赖任何业务口径,且是后续一切联调的前提。 diff --git a/docs/抖音-码速达/05-最小接入方案分析.md b/docs/抖音-码速达/05-最小接入方案分析.md new file mode 100644 index 00000000..05fd4f0c --- /dev/null +++ b/docs/抖音-码速达/05-最小接入方案分析.md @@ -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 + 例: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:///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:///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 + + if (isRedeemPath()) return // ← 新增:路径式独立入口 + + if (isWorkerSite()) return // 现有:worker 子域也是 BrowserRouter + return // 现有:主站 HashRouter(不动) +} + +function RedeemApplication() { + return ( + + }> + + } /> + } /> + + + + ) +} +``` + +- **部署不用改**: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` 的后台接入、多商品拆券。 diff --git a/docs/抖音-码速达/06-待确认问题清单.md b/docs/抖音-码速达/06-待确认问题清单.md new file mode 100644 index 00000000..1840a437 --- /dev/null +++ b/docs/抖音-码速达/06-待确认问题清单.md @@ -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:///#/redeem`,前端新增 `/redeem` 路由,与 `/collect` 同级;若平台不支持 hash 链接,改为 `https:///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 | 页面文案品牌 | 一般 | 待定 | | 前端设计 | diff --git a/docs/抖音-码速达/07-最小接入实现说明.md b/docs/抖音-码速达/07-最小接入实现说明.md new file mode 100644 index 00000000..208cd005 --- /dev/null +++ b/docs/抖音-码速达/07-最小接入实现说明.md @@ -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=' \ + -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=` 的 urlencoded 形态。 + +### 4.3 幂等:平台没有 `event_id` + +`webhook_events(provider, event_id)` 的唯一键要我们派生: + +- 有订单号:`topic:pub_time:p_id:s_ids` +- 没有:`topic:pub_time:` + +券本身还有一层幂等:`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 的兜底是否必须,但已经做了不亏)。 diff --git a/docs/抖音-码速达/08-公网联调.md b/docs/抖音-码速达/08-公网联调.md new file mode 100644 index 00000000..c4e0ca53 --- /dev/null +++ b/docs/抖音-码速达/08-公网联调.md @@ -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 字节, ← 不是我们的页面 +同一地址用 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) |