# 抖音码速达 · 按 SKU 分类改造总设计(对齐 kuaishou-91 三层)
> 状态:**抖音侧已实施(2026-10-14);P2 / P3 后续项未实施**。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。
> 依据:全链路代码走查 + 生产库只读实测(模板 662 条 / `kuaishou-91` 订单 32.9 万 / 抖音 11 笔)
> 前置:[10-三层分流设计.md](10-三层分流设计.md)、[11-接单模板待补清单.md](11-接单模板待补清单.md)
> 前提:抖音侧实现可自由重构(无历史兼容包袱);**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道,不拆成两个来源**
---
## 一、目标与结论摘要
**目标**:抖音与 `kuaishou-91` 用同一套「按 SKU 分类」逻辑,产出三种结果:
| 层 | 触发条件 | 产物 |
| --- | --- | --- |
| ① 皮肤/道具自动发货 | SKU 命中 affiliate_dash 映射表 | `affiliate_dash` 履约任务 + 领取链接 |
| ② 接单平台发单 | SKU 命中接单模板(`work_product_rules`) | 工单(打手抢单),**不建履约任务** |
| ③ 人工 | 以上都不命中 | **不建任务、不建工单**,进「待人工」队列 |
⚠️ 这三层**不是三条独立业务线**,而是**同一个单子选择哪种执行载体**(见 §2.1「一个单子的构成」):一个单子 = 订单骨架 + 买家侧凭证 + **一个**执行载体。
**结论(需要改的不是「加一层」,而是三件事)**:
1. **分类键不稳定**:现在 SKU 命中自动发货后,`order_items.sku_code` 会被**覆写成英文 sku**,导致「按 SKU 分类」失去稳定依据 → 必须确立规范化的 canonical SKU 并保证不被覆写;
2. **抖音绕过了分类框架**:抖音分支写死 `preferredExecutorKey`,既不认履约路由规则,也未命中时**强制**建 `manual_dispatch` 人工任务(91 是「不建任务」);
3. **人工层没有载体**:91 的「待配置队列」链路实际已成死代码(见 §3.4),人工单只是「裸单」;抖音连裸单标记都没有 → 需要一个显式的「待人工」状态/队列。
### 1.1 已实施(2026-10-14,抖音侧)
| # | 改动 | 位置 |
| --- | --- | --- |
| 1 | 抖音 `sku_code` **恒为内部 SKU**(不再被英文 sku 覆写);英文 sku 落 `snapshot.affiliateDash.sku`;引流标题落 `snapshot.productTitle` | `apps/backend/src/services/fulfillment/product-resolution-service.ts`(`resolveOrderItemForFulfillment`) |
| 2 | 抖音分级判定重写:命中映射 → `affiliate_dash`;未命中 → **不设 preferred、且不兜底 `manual_dispatch`** → 无执行载体 | 同上(`resolveDouyinMashidaItemCandidate`) |
| 3 | 映射来源改为「权威常量表 + 后台 `skuMapping` 增量」(新增商品不必发版) | 同上(`matchDouyinMashidaAffiliateDashSku`) |
| 4 | 写 `snapshot.classification = { channel, canonicalSku, matchMode }` | 同上 |
| 5 | **载体唯一守卫**:分类为 `affiliate_dash` 的订单项不再建工单 | `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts` |
| 6 | 券 `cert_link` 按基址形态决定(`/h5` 一律按订单号拼;其它形态有 token 用 token、否则回退 `certLink`)——**不再依赖是否命中自动发货** | `apps/backend/src/services/redeem/certificate-service.ts` |
| 7 | **抖音券写入工单 `material_json.douyinMashidaVoucher`**(对齐快手把凭证挂工单):① 发券后回填;② 建工单时券已存在则直接带上(覆盖「补模板后重新匹配」) | 同上 + `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts` |
**验证(2026-10-14 当时)**:`npm run typecheck` 通过;后端全量测试 **717 例 / 714 通过 / 0 失败 / 3 跳过**;本次新增 16 例(分类判定、sku_code 不覆写、券链接 5 例、券回填 4 例、载体守卫 3 例、91 回归)。2026-10-17 复核为 740 项(737 pass / 3 skipped)。
### 1.2 业务口径(2026-10-14 确认)与后续
| 项 | 决定 | 落法 |
| --- | --- | --- |
| A 「待人工」队列 / 后台筛选 | **测试阶段不做**,人工直接处理 | 未命中单 = 无任务无工单,维持现状 |
| B 券的发放 | **任何订单都要发券,与是否命中无关** | 发券逻辑不动(所有抖音单都发券 + `syncV2`);`cert_link` 也解耦命中判据(见 §1.1 第 6 项) |
| C 券 → 工单 | **写入工单 `material_json.douyinMashidaVoucher`** | 发券后回填 + 建单时带入,两条路径都覆盖 |
**仍需运营/产品**:抖音内部 SKU 命名规范 + 按 SKU 补接单模板(见 [11-接单模板待补清单.md](11-接单模板待补清单.md))。
---
## 二、现状全链路梳理
### 2.1 kuaishou-91 全链路
```mermaid
flowchart TD
A["91 下单 API(验签+参数校验)"] --> B["buildOpen91SourceEvent
provider=91kaquan / platform=kuaishou
skuCode=中文商品名(productNo 去 ----店铺 后缀,后续会被覆写)"]
B --> C["upsertOrderFromSource"]
C --> D["resolveOrderItemForFulfillment
分类"]
D -->|"命中 AFFILIATE_DASH_SKU_MAPPING"| E["isConfigured=true → 建 affiliate_dash 任务
requires_claim → claim token → 领取页"]
D -->|"未命中(rules 空 + unmatched 空串)"| F["isConfigured=false → 不建任何任务"]
C --> G["syncWorkerOrdersForSourceOrder(autoOnly)
按 work_product_rules 匹配"]
G -->|命中且 auto_create| H["建工单 → 打手抢单"]
G -->|未命中| I["不建工单"]
F --> J["有买家侧凭证但无执行载体
(无工单无任务),order_status=paid"]
```
| 环节 | 事实 | 位置 |
| --- | --- | --- |
| 进单 | `createOpen91Order`:验签 → 号码校验 → `buildOpen91SourceEvent` | `apps/backend/src/services/open-91/order-create-service.ts:26`、`apps/backend/src/services/platforms/ninetyone/order-service.ts:28` |
| SKU 口径 | 分类键 = **商品名**(`productNo` 去 `----` 后缀);`sku_code` 有三种形态、**不可作分类键**(见 §3.3) | `ninetyone/order-service.ts:62`、`parseOpen91ProductNo:91` |
| 分类 | 规则表为空、`unmatchedExecutorKey=''`、优先级 `[affiliate_dash]` → **只有映射命中才有 executor** | `app_config_entries.fulfillment_routing`(生产库实测) |
| 任务 | 仅 `configuredOrderItems`(即命中映射的项)建任务;`affiliate_dash` 走 `requires_claim=true` | `services/order/order-service.ts:233-236`、`services/order/delivery-task-service.ts:55` |
| 发单 | **无条件**对全部订单项跑接单模板匹配(`autoOnly:true`) | `services/order/order-service.ts:211`、`:241` |
| 电子凭证门禁 | 用 `oid=platform_order_id` 查快手电子凭证;未确认 → 暂停建任务,**但工单照建** | `services/fulfillment/order-fulfillment-readiness-service.ts:17` |
| 领取 | 仅 ① 层有 claim token → 领取页(输 UID → 绑定 → 发货) | `services/claim/claim-service.ts:152` |
| 后台 | 91 平台配置页有「待配置」列表 + 重试/标记失败 | `services/platforms/ninetyone/order-service.ts:199`(`retryOpen91Order`)、`:222`(`failOpen91Order`) |
**生产库实测(2026-10-14)**:
| 指标 | 数量 |
| --- | ---: |
| 91 订单 | 329,260 |
| └ 有快手电子凭证 | 329,219(**≈100%**) |
| └ `affiliate_dash` 任务 | 46,103(另 `kuaishou_ct_assisted` 46,894 / `kuaishou_feifei` 7,007) |
| └ 接单平台工单 | 126,537 |
| └ **工单 ∩ 任务** | **0(严格互斥)** |
| └ 既无任务也无工单 | 102,674(8 月 46,329 / 9 月 37,713 / 10 月 18,631;近 10 天约 1,000/天) |
| 后者的 `order_status` | 102,648 = `paid`,25 = `pending_config`,1 = `manual_failed` |
| 后者的快照 `isConfigured` | 近 10 天 10,208 项**全部 false** |
#### 一个单子的构成(关键认知)
**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道,工单不是独立的第三条线**——`sync-work-orders-from-send-code.ts:5-6` 注释即权威口径:「91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源」。两者共享同一店铺键空间与同一套匹配上下文,**不是两个来源**。
| 组成 | 说明 |
| --- | --- |
| `orders` + `order_items` | 订单骨架(91 回调权威 + 快手 send-code 补全/兜底) |
| `kuaishou_industry_vouchers` | 买家侧**快手电子凭证**(按 `oid` 关联;≈100% 订单都有) |
| **执行载体(二选一,实测交集 0)** | 工单(打手交付) **或** `fulfillment_tasks`(affiliate_dash 自动发货) |
| 凭证归属 | 工单自带凭证:`work_orders.material_json.kuaishouIndustryVoucher[]`(`voucherCode` / `token` / `status` / 有效期) |
→ 因此那 102,674 笔的准确说法是:**有买家侧凭证,但既没派打手、也没走自动发货**的裸单(需要显式人工队列,见 §4.3),而不是「另一条业务线」。
### 2.2 抖音 全链路(现状)
```mermaid
flowchart TD
A["trade_TradePaid 推送"] --> B["验签 + webhook_events 幂等"]
B --> C["order.orderDetail 拉详情"]
C --> D["buildDouyinMashidaSourceEvent
skuCode=内部SKU(code) / skuName=引流长标题"]
D --> E["upsertOrderFromSource"]
E --> F["resolveOrderItemForFulfillment 抖音分支"]
F -->|"命中常量映射"| G["affiliate_dash 任务
(skuCode 被覆写为英文 sku)"]
F -->|"未命中"| H["**强制** manual_dispatch 任务
manual_review(无人工队列)"]
E --> I["syncWorkerOrdersForSourceOrder(autoOnly)"]
I --> J["实测 11 笔全 unmatched → 0 工单"]
E --> K["每个订单项都发 DYC 券 + coupons.syncV2"]
G --> L["付款后私信领取链接(仅①层)"]
H --> M["券 cert_link 兜底指 /h5"]
```
| 环节 | 事实 | 位置 |
| --- | --- | --- |
| 进单 | 推送 → `orderDetail` 补齐 → `upsertOrderFromSource` | `services/platforms/douyin-mashida/notify-service.ts:112-229` |
| SKU 口径 | `skuCode` = 内部 SKU(`code`/`out_sku_id`/`product_id`),`skuName` = 引流长标题 | `douyin-mashida/order-mapper.ts:151-165` |
| 分类 | 抖音分支**写死** `preferredExecutorKey`:命中映射→`affiliate_dash`,否则→`manual_dispatch`;候选只有这两个 | `services/fulfillment/product-resolution-service.ts:255-317` |
| 映射来源 | 只查代码常量表 `AFFILIATE_DASH_SKU_MAPPING`(不走后台增量、无英文透传) | 同上 `:323-329` |
| `sku_code` 污染 | 选中 `affiliate_dash` 后 `skuCode` 被替换为**英文 sku**,`skuName` 被替换为**中文内部 SKU** | 同上 `:89-102` |
| 发单 | 与其他 provider 同样调用,但被 platform 过滤挡住 | `work-product-match-mappers.ts:346-372`(**已于 2026-10-14 清空 platform,解除**) |
| 券码 | **所有**订单项都发券 + `syncV2`;`cert_link` 优先 claim 链接、否则 `certLink`(含 `/h5` 捷径) | `services/redeem/certificate-service.ts:61`、`:137-180` |
| 核销 | 领取完成 best-effort `verifyV3`;`/redeem` 支持手动输订单号核销 | `services/claim/kuaishou-cloud-claim-service.ts:529`、`services/redeem/redeem-service.ts:53` |
**生产库实测(11 笔)**:命中映射 5 笔(3 `link_generated` / 2 `redeemed`,均为 `lucky_coin_x2`、`suit_romantic_destiny`)+ 未命中 6 笔(`manual_dispatch` / `manual_review`);接单模板匹配 **11 笔全 unmatched、0 工单**。
### 2.3 逐项差异对照
| 维度 | `kuaishou-91` | 抖音(现状) | 改造方向 |
| --- | --- | --- | --- |
| 分类键 | 中文商品名(`productNo`) | 内部 SKU(`code`) | 统一为规范化 **canonical SKU** |
| `sku_code` 落库 | 命中映射后被覆写成英文 | 同左(未命中时才是内部 SKU) | **不再覆写**,英文 sku 只落 snapshot |
| 分类框架 | 走 `resolveFulfillmentRoute`(规则+优先级+unmatched) | **写死 preferred,绕过框架** | 复用同一框架 |
| 未命中兜底 | 不建任务(裸单) | **强制建 `manual_dispatch` 任务** | 显式「待人工」,不建任务 |
| 接单平台 | 命中 126,537 工单 | 0(已清空 platform,待新单验证) | 按 SKU 对齐商品名/编码 |
| 人工队列 | 有 UI 但链路已成死代码 | 无 | 新增显式状态 + 队列 + 重试 |
| 券码 | 同一个单子的**买家侧快手电子凭证**(挂在任务/工单上,≈100% 订单都有) | 自有 `redeem_certificates` + `syncV2`(等价于抖音版买家侧凭证) | 按分类决定是否发券 / 链接形态 |
| 领取链接 | 命中映射的才有 | 同左(人工单已修不再误发) | 保持 |
| 电子凭证门禁 | 有(发码回调未确认先暂停) | 无(抖音无此概念) | 不适用 |
---
## 三、生产库关键事实(改造的前提)
### 3.1 接单模板(662 条)
| 维度 | 实测 |
| --- | --- |
| `provider` | 全部为空(通配) |
| `platform` | **原 354 条 `kuaishou`,已于 2026-10-14 清空**(现 662 条全通配) |
| `shop_id` | 全部为空 |
| `sku_code` | 仅 6 条有值(全是原 kuaishou 批次) |
| `match_json` | 仅 4 条有值 |
| 商品绑定 | 662 条(1:1),但 `seller_ids_json` 有**非空 CHECK** → 抖音无 sellerId → 这条路径对抖音**永远不可用** |
→ 抖音的模板匹配**只能走「规则自身的 `sku_code` / `product_name`」**(`work-product-match-mappers.ts:303-343`)。
### 3.2 履约路由配置(生产库)
```json
{ "rules": [], "enabled": true,
"executors": { "affiliate_dash": {"enabled": true}, "manual_dispatch": {"enabled": true} },
"unmatchedExecutorKey": "", "defaultExecutorPriority": ["affiliate_dash"] }
```
→ 91 未命中时**选不出执行器**(`isConfigured=false`)→ 不建任务。抖音若照抄此配置语义,就天然等于「未命中不建任务」。
### 3.3 落库口径(决定匹配键)
**91(生产库实测,329,248 个订单项)** —— `sku_code` 有三种形态,**不可作为分类键**:
| 商品类型 | `sku_code` | `sku_name` |
| --- | --- | --- |
| 命中映射(①层) | **英文 sku**(`suit_romantic_destiny`,如 330 笔) | 中文商品名(`套装-浪漫天命`) |
| 接口直接进单(走工单) | 91 `productNo`(中文商品名) | 中文商品名 |
| 被快手 send-code 补全后 | **平台数字 ID**(`189873094508762`,占 **244,322/329,248 = 74%**) | 中文商品名(`1个精致荣耀专属礼盒`) |
**抖音**:
| 商品类型 | `sku_code` | `sku_name` |
| --- | --- | --- |
| 命中映射(①层) | **英文 sku**(`lucky_coin_x2`) | **中文内部 SKU**(`幸运币2个`) |
| 未命中(②③层) | **内部 SKU**(`套装-牛仔情诗`) | **引流长标题**(`和平精英皮肤小店入口…`) |
→ 结论:
- **91 的分类键只能是「商品名」**(`sku_name` / `productNo`)——这正是 662 条接单模板**全部用 `product_name` 匹配**的原因;
- **抖音的分类键是「内部 SKU」**(未命中映射时落在 `sku_code` 上);
- 所以 canonical SKU 必须**按来源分别取**,**不能一律取 `sku_code`**;抖音「命中后被覆写」的问题必须修掉。
### 3.4 91 的「待配置队列」是死代码(重要)
- 类型里有 `UpsertOrderIgnoredResult`(`order-service.ts:63-69`),但**运行时从不返回 `ignored: true`**(全仓库仅出现在该类型定义处);
- `createOpen91Order` 的 `if (result.ignored) → upsertOpen91PendingOrder()`(`services/open-91/order-create-service.ts:63`)因此**永不触发**;
- 生产库仅 25 笔 `pending_config`(全部 2026-08-08~08-11,早期版本产物);
- 结论:**91 未命中的订单现在既不建任务、也不进队列**,只是 `paid` 状态的裸单(102,674 笔)。
→ 抖音改造时**不要复用这套死链路**,应新建显式的人工状态与队列。
---
## 四、目标模型:按 SKU 的三层分类
### 4.1 统一分类键(canonical SKU)
```text
canonicalSku(按来源分别取,禁止一律取 sku_code)
91 :商品名 —— sku_name / productNo(如「1个精英荣耀专属礼盒」「套装-浪漫天命」)
抖音 :内部 SKU —— sku_order_list[].code(如「幸运币2个」「套装-牛仔情诗」)
归一化:NFKC → 去所有空白 → 小写(复用 normalizeInternalSkuMatchKey)
```
落库约定(**核心改动**):
| 字段 | 约定 |
| --- | --- |
| `order_items.sku_code` | **仅抖音**:恒等于 canonicalSku(不再被执行器覆写);91 保持现状(`sku_code` 三种形态见 §3.3,其分类键取 `sku_name`) |
| `order_items.sku_name` | 可读商品名(抖音取内部 SKU;引流标题另存 `snapshot.productTitle`) |
| `order_items.item_snapshot_json.classification` | 新增:`{ channel, canonicalSku, matchedRuleId, matchReason, decidedAt }` |
| `item_snapshot_json.affiliateDash.sku` | 英文 sku 只落这里(+ `resolvedSkuCode`) |
### 4.2 判定顺序(抖音与 91 共用)
```text
输入:orderItem(canonicalSku, 商品名)
① 皮肤/道具自动发货
canonicalSku 命中 AFFILIATE_DASH_SKU_MAPPING(常量表 + 后台 skuMapping 增量)
→ executor=affiliate_dash;requires_claim;建 claim token;付款后发领取链接
→ 明确标记:不再参与接单平台发单(防重复交付)
② 接单平台发单
canonicalSku / 商品名 命中 work_product_rules(enabled + auto_create)
→ 建工单;**不建履约任务**;不发领取链接
(是否发券/券链接形态见 §4.4)
③ 人工
都不命中
→ isConfigured=false(不建任务);也不建工单
→ 标记 classification.channel='manual',进「待人工」队列(§4.3)
```
与 91 的对应关系:①②③ 完全同构;差别只在 ③ 由「裸单」升级为「显式待人工」。
### 4.3 人工层(③)的载体
| 设计点 | 方案 |
| --- | --- |
| 订单状态 | **不污染 `order_status`**(保持真实 `paid`),用 `order_items.snapshot.classification.channel='manual'` 表达 |
| 队列判据 | 「无履约任务 且 无工单」= 待人工(可直接 SQL 筛,见 §7.3) |
| 后台入口 | 订单列表/抖音配置页新增筛选「待人工」+ 单笔「重新匹配」按钮(复用 `POST /api/v1/admin/worker-platform/orders/reprocess-matches`) |
| 重试语义 | 运营补好映射/模板后,重跑分类即可建任务或工单 |
| 通知 | 复用 `notifyOpen91PendingConfig` 类似机制给运营/群机器人告警(可选) |
> 若产品希望与 91 的 `pending_config` 字面对齐,也可用 `order_status='pending_config'`,但会污染真实支付状态,**不推荐**。
### 4.4 券码与领取页按分类分流(需产品决策)
| 层 | 是否发券给平台 | `cert_link` | 领取页 |
| --- | --- | --- | --- |
| ① 自动发货 | 是(现状) | claim token 链接(现状) | 输 UID → 绑定 → 发货 |
| ② 接单平台 | **决策项** | 建议:不发券或指「人工/客服」页 | 不做领取交互 |
| ③ 人工 | **决策项** | 同上 | 现状已显示「人工处理中,请等待客服」 |
现状问题:②③ 也会发券且 `cert_link` 走 `/h5`(`certificate-service.ts:151` 的 `/h5` 捷径),买家点进去看到「人工处理中」,属误导。
---
## 五、改造方案(抖音侧,可自由重构)
### 5.1 数据层:canonical SKU 不再被覆写
- `resolveOrderItemForFulfillment`(`product-resolution-service.ts:69-137`):`skuCode` 不再随执行器替换;英文 sku 只写 `snapshot.affiliateDash.sku` 与 `snapshot.resolvedSkuCode`。
- **只对抖音生效**(91 现状不动,避免影响 32.9 万历史与线上逻辑):在函数内按 `isDouyinMashidaOrder(provider, platform)` 分支处理。
- 影响面需同步检查:`planner.buildFulfillmentTaskContext`(用 `skuCode/skuName`)、工单 `productName`(用 `sku_name`)、`redeem-service.buildSummary`、后台展示。
### 5.2 分类判定:抖音接入统一框架
改写 `resolveDouyinMashidaItemCandidate`(`product-resolution-service.ts:255-317`):
```ts
// 伪代码
const canonicalSku = 内部SKU
const dashSku = matchAffiliateDashSku(mergeAffiliateDashSkuMapping(config.skuMapping), canonicalSku)
if (dashSku) {
→ candidates=[{affiliate_dash, available:true}], preferred=affiliate_dash
} else {
→ candidates=[{affiliate_dash, available:false}],**不设 preferred**
// 让 resolveFulfillmentRoute 走规则/优先级/unmatched(当前配置下 unmatched='' → 不选执行器)
}
// 分类结果写 snapshot.classification
```
要点:
- 不再写死 `manual_dispatch` → 未命中=不建任务(与 91 一致);
- 顺带修好「后台履约路由规则对抖音无效」的问题(规则含 `internal_sku` 维度);
- 映射查找改为「常量表 + 后台增量」(对齐 91,新增商品不必发版)。
### 5.3 接单平台层对齐
| 事项 | 做法 |
| --- | --- |
| platform 限制 | **已执行**:清空 354 条 `platform=kuaishou`(2026-10-14),抖音现在可命中 |
| 匹配键 | 运营按 `sku_code = 内部 SKU` + `match_type=exact` 建模板(见 [11-接单模板待补清单.md](11-接单模板待补清单.md)) |
| provider 限制 | 已全为空(通配),无需改 |
| 代码侧 | 建议在 `matchesRuleProviderPlatform` 增加注释/白名单说明「抖音依赖 platform 留空」,避免后人又填回去 |
| **防重复交付(必做)** | 命中映射的订单项 → 跳过建工单(`sync-worker-orders-service.ts` 循环内加守卫,判据:`matchAffiliateDashSku(sku_code)` 或已存在 `executor_key='affiliate_dash'` 的任务) |
### 5.4 人工层落地
- `order_items.snapshot.classification.channel='manual'`(判定时写入);
- 后台订单列表加筛选项「待人工(无任务无工单)」;
- 单笔/批量「重新匹配」入口(复用现有 `reprocess-matches`);
- 可选:运营告警(同 `notifyOpen91PendingConfig`)。
### 5.5 券码与领取页
- ① 层:保持现状(有 token 才发领取链接、完成时自动核销);
- ②③ 层:按 §4.4 的产品决策实施(建议不发券 or 链接指人工页),并**去掉 `certificate-service.ts:151` 的 `/h5` 捷径**(它是历史遗留,会让人工单拿到领取页链接)。
### 5.6 可观测
- 分类结果落 `snapshot.classification`;
- 日志统一前缀 `[douyin-mashida/classify]`,含 `canonicalSku / channel / matchedRuleId / reason`;
- 后台「匹配诊断」页已能看工单侧未命中原因,补充抖音分类结果展示。
### 5.7 存量数据
- 不回溯历史 `sku_code`(已落库的英文 sku 保留);
- 存量 11 笔抖音单(6 笔人工)人工处理;
- 放行后只对新单生效。
---
## 六、改动清单(文件级)
| # | 文件 | 改动 | 风险 |
| --- | --- | --- | --- |
| 1 | `services/fulfillment/product-resolution-service.ts:69-137` | 抖音:`skuCode` 不再被覆写(canonical SKU 恒为内部 SKU) | 中(需同步检查展示/任务/工单) |
| 2 | `services/fulfillment/product-resolution-service.ts:255-317` | 抖音分类改写:不写死 preferred;未命中不选执行器;写 `snapshot.classification` | 低 |
| 3 | `services/fulfillment/product-resolution-service.ts:323-329` | 抖音映射查找加后台增量(`mergeAffiliateDashSkuMapping`) | 低 |
| 4 | `services/worker-platform/sync-worker-orders-service.ts`(循环内) | 防重复交付守卫:命中映射/已有 affiliate_dash 任务 → 跳过建工单 | 低(纯守卫) |
| 5 | `services/worker-platform/work-product-match-mappers.ts:346-372` | 注释/白名单明确「抖音依赖 platform 留空」;可选按 `sku_code` 提升匹配优先级 | 低 |
| 6 | `services/order/order-service.ts` | 分类结果透传(`classification` 写入 snapshot 已完成在 #1/#2);确认未配置时不再返回误导性的 `ignored` 语义 | 低 |
| 7 | `services/platforms/douyin-mashida/source-config-service.ts` | 新增开关:`workerDispatchEnabled`(默认 false)、`claimMessageEnabled` 已有 | 低 |
| 8 | `services/redeem/certificate-service.ts:137-180` | 去掉 `/h5` 捷径;按 `classification.channel` 决定 `cert_link`(或是否发券) | **中**(影响买家链接) |
| 9 | 后台(`AdminOrdersPage` / 抖音配置面板) | 「待人工」筛选 + 重新匹配入口 + 分类结果展示 | 低 |
| 10 | 后台(`AdminPlatformFulfillmentPage`) | 履约路由规则说明补充「抖音按内部 SKU 匹配」 | 低 |
| 11 | 文档 | 本文件 + 更新 10/11 号文档 | — |
---
## 七、测试计划
### 7.1 单测(新增/修改)
| 用例 | 期望 |
| --- | --- |
| 抖音 SKU 命中映射 | `sku_code` = 内部 SKU(**不被覆写**);`snapshot.affiliateDash.sku` = 英文 sku |
| 抖音 SKU 未命中映射 | `isConfigured=false` → **不建任务**(无 `manual_dispatch`) |
| 抖音 SKU 命中接单模板 | 建工单,且**不建履约任务** |
| 抖音 SKU 命中映射 + 同时命中模板 | **不建工单**(防重复交付) |
| 抖音履约路由规则(internal_sku)命中 | 规则生效(现在被 preferred 短路) |
| `workerDispatchEnabled=false` | 不建工单 |
| 91 回归 | 现有行为完全不变(sku_code 覆写保留) |
### 7.2 端到端(联调/灰度)
用 `scripts/douyin-mashida-push.ts` 造三类 SKU 的付款消息 → 校验:任务/工单/券/私信 四类产物与分类一致。
### 7.3 只读核对 SQL
```sql
-- 分类分布(改造后新单)
SELECT oi.item_snapshot_json::jsonb->'classification'->>'channel' AS channel, count(*)
FROM order_items oi JOIN orders o ON o.id = oi.order_id
WHERE o.provider = 'douyin_mashida' GROUP BY 1;
-- 待人工队列(无任务无工单)——91 现状 102,674 笔
SELECT count(*) FROM orders o
WHERE o.provider = 'douyin_mashida'
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_id = o.id)
AND NOT EXISTS (SELECT 1 FROM work_orders w WHERE w.order_id = o.id);
-- 防重复交付自查(应为 0)
SELECT count(*) FROM work_orders w
JOIN orders o ON o.id = w.order_id
JOIN fulfillment_tasks ft ON ft.order_id = o.id AND ft.executor_key = 'affiliate_dash'
WHERE o.provider = 'douyin_mashida';
```
---
## 八、灰度与上线
1. **代码合并但不打开开关**:新分类逻辑上线,`workerDispatchEnabled=false` → 抖音行为 = 「命中映射自动发货 / 否则待人工」,不建工单;
2. **验证人工层**:确认未命中单不再产生 `manual_dispatch` 任务,且能进「待人工」筛选;
3. **打开发单开关**(小批商品先配模板)→ 观察工单量与匹配诊断;
4. **全量放开**:补齐抖音商品模板(见 11 号文档);
5. 任一步异常:关开关即可回到第 1 步状态。
---
## 九、未决问题(需产品/运营确认)
1. **②③ 层是否发券给抖音平台**?(现状:全发;若不发会影响买家在抖店看到的卡券入口)
2. **②③ 层的 `cert_link` 指向**:`/redeem`(买家自助兑换)还是「客服/人工」页?
3. **人工层表达**:显式 `classification.channel='manual'`(推荐)还是沿用 91 的 `order_status='pending_config'`?
4. **91 侧是否同步整改**:91 的 102,674 笔裸单 + 死掉的待配置链路要不要一起修(本次设计对 91 只做「不改」,但问题客观存在)?
5. **打手材料字段口径**:抖音模板的 `requirement_json` 需交什么素材(买家留言 `buyer_words`?UID?);
6. **抖音内部 SKU 命名规范**:同一商品出现过「套装-浪漫天命」与「浪漫天命」两种写法,需回推抖音侧统一。
---
## 十、附:运营配置清单(配合 §5.3)
1. 接单模板按 **`sku_code = 抖音内部 SKU` + 精确匹配** 新建(现 662 条里只有 6 条配了 `sku_code`);
2. 已自动发货的商品(`幸运币2个`、`套装-浪漫天命` 等)**不要建模板**;
3. 平台/来源一律**留空**(现已全空,勿再填);
4. 「适用商品绑定」对抖音无效(缺 sellerId),勿依赖;
5. 详细逐商品清单见 [11-接单模板待补清单.md](11-接单模板待补清单.md)。