Files
order_site/docs/抖音-码速达/00-总览.md
T
yml2213 7bacb7d5eb docs: 重构项目文档并补充三块载体、成本中心与人工支出方案
合并本轮全部文档改动(原 5 个 docs 提交压成 1 个)。

口径与索引:
- 新增 docs/README.md 文档总索引:三来源口径(91 + 快手 send-code = kuaishou-91)、
  「一个订单 = 骨架 + 买家凭证 + 一个执行载体」、成本只改一处、文档状态机
- 过期阶段文档移入 docs/archive/2026-10-08/(保留内容,不删除)

分析报告:
- docs/项目全量分析报告-2026-10-17.md:架构、五条业务链路、P0/P1/P2 风险与改进路线
- docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:生产只读克隆量化、上一轮结论复核与 4 处修正

方案文档:
- docs/成本中心与三块履约模型.md:三块执行载体、标准商品、成本版本与快照、人工支出登记
- docs/财务系统集成方案.md:复式记账模型、分录表、分期落地
- docs/影子总账说明.md:影子期与转正期的边界、对账与转正标准

SQL 草案(docs/drafts/,未接入迁移):
- 载体判定与人工块量化.sql:量化查询、carrier_type 判定与回填、守恒校验
- 成本中心与销售事实-DDL草案.sql:product_catalog / channel_product_mappings /
  cost_items / cost_versions / cost_snapshots / cost_exceptions / sales_daily_facts
- 人工支出登记-DDL草案.sql:manual_fulfillment_cases / manual_expense_records / manual_expense_events

业务口径确认(2026-10-17):
- 收入 = 用户实付(orders.total_amount),与快手 paymentFen 数据核验 100% 一致
- 打手报酬 = 支出/成本(最终提现打款),按总额法
- 人工块以「支出可追溯」为先,SLA 监管延后
- 退款/仲裁暂未接入,先预留字段与反向冲正关系

三份草案已在一次性沙箱库按依赖顺序零错误应用(10 张新表、27 个 CHECK 约束)。
2026-10-17 22:03:03 +08:00

320 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 抖音码速达 · 总览(进度 / 代码 / 配置 / 上线)
> 更新:2026-10-15 **状态:最小接入已跑通,且按 SKU 的三层分流已上线**(真实网关签名验证 / 真实推送订阅 / 真实 orderDetail 建单 / 真实 coupons.syncV2 同步成功 / 工单发布顺带核销抖音券),两端自检全绿
> 本文是这套内容的**入口**,具体细节在 04~12,最新增量见 [§九点六](#九点六2026-10-14--10-15-增量)。
---
## 一、一句话现状
按 [05-最小接入方案分析.md](05-最小接入方案分析.md) 落地的**最小闭环六步全部完成**;2026-10-17 复核:后端 740 项测试(737 pass / 3 skipped)、前端 143 项测试全绿,迁移 `094` / `095` 已在本地临时库实测通过。
**没做的**:兑换后的实际交付物(卡密/充值)、`order.searchList` 对账、兑换页二次校验(手机后 4 位)。
> ✅ **原「按 SKU 的三层分类」已上线**(2026-10-14):抖音与 `kuaishou-91` 来源(91 回调 + 快手 send-code)同构(命中映射 → `affiliate_dash` 自动发货;未命中 → 接单平台建工单;都不中 → 无载体=待人工),载体唯一有硬守卫。总设计 [12-按SKU分类改造总设计.md](12-按SKU分类改造总设计.md)、分层实测 [10-三层分流设计.md](10-三层分流设计.md)、运营动作 [11-接单模板待补清单.md](11-接单模板待补清单.md)。
**只走真实公网联调**(离线模拟已按要求删除):
1. `ngrok http 80` 起隧道拿公网地址
2. `douyin-mashida:set-public-url` 一键把 `certLink` 切过去 + 打印平台侧待配项 + 自检隧道
3. `douyin-mashida:subscribe` **订阅推送**(`message.config`)—— 不做这步平台一条消息都不会推
4. `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 |
| 公网联调 | `set-public-url` + `preflight` + `subscribe` 三个脚本 + 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` 表 |
| `db/migrations/095_redeem_certificates_platform_cert.sql` | 30 | 记录**平台侧实际券码**(平台会改名,核销必须用它) |
| `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 | **前置自检**:配置就绪度 + 真实网关连通性(可选验签名)+ 公网回调 + 域名一致性 |
| `scripts/douyin-mashida-subscribe.ts` | 250 | **推送订阅**:调 `message.config` 配置推送地址与 topics + `messagetest` 自测 |
### 新增 · 前端
| 文件 | 行 | 作用 |
| --- | ---: | --- |
| `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` / `douyin-mashida:subscribe` |
| `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) | 抓取说明 + 目录 | ✅ 已入库 |
| [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` | 进度索引 | ✅ 已入库(现行) |
---
## 六、落地提交记录
| commit | 内容 |
| --- | --- |
| `5a73fb74` | 抖音券核销后台界面(列表/核销/冲正/同步/对账/作废) |
| `319e0822` | 领取消息只发给有自动发货领取单元的单,人工单不再假发货 |
| `4b081d88` | 抖音按 SKU 三层分类接入,对齐 `kuaishou-91` |
| `10464fea` | 抽离接单模板匹配引擎(来源适配器/引擎/价格收口) |
| `6850bc64` | 工单发布并核销支持抖音券(按来源分流) |
> 本文档与 04~12 均已入库;历史分析文档按状态标注,不再使用“仅本地/不入库”的口径。
---
## 七、上线步骤
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~~ | ~~履约任务分支~~ | **已完成且已修正**:命中映射 → `affiliate_dash` 自动发货;未命中**不再写死** preferred、**不再兜底 `manual_dispatch`**(否则会出现「人工任务 + 工单」双载体) | `product-resolution-service.ts:286-350`(commit `4b081d88`) |
| ~~2b~~ | ~~**接单平台发单层(层②)**~~ | **已完成**:抖音按内部 SKU 三层分类(`snapshot.classification`),发单层有「载体唯一」硬守卫(命中 `affiliate_dash` 即跳过建工单);生产库接单模板 `platform` 已全部清空(354 条),抖音单从此可命中模板 | [10-三层分流设计.md](10-三层分流设计.md) / [11-接单模板待补清单.md](11-接单模板待补清单.md) |
| ~~2c~~ | ~~工单「发布并核销」只核销快手电子凭证~~ | **已完成**(2026-10-15):按来源分流,抖音工单核销 DYC 券(`redeem_certificates`),见 [§九点六](#九点六2026-10-14--10-15-增量) | `worker-platform/publish-douyin-voucher-service.ts` |
| 3 | 兑换后的实际交付物 | 当前只「上报核销 + 展示掩码券码 + 发链接」 | 等业务定(B2) |
| 4 | `order.searchList` 对账任务 | 消息丢了就漏单 | 二期 |
| ~~5~~ | ~~`cancelVerify` 后台按钮~~ | **已完成**:后台「抖音券核销」含核销/冲正/重新同步/对账/重发/作废(每个写操作带审计) | `routes/admin/douyin-mashida.ts`(commit `5a73fb74`) |
| 6 | 兑换页二次校验(手机后 4 位) | 知道订单号即可兑换 | 看业务是否接受(B1) |
| 7 | `message.config` 自动订阅 | 需平台侧手配 | 可选做「一键订阅」 |
| 8 | 隧道域名变化需手工同步平台侧 | `set-public-url` 会打印待配项;ngrok 免费版建议保留静态域名 | 固定域名后基本不是问题 |
| 9 | ngrok 免费版对浏览器插警示页 | 买家点 `cert_link` 会先过一页英文提示 | 正式环境用正式域名;联调期间点一下即可 |
| — | 工单里的抖音券前端不渲染 | **有意为之**(2026-10-15 用户口径):打手/客服**不需要看到券码**,快照只落 `material_json` 供追溯 | `material_json.douyinMashidaVoucher` |
### 联调要实测的点
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,签名原串就对不上。
---
## 九点五、真实平台实测结论(2026-10-09)
| 验证项 | 结果 |
| --- | --- |
| 出网签名(用真实 uid/secret 调 `order.orderDetail`) | ✅ `code=10000`,签名规则与文档一致 |
| 推送订阅 `message.config` | ✅ `code=10000 成功`(配置前平台一条都不推 —— 这是"下单没进 dev"的根因) |
| 进站推送(真密钥签名 → 公网地址 → 验签 → 建单) | ✅ `action=paid-order-upserted` |
| 19 位订单号精度 | ✅ `6930310175251856483` 精确保留 |
| `coupons.syncV2` 同步到真平台 | ✅ `sync_status=success` |
| 兑换页状态 | ✅ `redeemable`,展示真实商品名与真实支付时间 |
| **平台自动推送**(订阅后真单付款) | ✅ 6 秒到达,来源 IP `118.31.56.230`,端到端约 7 秒完成建单+发券+同步 |
| `pay_time` 字符串形态 | ⚠️ 实测发现并已修复(见 07 §4.8) |
| **核销(verifyV3)** | ✅ 真单核销成功,平台侧 `status=3 已使用 / verify_num=1` |
| **平台会改写券码** | ⚠️ 3 笔里 2 笔被平台改名 → 已修(核销改用平台侧券码 + 失败自愈,迁移 095) |
| `order.message` 发消息 | ✅ 兑换成功后已调用,无错误日志(成功日志默认被 integration 阈值 warn 挡住) |
| `messagetest` 自测入口 | ⚠️ 始终 `20006 data参数内容解析异常`,未探明其 data 要求;不影响真实链路 |
## 九点六、2026-10-14 / 10-15 增量
### 1)按 SKU 三层分流上线(`4b081d88`)
抖音与 `kuaishou-91` 同构,**只动抖音,kuaishou-91 不变**:
| 层 | 判据 | 载体 |
| --- | --- | --- |
| ① 自动发货 | 内部 SKU 命中 `AFFILIATE_DASH_SKU_MAPPING`(常量表 + 后台 `skuMapping` 增量) | `affiliate_dash` 履约任务(买家输 UID → 扫码绑定 → 自动核销) |
| ② 接单平台 | 命中接单模板(`work_product_rules`,按**内部 SKU**) | `work_orders` 工单 |
| ③ 人工 | 两者都不中 | **无载体**(无任务无工单)=待人工 |
- `sku_code` 恒为**内部 SKU**(不再被英文 sku 覆写);引流长标题只进 `snapshot.productTitle`,**永不参与匹配**。
- 未命中映射时**不设 preferred、强制覆盖 `unmatchedExecutorKey:''`**——否则会出现「人工任务 + 工单」双载体。
- 载体唯一守卫:`sync-worker-orders-service.ts:65` 命中 `classification.channel==='affiliate_dash'` 的订单项**不建工单**(reason `affiliate_dash_carrier_taken`)。
- 券 → 工单快照:`material_json.douyinMashidaVoucher`(对齐快手 `kuaishouIndustryVoucher`)。**前端有意不渲染**(打手/客服不需要看到券码)。
### 2)接单模板匹配引擎重构(`10464fea`,P0+P1)
按 [接单模板匹配引擎重构设计.md](../接单模板匹配引擎重构设计.md) 抽出 `services/product-match/`(适配器按**来源**划分:`kuaishou-91` / `pdd` / `douyin-mashida`,其中 `kuaishou-91` 同时覆盖 91 回调与快手 send-code 两条数据通道)。生产回放 6000 条订单项:`kuaishou-91`/PDD 决策 **0 差异**,抖音 5 条全为修正。旧 `work-product-match-mappers.ts` 变为门面,签名不变。
### 3)工单「发布并核销」支持抖音券(本次)
**问题**:抖音单走到接单平台后有工单,但「发布并核销」只查 `kuaishou_industry_vouchers`(抖音订单在那张表里永远是空的)→ **抖音券从未被核销**。三个入口(发布 / 指派 / 寄售履约)共用同一个函数,全部受影响。
**改法**:在唯一入口按**来源**分流,核销本身**不重复实现**。
```text
publishAdminWorkOrder / assignAdminWorkOrder / 寄售履约
└─ consumeIndustryVouchersBeforeWorkOrderPublish (publish-voucher-service.ts)
├─ 抖音(orders.provider === 'douyin_mashida')
│ └─ verifyDouyinMashidaCertificateForWorkOrderPublish (publish-douyin-voucher-service.ts)
│ ├─ 按 platform_order_id(= redeem_certificates.order_id,子单优先/父单兜底)查券
│ ├─ 委托 verifyCertificate(redeem/certificate-service.ts)→ coupons.verifyV3
│ │ (幂等 / invalid 拒核 / 未同步拒核 / 平台券码优先 / 失败对账自愈)
│ └─ 券快照 merge 进工单 material_json.douyinMashidaVoucher + 事件 publish_voucher_consumed
└─ 其它来源(kuaishou-91)
└─ 原快手电子凭证路径(listKuaishouIndustryVouchersByOid + consumeKuaishouIndustryVoucher)
```
| 文件 | 作用 |
| --- | --- |
| `worker-platform/publish-douyin-voucher-service.ts` | 抖音券核销分支(新增) |
| `worker-platform/publish-voucher-service.ts` | 顶部按来源分流(`isDouyinMashidaWorkOrder`,查 `orders.provider`) |
| `utils/voucher-consume.ts` | 前端失败提示改为「请到「抖音券核销」或快手电子凭证后台人工核销后再发货」 |
口径要点:
1. **查不到券 → 抛 409**(`work_order_publish_douyin_certificate_missing`)而不是静默成功——抖音口径是「任何订单都发券」,查不到说明发券环节没走完,需要运营去「抖音券核销」重发。
2. **券状态落库由 `verifyCertificate` 负责**,因此后台「抖音券核销」界面的状态、重复发布的幂等行为与买家兑换页完全一致(不会有「本地说没核销」的分叉)。
3. **回填工单是 best-effort**:失败只留 warn,不影响发布结果。
4. 单测用 `setOrderProviderResolverForTest` / `setDouyinVoucherDepsForTest` 注入(生产恒为 undefined,直连真实仓储与网关)。
**验证**(2026-10-15):后端 `npm run check` ✅ 740 例 0 失败(本次新增 13 例:核销成功回填/保留 material 其它字段/无券报错/真源报错转失败结果且不回填/已核销幂等/指派路径/无平台单号跳过 + 来源分流回归 3 例);前端 `npm run check` ✅ 143 例 0 失败。
### 4)其它
- `584b6f52`:问题单处置存在活跃拼单份额时,禁止取消 / 退回大厅 / 重开。
---
## 十、自检结果(2026-10-15 当时)
| 项 | 结果 |
| --- | --- |
| 后端 `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`、重复券码触发唯一键 |