refactor(product-match): 抽离接单模板匹配引擎(来源适配器/引擎/价格收口 + 后台按订单项试匹配)

新增 services/product-match:来源适配器(kuaishou-91 合成 91+快手 / pdd / douyin-mashida)+ 平台无关匹配上下文 + 条件打分引擎(并列最高=ambiguous 不猜、逐维诊断)+ 价格数量拼单唯一实现。

- 商店键命名空间化(kuaishou:/pdd:/douyin:),店铺为可选作用域:留空即可按订单信息匹配
- 抖音匹配文本只取内部 SKU,引流标题只进 promoTitle,itemTitle 恒空(修复 0.85 元误命中碎梦浮光珍宝礼盒、拼单未开)
- work-product-match-mappers 改为门面(签名不变),work-order-pricing 改为转发
- 新增后台「按订单项试匹配」接口与面板:走发单同一套链路,返回模板/数量/单价/总额/拼单
- 生产样本回放:91/PDD 6000 条决策 0 差异;抖音 5 条全为修正

设计见 docs/接单模板匹配引擎重构设计.md
This commit is contained in:
yml2213
2026-10-14 15:58:21 +08:00
parent 4b081d88c5
commit 10464fea89
26 changed files with 3218 additions and 477 deletions
+4 -3
View File
@@ -1,7 +1,7 @@
# 抖音码速达 · 总览(进度 / 代码 / 配置 / 上线)
> 更新:2026-10-09 **状态:最小接入已实现完毕,且已在真实平台跑通**(真实网关签名验证 / 真实推送订阅 / 真实 orderDetail 建单 / 真实 coupons.syncV2 同步成功),两端自检全绿
> 本文是这套内容的**入口**,具体细节在 04~09。
> 本文是这套内容的**入口**,具体细节在 04~11。
---
@@ -9,7 +9,7 @@
按 [05-最小接入方案分析.md](05-最小接入方案分析.md) 落地的**最小闭环六步全部完成**,后端 636 测试 / 前端 115 测试全绿,迁移 `094` 已在本地临时库实测通过(建表 + 幂等 + 唯一约束行为均验证)。
**没做的**:履约任务/工单接入、兑换后的实际交付物(卡密/充值)、`order.searchList` 对账。
**没做的**:**按 SKU 的三层分类**(抖音目前只有「皮肤自动发货」+「人工」两条路,缺 91/快手 的中间层 → 总设计见 [12-按SKU分类改造总设计.md](12-按SKU分类改造总设计.md),分层设计与生产实测见 [10-三层分流设计.md](10-三层分流设计.md),运营动作见 [11-接单模板待补清单.md](11-接单模板待补清单.md))、兑换后的实际交付物(卡密/充值)、`order.searchList` 对账。
**只走真实公网联调**(离线模拟已按要求删除):
@@ -205,7 +205,8 @@ git add apps/backend/src/app.ts \
| # | 缺口 | 影响 | 备注 |
| --- | --- | --- | --- |
| ~~1~~ | ~~后台前端配置面板~~ | **已完成**:`/admin/platform-shops?tab=douyinMashida` | — |
| 2 | 履约任务/工单分支 | 订单不进打手池,后台"任务"里看不到 | `product-resolution-service.ts` 未加分支(**故意**:本期不做打手) |
| 2 | 履约任务分支 | **已完成**:抖音已接入 `affiliate_dash`(皮肤/道具自动发货)+ `manual_dispatch`(人工兜底) | `product-resolution-service.ts:255-317`;但**写死了 preferred,履约路由规则对抖音无效** |
| 2b | **接单平台发单层(层②)** | 抖音单不进打手池:生产实测 11 笔全 `unmatched`、工单 0 个 | 设计 [10-三层分流设计.md](10-三层分流设计.md) / 运营 [11-接单模板待补清单.md](11-接单模板待补清单.md) |
| 3 | 兑换后的实际交付物 | 当前只「上报核销 + 展示掩码券码 + 发链接」 | 等业务定(B2) |
| 4 | `order.searchList` 对账任务 | 消息丢了就漏单 | 二期 |
| 5 | `cancelVerify` 后台按钮 | 误核销只能手工 | 出网方法已就绪 |
@@ -0,0 +1,271 @@
# 抖音码速达 · 三层分流设计(皮肤自动发货 / 接单平台 / 人工)
> 状态:**设计稿,未实施**(2026-10-14)
> 依据:生产库只读实测(接单模板 662 条)+ 全链路代码走查
> 关联:[09-皮肤领取流程.md](09-皮肤领取流程.md)、[11-接单模板待补清单.md](11-接单模板待补清单.md)
---
## 一、要解决的问题
抖音现在只有两条路:
1. 内部 SKU 命中 `AFFILIATE_DASH_SKU_MAPPING` → `affiliate_dash` 皮肤自动发货;
2. 其余 → 一律 `manual_dispatch`(人工)。
而 91/快手 是**三条路**:皮肤自动发货 / **接单平台自动发单(按接单模板建工单给打手)** / 人工兜底。本次目标:让抖音复用同一套分类,把中间那层补上。
---
## 二、91/快手 现在的三层机制(代码事实)
```mermaid
flowchart TD
A["订单项(upsertOrderFromSource)"] --> B["① 履约路由<br/>resolveFulfillmentRoute"]
A --> E["② 接单平台发单<br/>syncWorkerOrdersForSourceOrder(autoOnly)"]
B -->|"映射命中 / 规则命中 affiliate_dash"| C["executor=affiliate_dash<br/>皮肤自动发货"]
B -->|"规则指向 / 兜底"| D["executor=manual_dispatch"]
E -->|"接单模板命中且 auto_create"| F["建工单 → 打手接单"]
E -->|"未命中"| G["不建工单"]
D --> H["③ 人工处理"]
G --> H
```
### 层① 履约路由(决定「谁发货」)
| 事实 | 位置 |
| --- | --- |
| 可路由执行器**只有两个**:`affiliate_dash` / `manual_dispatch` | `apps/backend/src/services/fulfillment/routing-config-service.ts:76-81` |
| 判定顺序:指定匹配(preferred)→ 商品路由规则(商品名 / **内部SKU**)→ 全局优先级 → 未命中兜底 | 同上 `:144-253` |
| preferred 一旦可用就**直接返回**,规则层不再执行 | 同上 `:168-182` |
| `manual_dispatch` 不需要候选即可被选中(天然兜底) | 同上 `:320-328` |
| 配置入口(后台):「履约配置」页 → 商品路由规则 | `apps/frontend/src/pages/admin/platform/AdminPlatformFulfillmentPage.tsx:47-50` |
| **生产库实测(2026-10-14)**:`rules: []`(商品路由规则**一条都没配**)、`unmatchedExecutorKey: ""`、`defaultExecutorPriority: ["affiliate_dash"]` | `app_config_entries.fulfillment_routing` |
| 因为 `unmatchedExecutorKey` 是**空串**,91 未命中映射时**不建履约任务**(`isConfigured=false`,订单停在「待配置」) | `apps/backend/src/services/fulfillment/product-resolution-service.ts:217` |
| 生产库验证:有工单的 91 订单 2000 个样本中,`with_tasks = 0` → **工单单没有履约任务** | `work_orders` × `fulfillment_tasks` 只读核对 |
### 层② 接单平台发单(决定「要不要找打手」)
| 事实 | 位置 |
| --- | --- |
| 建单后**无条件**对所有 provider 调用发单同步(`autoOnly: true`) | `apps/backend/src/services/order/order-service.ts:211`、`:241` |
| 发单主流程:逐订单项匹配模板 → 命中且 `auto_create` → 建工单 | `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts:37`、`:119-226` |
| 匹配有**两条路径**:① 商品绑定(`work_product_rule_mappings`)② 旧规则(`rule.product_name` / `rule.sku_code` 对 `item.sku_name` / `item.sku_code`) | `apps/backend/src/services/worker-platform/work-product-match-mappers.ts:100-107` |
| 路径①要求 `seller_ids_json` 命中(**DB CHECK 强制非空数组**)→ 抖音没有 sellerId,**这条路径永不命中** | 同上 `:171-185`;约束 `work_product_rule_mappings_seller_ids_json_check` |
| 路径②是「无 sellerId 上下文」时唯一可用的匹配 | 同上 `:238-344` |
| provider/platform 过滤:规则留空=通配;**只有 PDD 有「复用快手模板」豁免** | 同上 `:346-372` |
| 配置入口(后台):「接单平台」页 → **接单模板**(provider 下拉已含「抖音码速达」) | `apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx`、`apps/frontend/src/utils/admin-options.ts:12` |
### 层③ 人工
`manual_dispatch` 执行器把任务置 `MANUAL_REVIEW` 并通知(`apps/backend/src/services/fulfillment/executors/manual-executor.ts:16-45`)。
### 关键结论:层① 与层② 是**同一个单子的两种执行载体**(业务上严格互斥)
⚠️ 先纠正一个容易搞错的关系:**91 单、快手、工单不是三条独立的线,本质是一个单子**。
`sync-work-orders-from-send-code.ts:5-6` 的注释就是权威口径:「**91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源**」。
一个单子的构成(生产库实测):
| 组成 | 说明 | 生产库 |
| --- | --- | ---: |
| `orders` + `order_items` | 订单骨架(91 回调权威 + 快手 send-code 补全/兜底) | 329,260 |
| `kuaishou_industry_vouchers` | 买家侧**快手电子凭证**(按 `oid` 关联) | 329,219(≈100%) |
| **执行载体(二选一)** | 工单(打手交付) **或** `fulfillment_tasks`(affiliate_dash 自动发货) | 两者交集 **0** |
| 凭证归属 | 工单自带凭证:`work_orders.material_json.kuaishouIndustryVoucher[]`(`voucherCode` / `token` / `status`) | — |
- 所以「工单」**不是**独立于订单的第三条线,而是这个单子的执行载体之一;
- 代码层**没有**「二选一」的守卫(`syncWorkerOrdersForSourceOrder` 独立运行),业务上的严格互斥由「商品集合 + 凭证归属」保证(实测 0 / 329,260);
- 抖音改造要对齐的是这个模型:**一个单子 = 骨架 + 买家侧凭证 + 一个执行载体**(抖音的凭证就是 DYC 券 + `coupons.syncV2`)。
---
## 三、生产库实测:模板现状与抖音实际结果
### 3.1 接单模板(662 条,全部 enabled / auto_create=true)
| 维度 | 实测 |
| --- | --- |
| `provider` | **全部为空**(662/662) |
| `platform` | `kuaishou` **354 条**、空 **308 条**(不是全部留空) |
| `shop_id` | **全部为空**(662/662)→ 不锁店铺 |
| `match_json` | 只有 4 条非空(全是 kuaishou) |
| `sku_code` | 只有 6 条非空(全是 kuaishou) |
| 商品绑定 | `work_product_rule_mappings` 662 条(1 模板 1 绑定),但绑定要求 sellerId |
| 商品名覆盖 | 含「和平精英」的模板 **0 条** |
**结论:模板匹配实际只靠 `product_name`。** 而抖音未命中皮肤映射的商品,其 `sku_name` 是**引流长标题**,`product_name contains` 基本匹配不上。
### 3.2 抖音单实际结果(生产库,全量 11 笔)
| 指标 | 值 |
| --- | ---: |
| `orders`(provider=douyin_mashida) | 11 |
| `order_items` | 11 |
| `work_product_match_logs.match_status = unmatched` | **11(全部)** |
| 已建工单 | **0** |
逐笔现状(含分层归属):
| 落库 sku_code | 落库 sku_name | 笔数 | 归属 |
| --- | --- | ---: | --- |
| `lucky_coin_x2` | 幸运币2个 | 3 | 层① 自动发货 ✅ |
| `suit_romantic_destiny` | 套装-浪漫天命 | 2 | 层① 自动发货 ✅ |
| `套装-牛仔情诗` | 和平精英皮肤小店入口返场集市新皮肤出售牛仔情诗 | 2 | 层③ 人工(无模板) |
| `套装-狂战萌兔` | 和平精英皮肤小店入口出售返场吃鸡皮肤狂战萌兔 | 2 | 层③ 人工(无模板) |
| `浪漫天命` | 和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命 | 1 | 层③ 人工(映射表写法不一致) |
| `突击兵密钥5个` | 和平精英指挥官密钥出售隐藏款密钥突击兵密钥碎梦浮光礼盒系列 | 1 | 层③ 人工(**本可命中模板,被 platform 挡住**) |
**模拟「放行 platform=kuaishou 模板」**:11 笔中只有 1 笔能命中 —— `突击兵密钥5个` → `rule 21「突击兵」`(kuaishou / contains)。其余 10 笔在 662 个模板里没有任何对应商品,**必须新建模板**。
### 3.3 抖音落库口径(决定「用什么键去匹配」)
`resolveOrderItemForFulfillment` 在 `selectedExecutorKey === affiliate_dash` 时会把 `skuCode` 换成英文 sku:
| 商品类型 | 落库 `sku_code` | 落库 `sku_name` |
| --- | --- | --- |
| 命中皮肤映射(层①) | **英文 sku**(`lucky_coin_x2`) | **中文内部 SKU**(`幸运币2个`) |
| 未命中映射(层②/③) | **内部 SKU**(`套装-牛仔情诗`) | **引流长标题** |
由此得出一条硬性设计结论:**层② 的匹配键只能是「内部 SKU」,即未命中映射那批的 `item.sku_code`**(`item.sku_name` 是引流标题,不可作为匹配键)。
---
## 四、目标设计
### 4.1 判定顺序
```text
订单项(provider=douyin_mashida)
├─ ① 内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING(含后台增量映射)
│ → preferred = affiliate_dash(皮肤自动发货,现状不变)
│ → 且【不参与接单平台发单】(防重复交付,见 4.3)
├─ ② 否则交给 resolveFulfillmentRoute(不再写死 preferred)
│ → 商品路由规则(商品名 / 内部SKU)→ 全局优先级 → 兜底
│ → 命中 affiliate_dash 时才自动发货;否则 executor=manual_dispatch
├─ ③ 接单平台发单(独立层,与 ①② 并行判断)
│ → 接单模板命中且 auto_create → 建工单给打手
└─ ④ 以上都不中 → 人工(MANUAL_REVIEW)
```
要点:**层①/② 决定「谁发货」,层③ 决定「要不要找打手」**,与 91 保持同一套语义。
### 4.2 与 91 对齐的两处
1. 抖音不再是「硬二分」,而是**参与同一套履约路由**;
2. 抖音的皮肤映射支持**后台增量**(与 91 一致),新增商品不必改代码。
### 4.3 防重复交付(新增硬校验)
91 靠「商品集合不重叠」侥幸避免「既建工单又自动发货」。抖音商品命名混乱(同一商品出现「套装-浪漫天命」与「浪漫天命」两种写法),必须加**显式校验**:
> 该订单项命中皮肤映射,或已存在 `executor_key='affiliate_dash'` 的履约任务 → **跳过建工单**。
判据实现位置建议:`sync-worker-orders-service.ts` 的订单项循环内、`createWorkOrder` 之前。
判定方式:`matchAffiliateDashSku(sku_name) || matchAffiliateDashSku(sku_code)`(命中映射的商品,中文内部 SKU 在 `sku_name` 上),或直接查该 `order_item_id` 的 affiliate_dash 任务是否存在。
### 4.4 开关与灰度
建议在抖音配置(`app_config_entries.douyin_mashida`)新增布尔项:
| 字段 | 默认 | 作用 |
| --- | --- | --- |
| `workerDispatchEnabled` | **false** | 抖音单是否参与接单平台发单(层③)。关闭时行为与现在完全一致 |
理由:层③ 会真实派出工单、产生打手赏金,属于业务口径变化,必须可灰度、可一键回退。
### 4.5 与 91 的第三处差异:抖音现在会「强行建人工任务」
| 维度 | 91(现状) | 抖音(现状) |
| --- | --- | --- |
| 未命中映射时 | `unmatchedExecutorKey=""` → **不建履约任务**,订单停在「待配置」;若接单模板命中则建工单 | 分支写死 `preferredExecutorKey=manual_dispatch` → **必建 `manual_dispatch` 任务(manual_review)** |
| 生产数据 | 有工单的 91 订单 2000 个样本:`with_tasks = 0`(纯工单,无任务) | 11 笔:5 笔 `affiliate_dash`(3 `link_generated` / 2 `redeemed`)+ 6 笔 `manual_dispatch` / `manual_review` |
→ 放行接单平台发单后,抖音会出现**「人工任务 + 工单」并存**,与 91 的「纯工单」语义不一致。要么接受(人工复核多一步),要么把抖音未命中时的兜底改成「不建任务、订单进待配置」。已列入 §八 未决问题。
---
## 五、改动清单(文件级)
> ✅ **实施状态(2026-10-14)**:第 **1、2、3、4** 项已落地并测试通过(类型检查 + 后端全量 712 例);第 **7、8、9、10** 项待实施。详见 [12-按SKU分类改造总设计.md](12-按SKU分类改造总设计.md)「§1.1 已实施 / §1.2 待实施」。
> - 第 4 项的实际落法:抖音**未命中时不再兜底 `manual_dispatch`**(覆盖 `unmatchedExecutorKey`),因此「未命中 = 无执行载体」,由发单层先判接单模板 → 命中即工单、都不中即待人工;载体唯一由 `snapshot.classification.channel === 'affiliate_dash'` 守卫。
| # | 文件 | 改动 | 风险 |
| --- | --- | --- | --- |
| 1 | `apps/backend/src/services/fulfillment/product-resolution-service.ts:255-317` | 抖音分支去掉写死的 `preferredExecutorKey`:映射命中才置 `affiliate_dash`,未命中交给规则/优先级/兜底(候选保留 `affiliate_dash` + `manual_dispatch`) | 低。默认行为不变(无规则时仍回落人工) |
| 2 | 同上 `:323-329` | `matchDouyinMashidaAffiliateDashSku` 改为「常量表 + `mergeAffiliateDashSkuMapping(config.skuMapping)`」,与 91 对齐(可选英文透传) | 低 |
| 3 | `apps/backend/src/services/worker-platform/work-product-match-mappers.ts:346-372` | `matchesRuleProviderPlatform` 对抖音放行(对齐 PDD 的复用豁免),使抖音可命中 `platform=kuaishou` 的 354 条模板 | **中**:会真实触发发单,需与开关同时上 |
| 4 | `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts`(循环内) | ① 抖音 `workerDispatchEnabled=false` 直接跳过;② 命中皮肤映射 / 已有 affiliate_dash 任务 → 跳过建工单 | 低(纯守卫) |
| 5 | `apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts` | 新增 `workerDispatchEnabled` 字段 + normalize + 默认 false | 低 |
| 6 | `apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts` + `apps/frontend/src/types/admin/platform-config/douyin-mashida.ts` | 配置出参 / 类型透出 | 低 |
| 7 | `apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx` | 新增「参与接单平台发单」开关 + 说明文案 | 低 |
| 8 | 文档 | 本文件 + `11-接单模板待补清单.md` | — |
> 只做 1、2、3、4 的守卫部分(不加开关)也能跑,但不建议:层③ 一旦放行会立即对存量抖音单重跑匹配。
---
## 六、风险与对策
| 风险 | 说明 | 对策 |
| --- | --- | --- |
| **误派单** | 模板用 `contains` 短名(如「突击兵」)会命中引流长标题 | 优先按 `sku_code`(内部SKU,`exact`)配模板;短名模板改成 `exact` |
| **重复交付** | 模板命中已自动发货商品 → 同时建工单 + 发货任务 | 4.3 的硬校验(必须做) |
| **存量单被补发** | 放行后历史 unmatched 的抖音单可能被重跑匹配建单 | 开关默认关;放行前后用只读 SQL 对比工单数 |
| **打手侧口径不符** | 模板的材料字段(`requirement_json.fields`)是快手/PDD 口径,抖音单未必有对应素材 | 新建抖音模板时单独确认字段 |
| **引流标题不可靠** | `sku_name` 是运营随手写的标题(同一商品两种写法已实测) | 统一以 `sku_code`(内部SKU)为准,并把命名规范回推到抖音侧商品配置 |
---
## 七、验证方法
### 7.1 只读 SQL(生产库核对,勿写)
```sql
-- 1) 抖音单是否已发单
SELECT count(*) AS douyin_work_orders
FROM work_orders w JOIN orders o ON o.id = w.order_id
WHERE o.provider = 'douyin_mashida';
-- 2) 抖音单匹配结论分布
SELECT l.match_status, count(*)
FROM work_product_match_logs l JOIN orders o ON o.id = l.order_id
WHERE o.provider = 'douyin_mashida'
GROUP BY 1;
-- 3) 模板平台分布(确认放行范围)
SELECT platform, count(*) FROM work_product_rules WHERE enabled GROUP BY 1;
-- 4) 模拟某个内部 SKU 放行后能命中哪些模板(:sku 换成抖音内部 SKU)
SELECT id, platform, product_name, match_type
FROM work_product_rules
WHERE enabled AND (
(match_type = 'exact' AND :sku = product_name) OR
(match_type <> 'exact' AND :sku LIKE '%' || product_name || '%')
);
```
### 7.2 单测范围
| 用例 | 期望 |
| --- | --- |
| 抖音内部 SKU 命中映射 + 未命中映射 两种 | `selectedExecutorKey` 分别为 `affiliate_dash` / `manual_dispatch`(无规则时) |
| 抖音商品路由规则(`internal_sku` 维度)命中 | 规则生效(现在被 preferred 短路,改后应生效) |
| 抖音单 + `workerDispatchEnabled=false` | 不建工单 |
| 抖音单 + 命中皮肤映射 | 不建工单(防重复交付) |
| 抖音单 + 命中模板 + 开关开 | 建工单,`match_status=matched` |
---
## 八、未决问题
1. **放行范围**:抖音是否允许直接命中 `platform=kuaishou` 的 354 条模板?还是要求运营复制一份 `platform` 留空/抖音的模板(数据更干净,但要人工维护 662→增量)?
2. **奖励与材料**:抖音模板的接单金额、材料字段(`requirement_json`)是否与快手同口径?打手端是否需要展示买家留言/收件人(抖音单的 buyer 信息在 `detail.buyer_words` / `post_receiver`)?
3. **映射命中但准备失败**:命中皮肤映射但 `affiliate_dash` 下单失败(余额不足等)时,是否允许回落层③发单?还是只进人工?
4. **商品命名规范**:抖音侧内部 SKU 命名(「套装-浪漫天命」vs「浪漫天命」)是否统一?这是映射漏配的根因之一。
5. **存量补单**:放行后是否要对历史 11 笔(尤其 6 笔人工单)重跑匹配?建议只对新单生效,历史单人工处理。
6. **抖音未命中时还要不要建 `manual_dispatch` 任务**(见 §4.5)?91 是「不建任务、订单进待配置」,抖音目前是「一定建人工任务」。
7. **接单模板的 platform 限制已清空**(2026-10-14 生产库执行,见 `backups/douyin-platform-clear-20261014/`):354 条原 `platform=kuaishou` 已改为留空 → 抖音单**现在就能命中**这些模板(新单生效,历史单不回溯)。是否接受「用清空 platform 代替代码放行」这一取舍,需确认。
> 已执行变更记录:354 条 `work_product_rules.platform` 由 `kuaishou` 清空(事务内 UPDATE,`updated_rows=354`,事后 `platform <> ''` 计数为 0)。原值备份在 `backups/douyin-platform-clear-20261014/`。
@@ -0,0 +1,115 @@
# 抖音码速达 · 接单模板待补清单(运营动作)
> 状态:**待运营执行**(2026-10-14)
> 数据来源:生产库只读实测(接单模板 662 条 + 抖音单 11 笔)
> 前置阅读:[10-三层分流设计.md](10-三层分流设计.md)(为什么要走接单平台、代码要改什么)
---
## 一、先记住三条匹配口径(决定模板怎么填)
1. **抖音单没有「卖家ID(sellerId)」上下文** → 「适用商品绑定」(`work_product_rule_mappings`,数据库强制要求填店铺 ID)**对抖音完全无效**。抖音只能靠**模板自身**的「商品名 / SKU编码」匹配。
2. **未命中皮肤映射的抖音商品**,`SKU编码(item.sku_code)` = **内部 SKU**(如「套装-牛仔情诗」),而「商品名(item.sku_name)」是**引流长标题**(如「和平精英皮肤小店入口返场集市新皮肤出售牛仔情诗」)。
→ **模板必须按「SKU编码 = 内部SKU」+ 匹配方式「精确」来配**,不要依赖商品名包含匹配。
3. **模板「平台」留空 = 适用所有平台**(含抖音);一旦填了 `kuaishou`,**当前代码下抖音命不中**(`matchesRuleProviderPlatform` 只对 PDD 做了跨平台豁免)。在代码放行前,抖音的模板请**务必把「平台」留空**。
> 已实测:全库 662 条模板 `provider` 全部留空、`shop_id` 全部留空,但 `platform` 有 **354 条锁死 `kuaishou`**、308 条留空。
---
## 二、需要运营补的模板(当前生产库抖音 11 笔的结论)
| 落库 SKU编码 | 商品(引流标题略) | 笔数 | 现状 | 运营待办 |
| --- | --- | ---: | --- | --- |
| `lucky_coin_x2` | 幸运币2个 | 3 | 皮肤自动发货 ✅ | **无需模板**(且以后不要给它建模板,否则会重复交付) |
| `suit_romantic_destiny` | 套装-浪漫天命 | 2 | 皮肤自动发货 ✅ | **无需模板** |
| `套装-牛仔情诗` | 和平精英…牛仔情诗 | 2 | 人工 | **新建模板**(SKU编码=`套装-牛仔情诗`) |
| `套装-狂战萌兔` | 和平精英…狂战萌兔 | 2 | 人工 | **新建模板**(SKU编码=`套装-狂战萌兔`) |
| `浪漫天命` | 和平精英…浪漫天命 | 1 | 人工 | **先确认商品口径**:该商品与 `套装-浪漫天命` 是否为同一款?若是,应修抖音商品 SKU 命名/补映射,而不是建模板 |
| `突击兵密钥5个` | 和平精英指挥官密钥…突击兵密钥… | 1 | 人工 | **已有模板 `id=21「突击兵」`(platform=kuaishou,contains)**;在代码放行跨平台前,可**复制一条「平台留空」的模板**(SKU编码=`突击兵密钥5个`,精确) |
> 本清单只覆盖**已经产生过订单**的抖音商品。抖音在售商品全集需要在放行前拉全(平台侧商品列表接口未接入,建议用后台导一次商品 SKU),按同样口径批量建模板。
---
## 三、新建模板的建议字段
以「套装-牛仔情诗」为例(其余同理):
| 字段 | 建议值 | 说明 |
| --- | --- | --- |
| 模板名称 | `抖音-套装-牛仔情诗` | 便于和快手商品区分 |
| 所属分类 | 按游戏/玩法归类(可空) | 只影响报表归类 |
| 计价方式 | 固定金额 | 与现有模板一致 |
| 接单金额 | 由运营按成本定 | 全库现有模板集中在 ¥55~¥398 区间,仅供参考 |
| 验收模式 | 标准验收 | 与快手模板保持一致 |
| 来源(provider) | **留空** | 留空即通配 |
| **平台(platform)** | **留空** | 填 kuaishou 会导致抖音命不中(放行代码前) |
| **SKU编码(sku_code)** | **`套装-牛仔情诗`(抖音内部SKU,原样复制)** | 唯一可靠的匹配键 |
| **商品名(product_name)** | 同 SKU编码(或留空) | 不要填引流长标题 |
| 匹配方式(match_type) | **精确(exact)** | 避免短名 contains 误命中 |
| 启用模板 | 开 | — |
| 自动建单 | **开** | 不开则只匹配、不建单 |
| 启用拼单 | 关(除非业务需要) | 与现有模板一致 |
| 任务时限 / 超时策略 | 按现有同类模板 | — |
| 资料字段(requirement) | **需单独确认** | 现有模板是快手/PDD 口径(如 `gameId:游戏编号`),抖音商品要交什么素材需产品确认 |
---
## 四、后台操作路径
**接单平台 → 接单模板 → 「+ 新建模板」**
1. 填「模板名称 / 所属分类 / 接单金额」;
2. 「来源」留空、「平台」**留空**;
3. 「SKU编码」填抖音**内部 SKU**(可在「订单详情 → 商品」或 `order_items.sku_code` 里取);
4. 「匹配方式」选**精确**;
5. 「启用模板」「自动建单」打开;
6. 保存后在**匹配诊断**页查看是否命中(该页会显示 `rule_not_matched` 等未命中原因)。
> ⚠️ 「适用商品绑定」那一栏对抖音**不要填**(填了也没用,抖音无 sellerId)。
---
## 五、验证(只读 SQL,放行后逐条核对)
```sql
-- 1) 某个内部 SKU 会被哪些模板命中(把 :sku 换掉)
SELECT id, platform, product_name, match_type, auto_create
FROM work_product_rules
WHERE enabled AND (
(match_type = 'exact' AND :sku = product_name) OR
(match_type <> 'exact' AND (:sku = product_name OR :sku LIKE '%' || product_name || '%' OR product_name LIKE '%' || :sku || '%'))
);
-- 2) 抖音单是否已经建出工单
SELECT w.work_order_no, w.status, w.product_name, o.platform_order_id
FROM work_orders w JOIN orders o ON o.id = w.order_id
WHERE o.provider = 'douyin_mashida'
ORDER BY w.id DESC LIMIT 20;
-- 3) 抖音单匹配结论(应逐步由 unmatched 变为 matched)
SELECT l.match_status, count(*)
FROM work_product_match_logs l JOIN orders o ON o.id = l.order_id
WHERE o.provider = 'douyin_mashida'
GROUP BY 1;
-- 4) 防重复交付自查:同订单项既有 affiliate_dash 任务又有工单(结果应为 0)
SELECT count(*)
FROM work_orders w
JOIN fulfillment_tasks ft ON ft.order_id = w.order_id AND ft.executor_key = 'affiliate_dash'
JOIN orders o ON o.id = w.order_id
WHERE o.provider = 'douyin_mashida';
```
**匹配诊断页**(接单平台 → 匹配诊断)比 SQL 更直观,可直接看单笔订单项的未命中原因。
---
## 六、红线
1. **已自动发货的商品不要建模板**(如 `幸运币2个`、`套装-浪漫天命`、`lucky_coin_x2` 系列)——否则同一单会**既派打手又自动发货**。
2. 模板「平台」在代码放行前**必须留空**。
3. 匹配键用 **SKU编码(内部SKU)+ 精确**,不要用引流长标题做 contains。
4. 抖音「浪漫天命 / 套装-浪漫天命」这类**同一商品两种写法**,先统一命名口径,再决定是补映射还是建模板。
5. 存量 11 笔(尤其 6 笔人工单)建议**人工处理不回溯**,新规则只对新单生效。
@@ -0,0 +1,432 @@
# 抖音码速达 · 按 SKU 分类改造总设计(对齐 91/快手 三层)
> 状态:**设计稿,未实施**(2026-10-14)
> 依据:全链路代码走查 + 生产库只读实测(模板 662 条 / 91 订单 32.9 万 / 抖音 11 笔)
> 前置:[10-三层分流设计.md](10-三层分流设计.md)、[11-接单模板待补清单.md](11-接单模板待补清单.md)
> 前提:**抖音侧实现可自由重构**(用户确认,无历史兼容包袱)
---
## 一、目标与结论摘要
**目标**:抖音与 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` |
**验证**:`npm run typecheck` 通过;后端全量测试 **717 例 / 714 通过 / 0 失败 / 3 跳过**;本次新增 16 例(分类判定、sku_code 不覆写、券链接 5 例、券回填 4 例、载体守卫 3 例、91 回归)。
### 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 91/快手 全链路
```mermaid
flowchart TD
A["91 下单 API(验签+参数校验)"] --> B["buildOpen91SourceEvent<br/>provider=91kaquan / platform=kuaishou<br/>skuCode=中文商品名(productNo 去 ----店铺 后缀,后续会被覆写)"]
B --> C["upsertOrderFromSource"]
C --> D["resolveOrderItemForFulfillment<br/>分类"]
D -->|"命中 AFFILIATE_DASH_SKU_MAPPING"| E["isConfigured=true → 建 affiliate_dash 任务<br/>requires_claim → claim token → 领取页"]
D -->|"未命中(rules 空 + unmatched 空串)"| F["isConfigured=false → 不建任何任务"]
C --> G["syncWorkerOrdersForSourceOrder(autoOnly)<br/>按 work_product_rules 匹配"]
G -->|命中且 auto_create| H["建工单 → 打手抢单"]
G -->|未命中| I["不建工单"]
F --> J["有买家侧凭证但无执行载体<br/>(无工单无任务),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` 去 `----<shopId>` 后缀);`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 与快手是互相配合的两个数据源,工单不是独立的第三条线**——`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<br/>skuCode=内部SKU(code) / skuName=引流长标题"]
D --> E["upsertOrderFromSource"]
E --> F["resolveOrderItemForFulfillment 抖音分支"]
F -->|"命中常量映射"| G["affiliate_dash 任务<br/>(skuCode 被覆写为英文 sku)"]
F -->|"未命中"| H["**强制** manual_dispatch 任务<br/>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 逐项差异对照
| 维度 | 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)。
@@ -0,0 +1,362 @@
# 接单模板匹配引擎重构设计
> 目标读者:后端 / 后台运营。状态:**设计草案(未实施)**。日期:2026-10-14。
>
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从当前散落在 4 个消费者、3 处平台判断、1 张"店铺必填"的绑定表里的实现,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
---
## 1. 现状盘点(全部为生产实测 / 代码实证)
### 1.1 当前链路
```mermaid
graph LR
A["91 回调"] --> ORD[("orders / order_items")]
B["快手 send-code"] --> ORD
C["PDD 同步"] --> ORD
D["抖音订单"] --> ORD
ORD --> CTX["匹配上下文构造<br/>resolveKuaishouWorkProductMatchContext"]
CTX --> ENG["打分<br/>work-product-match-mappers"]
CFG[("work_product_rules<br/>+ work_product_rule_mappings")] --> ENG
ENG --> WO["work_orders:价格 / 拼单 / 时限 / 要求字段"]
ENG --> LOG[("work_product_match_logs")]
ADM["后台:匹配测试 / 重匹配"] --> ENG
OTH["寄卖建议价 / 模板改价同步大厅"] -.-> CFG
```
### 1.2 四个消费者、各自实现
| 消费者 | 位置 | 现在怎么匹配 | 问题 |
| --- | --- | --- | --- |
| 发单建工单 | `services/worker-platform/sync-worker-orders-service.ts:48-260` | `resolveMatchingProductRuleDecision` + 数量/价格/拼单各自解析 | 触发点分散、不重算 |
| 后台自测 / 重匹配 | `services/worker-platform/admin-kuaishou-match-service.ts:301-370` | **自己拼一套 order/item payload**,`isPdd` 靠 `sellerId.includes('pdd')` 猜(:310-315) | 与生产路径两套推导 |
| 履约 / 自动发货 | `services/fulfillment/product-resolution-service.ts:207/246/484-494` | `isOpen91KuaishouOrder` / `isDouyinMashidaOrder` + 路由规则 + affiliate_dash SKU 映射 | 与模板匹配互不知晓 |
| 周边 | 寄卖建议价 `consignment-matching-service.ts:622`;模板改价同步大厅 `admin-worker-catalog-service.ts:513-545` | 各自再算一遍规格/数量/价格 | 同口径三处实现 |
### 1.3 匹配条件当前怎么表达(根因)
1. **模板行自带匹配字段**:`work_product_rules.provider / platform / shop_id / sku_code / product_name / match_type / match_json`。→ "模板名"本身就是匹配键,于是出现"名字越长分越高"(`work-product-match-mappers.ts:336-342`)。
2. **商品绑定表要求店铺非空**:`work_product_rule_mappings` 有 CHECK `jsonb_array_length(seller_ids_json) > 0`(迁移 034),且打分函数第一关就是店铺命中(`:179-185`)。→ **不配店铺就无法使用"商品绑定"这一层**,而它恰恰是唯一表达 SKU 精确/数量系列的地方。
3. **上下文只有一个平台实现**:`resolveKuaishouWorkProductMatchContext` 只读 `snapshot.kuaishouSendCode`(`:147-169`)。其它平台靠别名兜(`out_sku_sn / externalSkuName / goodsId / spec`,`:241-257、303-316`)。
4. **兜底算法会被静默启用**:`:103 useLegacyFallback = !context.sellerId || 开关`,后台 `legacyFallbackEnabled=false`(生产实测值)也拦不住。
5. **匹配入口三处触发且不重算**:`syncWorkerOrdersForSourceOrder` 被 91 回调(`platforms/ninetyone/order-service.ts:187`)、订单通用入口(`order/order-service.ts:211/241`)、快手 send-code(`sync-work-orders-from-send-code.ts:141/224`)分别调用;服务内发现已有工单即 `already_exists` 跳过(`sync-worker-orders-service.ts:111-121`)→ **先到先匹配,后到的补全数据不会纠正错误结果**(抖音 3 张单即此形态)。
### 1.4 平台耦合清单
| 位置 | 耦合内容 | 后果 |
| --- | --- | --- |
| `work-product-match-mappers.ts:360-371` | 跨平台豁免只给 PDD(`provider=pdd / platform=pdd\|kuaishou`) | 新平台要么被挡、要么误命中 |
| `work-product-match-mappers.ts:103` | 缺 sellerId 就打开旧兜底 | 后台开关失效 |
| `work-product-match-mappers.ts:306-316` | 匹配文本含 `snapshot.externalSkuName`(抖音=引流标题) | 引流词命中错误模板 |
| `admin-kuaishou-match-service.ts:310-319` | `isPdd` 靠字符串猜 | 后台自测与生产不一致 |
| `product-resolution-service.ts:484-494` | `isOpen91KuaishouOrder` / `isDouyinMashidaOrder` | 履约侧第二套平台判断 |
| `services/platforms/` | 无平台注册表(无 index) | 新平台接入靠人肉对齐 |
### 1.5 订单信息实际有什么(生产实测 2026-10-14)
| 来源 provider | 订单项数 | 有 shop_id | 店铺数 | 有 kuaishouSendCode |
| --- | --- | --- | --- | --- |
| 91kaquan | 329,677 | 100% | 8 | 252,375(76.6%) |
| pdd | 4,443 | 100% | 4 | 0 |
| douyin_mashida | 17 | 100% | 1 | 0 |
**店铺键是三套命名空间**(互不相通):
| 来源 | shop_id 样例 | 说明 |
| --- | --- | --- |
| 91kaquan | `4661340439` `141242642` `91kaquan` | 快手 sellerId;`91kaquan` 是兜底值(21,044 单);历史脏:同店 shop_name 有「向日葵游戏服务店 / 91卡券 / 快手店铺-4661340439」三种写法 |
| pdd | `xiangrikui` `mutashuyu` | PDD 店铺 code |
| douyin_mashida | `305760014` | 抖音店铺 id(当前 0 条绑定含它) |
其它可用键(按来源):
| 来源 | 平台商品ID | 平台SKU ID | 商家外部SKU编码 | 规格/内部SKU文本 | 商品标题 | 数量 |
| --- | --- | --- | --- | --- | --- | --- |
| kuaishou-91 | `snapshot.productNo` | — | `externalSkuCode` | 有 send-code 时=`skuNick`(真实规格名);否则=`sku_name`(内部商品名) | `kuaishouSendCode.itemTitle` | `order_items.quantity` / 规格文本 |
| pdd | `goods_id` | — | `out_sku_sn` | `spec` | `goods_name` | `goods_number` |
| douyin-mashida | `productId` | `skuId` / `outSkuId` | `code`(内部 SKU) | `code`(内部 SKU) | `productName`(**就是引流标题**) | `item_num`(每件=1,数量在 SKU 文本里) |
> 结论:**"不绑店铺也能匹配"在数据上是成立的**——每个来源都有 ≥1 个稳定商品键(91 商品名 / PDD out_sku_sn / 抖音 内部 SKU);店铺只是"更精确的加分条件"。
---
## 2. 目标模型
### 2.1 分层
```mermaid
graph LR
S["来源适配器<br/>kuaishou-91 / pdd / douyin-mashida"] --> M["ProductMatchContext<br/>平台无关标准字段"]
M --> E["引擎(纯函数)<br/>条件打分 + 决策"]
CFG[("模板 work_product_rules<br/>+ 匹配条目 work_product_match_entries")] --> E
E --> P["WorkOrderPlan<br/>rule / entry / 数量 / 单价 / 总额 / 拼单 / 理由"]
P --> C1["发单建工单"]
P --> C2["后台试匹配 & 重匹配"]
P --> C3["大厅改价 / 寄卖建议价"]
DEJ["来源数据补全(快手 send-code 回填等)"] --> S
```
三层职责单一:**适配器只做字段归一**、**引擎只做打分决策(无 DB、无平台判断)**、**价格/拼单只有一处实现**。
### 2.2 ProductMatchContext(平台无关)
```ts
type ProductMatchContext = {
sourceKey: 'kuaishou-91' | 'pdd' | 'douyin-mashida' | 'unknown' // 来源(适配器),不是 provider
provider: string
platform: string // 仅展示/日志用,不参与匹配
shopKeys: string[] // 命名空间化店铺键:kuaishou:4661340439 / douyin:305760014 / pdd:xiangrikui;空数组=该来源无店铺上下文
hasSellerContext: boolean // 是否有卖家上下文(快手 send-code 卖家 ID)
item: {
itemId: string // 平台商品 ID
relItemId: string // 关联商品 ID(快手 relItemId / 拼多多 goods_id)
skuId: string // 平台 SKU ID
relSkuId: string // 关联 SKU ID
skuCodes: string[] // 编码候选(外部 SKU 编码 / 平台编码)
skuNick: string // SKU 规格文本(单值,结构化条件用)
skuTexts: string[] // SKU 文本候选(文本条件用,顺序=可信度);不含引流标题
itemTitle: string // 商品标题(默认不参与,需条件显式声明;抖音恒为空)
promoTitle: string // 引流标题:只展示、永不参与匹配
}
quantity: number // 已归一(规格文本 > 订单项数量 > 1)
raw: JsonObject // 仅写日志
}
```
硬性不变式:
- 匹配**只读** `item.*` 与 `quantity`;`promoTitle` / `raw` 永不进打分。
- 每个来源必须至少提供一个稳定商品键;缺失时引擎返回 `unmatched` + 原因(不允许静默降级到别家算法)。
- **店铺、来源都是可选作用域**:配置了就必须命中(过滤),不配=不限;两者都不配也能匹配。
### 2.3 匹配条目 MatchEntry(替代"店铺必填的绑定表")
一个模板可挂 0..N 条匹配条目;**每条条件都可选,`[]` / 空串 = 不限**;不含任何条件的条目 = 通配(对应现在的 `product_default`,必须显式创建)。
| 字段 | 含义 | 可空 |
| --- | --- | --- |
| `sourceKeys` | 适用来源(`kuaishou-91` / `pdd` / `douyin-mashida`) | ✅ 空 = 全部来源 |
| `shopKeys` | 适用店铺(命名空间键,多选) | ✅ **空 = 不限店铺(本次重点)** |
| `itemIds` / `relItemIds` | 平台商品 ID / 关联商品 ID(多值 OR) | ✅ |
| `skuIds` / `relSkuIds` | 平台 SKU ID / 关联 SKU ID(多值 OR) | ✅ |
| `skuText` / `skuNick` + 匹配方式 | 规格文本:`exact` / `contains` / `series`(剥数量,兼容密钥·秘钥)/ `series-exact` | ✅ |
| `itemTitle` + 匹配方式 | 商品标题:`off` / `exact` / `contains`(默认 **off**) | ✅ |
| `qtyMin` / `qtyMax` | 数量区间(0/0 = 不限),用于"5 个以上另一档价" | ✅ |
| `baseScore` | 条目基础分(条件强度层级,由编译/模板推导,非人工填写) | ❌ |
| `priority` | 同模板内排序;越小越优先(沿用 `sort_order ASC` 语义) | ❌ |
> 决策已定:**不做「平台」维度**,平台差异由「来源」表达(`kuaishou-91` / `pdd` / `douyin-mashida`),避免同一件事两处可配。
### 2.4 打分与决策(替换"模板名越长分越高")
条件分两类:**作用域**(来源/店铺,配了就必须命中)与**判定条件**(`all` 全中、`any` 至少一个、`bonus` 命中加分)。判定条件的权重按"确定性"排序:
| 条件 | 命中得分 |
| --- | --- |
| `relSkuId` / `skuId` 精确 | 900 |
| `skuText` / `skuNick` 精确 | 800 |
| 数量系列 `series` / `series-exact` | 600 |
| `skuCode` 精确(模板编码命中) | 500 |
| `skuText` / `skuNick` 包含(旧"模板名"路径) | 400 + 具体度(`min(80, 模板名长度)`,复刻历史"更长更具体"的排序语义) |
| `relItemId` / `itemId` 精确 | 300 |
| `itemTitle` 命中 | 200(仅在条件显式配置时参与) |
| 数量区间命中 | 不满足即淘汰;满足不加分 |
条目基础分(`baseScore`)表达层级:SKU 精确映射 1000 > 数量系列映射 800 > 商品默认映射 500 > 结构化条件 300 > 纯文本条件 0。
即:**映射类条件永远压过纯文本条件**(对应旧实现里 `sku_exact(10000) > sku_series(8000) > product_default(5000) > 模板名路径(≤290)` 的层级关系)。
决策:最高分唯一 → `matched`;并列最高 → `ambiguous`(**不猜**,记日志待人工/补配置);无候选 → `unmatched`。
`unmatched` / `ambiguous` 必须落诊断:每条候选差在哪一维(`source_mismatch` / `shop_mismatch` / `quantity_below_min` / `any_not_matched:<字段:模式:值>`),后台直接可读。
### 2.5 数量、价格、拼单(单一实现)
```
quantity = 规格文本数量系列(「指挥官密钥5个」→ 5)
> 订单项数量字段(order_items.quantity)
> 1
价格 = 模板.unit_price_fen > 0 ? 单价 × quantity : 固定 reward_amount
拼单 = 模板.sharing_enabled && quantity > 1 && 验收模式非 friend_gift
;auto_from_order 时使用订单 quantity 作为份数
```
> 注意:**规格文本优先于数量字段**是抖音的硬要求(订单项 `item_num` 恒为 1,数量写在 SKU 名里),
> 因此不能反过来"显式字段优先",否则「指挥官密钥5个」会按 1 个计价。
以上合并现 `work-order-pricing.ts`,作为唯一实现,被发单/后台改价/寄卖建议价共用。
### 2.6 触发点收敛
- 匹配**只在来源数据补全之后**执行一次;补全(快手 send-code 回填、抖音详情补齐)不得触发第二次写单据。
- 幂等:同一 `order_item` 已有工单则跳过(保留现行为),但**后台"重匹配"必须能整单重跑**(删除/更新由显式操作触发)。
- 建议入口:`order_items` 落库后统一发一条"匹配任务"(事件/队列表或同步调用),发单、日志、诊断共用同一结果。
---
## 3. 数据模型改造
### 3.1 新表 `work_product_match_entries`
```sql
CREATE TABLE IF NOT EXISTS work_product_match_entries (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT NOT NULL REFERENCES work_product_rules(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 100,
source_keys JSONB NOT NULL DEFAULT '[]'::jsonb, -- 空 = 全部来源
shop_keys JSONB NOT NULL DEFAULT '[]'::jsonb, -- 空 = 不限店铺(命名空间键)
item_id TEXT NOT NULL DEFAULT '',
out_item_id TEXT NOT NULL DEFAULT '',
sku_id TEXT NOT NULL DEFAULT '',
out_sku_id TEXT NOT NULL DEFAULT '',
sku_text TEXT NOT NULL DEFAULT '',
sku_match_mode TEXT NOT NULL DEFAULT 'contains'
CHECK (sku_match_mode IN ('exact', 'contains', 'series')),
title_text TEXT NOT NULL DEFAULT '',
title_match_mode TEXT NOT NULL DEFAULT 'off'
CHECK (title_match_mode IN ('off', 'exact', 'contains')),
qty_min INTEGER NOT NULL DEFAULT 0,
qty_max INTEGER NOT NULL DEFAULT 0,
is_wildcard BOOLEAN NOT NULL DEFAULT false, -- 通配条目(无条件匹配全部),需显式勾选
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
-- 要么显式声明通配,要么至少有一个判定维度,避免"空条件条目"意外抢单
CHECK (
is_wildcard
OR source_keys <> '[]'::jsonb OR shop_keys <> '[]'::jsonb
OR out_sku_id <> '' OR sku_id <> '' OR sku_text <> ''
OR out_item_id <> '' OR item_id <> '' OR title_text <> ''
OR qty_min > 0 OR qty_max > 0
)
);
CREATE INDEX IF NOT EXISTS work_product_match_entries_lookup_idx
ON work_product_match_entries (enabled, priority, id);
```
### 3.2 兼容与迁移(不动存量数据语义)
1. **模板行字段保留只读**:`product_name / match_type / sku_code / shop_id / provider / platform` 保留,迁移脚本为每个启用模板生成一条兼容条目:
- `product_name` 非空 → 条目 A:`skuText = product_name`, `skuMatchMode = match_type`(`exact|contains`);
- `sku_code` 非空 → 条目 B:`outSkuId = sku_code`(现在只有 6 条模板配了)。
2. **4 条商品绑定 → 条目**:`seller_ids_json → shopKeys`(补 `kuaishou:` 前缀)、`mapping_type=sku_exact → outSkuId/skuText + exact`、`sku_series → skuText + series + sku_match_mode`、`rel_item_id → itemId`、`item_title → titleText(contains)`。
3. **`work_product_rule_mappings` 双写期**:新写入写新表,旧表保留一个版本用于回滚对比,确认稳定后下线。
4. **匹配日志扩展**:`work_product_match_logs` 增加 `source_key / shop_key / candidates_json(逐维得分) / decision_reason`(现有列保留)。
5. 不改 `work_orders` 结构:匹配产物(rule_id + 价格/拼单)落库口径不变。
---
## 4. 代码结构
```
apps/backend/src/services/product-match/
types.ts # ProductMatchContext / MatchEntry / MatchDecision / WorkOrderPlan
context.ts # 组装上下文(读 order_items 标准字段 + 快照别名),不含平台分支
adapters/
kuaishou-91.ts # 91 回调 + 快手 send-code 两路数据合成同一上下文(共享店铺键空间)
pdd.ts
douyin-mashida.ts # skuText=内部SKU;promoTitle=引流标题(永不参与匹配)
registry.ts # sourceKey → adapter
engine.ts # 条件打分 + 决策 + 诊断(纯函数,无 DB / 无平台判断)
pricing.ts # 数量 / 单价 / 总额 / 拼单(收编 work-order-pricing.ts)
index.ts # 对外唯一入口:resolveWorkOrderPlan(order, item, { rules, entries })
```
对外只有一个函数出口;发单、后台自测、重匹配、寄卖建议价、大厅改价全部调用它。新增来源 = `adapters/<source>.ts` + registry 注册 + fixtures,**引擎不动**。
---
## 5. 后台 UI 调整(运营视角)
- 模板表单拆成两块:**① 模板(价格/时间/要求字段/验收模式/拼单)**、**② 匹配条件(可加多条条目)**。模板名称降级为纯展示名,**不再参与匹配**。
- 匹配条目表单:来源 / 平台 / 店铺(多选,**可留空**)/ 商品ID / SKU编码 / SKU文本 + 匹配方式(精确·包含·数量系列)/ 标题匹配(默认关闭)/ 数量区间 / 优先级。
- "商品绑定"列表页改为"匹配条目"列表:显示条件摘要 + 命中统计。
- 保留并强化"试匹配":粘贴订单信息 → 选来源 → 输出决策 + 每个候选的逐维得分 + 为什么没命中(替代现在只给模板名与拒绝原因的诊断)。
- 匹配日志增加来源/店铺/决策原因筛选。
---
## 6. 分期落地与风险护栏
| 期 | 内容 | 影响面 | 预估 |
| --- | --- | --- | --- |
| P0 | 兜底开关尊重后台配置;抖音适配层:`skuText` 用内部 SKU、`shop` 用 `order.shop_id`、`itemTitle` 标为 `promoTitle` | 仅抖音 | 0.5~1 天 |
| P1 | 抽 `product-match/`(context + adapters + engine + pricing),行为等价迁移;补每来源 golden fixtures | 全来源,行为等价 | 2~3 天 |
| P2 | 后台自测/重匹配、寄卖建议价、大厅改价统一走 `resolveWorkOrderPlan`;匹配触发点收敛到"补全后一次" | 全来源 | 1~2 天 |
| P3 | 新表 + 迁移脚本 + 后台 UI(可选条件/店铺可选/条目化)+ 旧表下线 | 含数据迁移 | 2~3 天 |
护栏:
- **91 冻结**:91 走 legacy 兼容条目 + golden fixtures 全量回归(关键样本:8 家店、有/无 send-code 两类单、数量系列 1/2/5/10 个)。
- **逐来源灰度**:新引擎先只对 `douyin-mashida` 生效,pdd 次之,91 最后;每步都能一键回退到旧路径。
- **不猜**:并列最高一律 `ambiguous`,配合后台"未命中/歧义"清单驱动补配置(避免再出现"悄悄选了错模板")。
- **数据零改动**:P0/P1 不发 migration;P3 的迁移只新增表与条目,不动 `work_orders` / 历史匹配。
---
## 7. 验收标准
1. 每来源一组 fixtures:给定订单信息(快照 + 商品字段)→ 期望 `{status, ruleId, quantity, rewardAmount, sharing}`;91 的 30+ 组历史样本结果与现网一致。
2. 抖音「指挥官密钥1个 / 5个」:命中 9.5 元/个模板、数量 5 时自动开 5 份拼单(当前为 0.85 元/个、拼单关)。
3. 店铺缺失场景:清空 `shopKeys` 的条目仍能命中;`shop` 为 null 时不产生 `ambiguous`。
4. 引流标题场景:商品标题含其它商品词时,只要条目未开启标题匹配,结果不受影响。
5. 后台"试匹配"输出与生产建单结果一致(当前两者是两套推导,这是必测项)。
6. 新增来源演练:只加一个 adapter + 配置即可跑通(以 fixtures 覆盖)。
---
## 8. 实施进展
### 8.1 已完成(P0 + P1,2026-10-14)
新增模块 `apps/backend/src/services/product-match/`:
| 文件 | 职责 |
| --- | --- |
| `types.ts` | `ProductMatchContext` / `ProductMatchEntry` / `ProductMatchDecision` |
| `normalize.ts` | 文本/标识归一、数量解析、数量系列(含密钥·秘钥等价) |
| `adapters/{kuaishou-91,pdd,douyin-mashida,registry}.ts` | 三个来源适配器 + 注册表(平台差异只在这里) |
| `context.ts` | 上下文构建 + 来源文本兜底策略 + 旧投影兼容 |
| `compile.ts` | 旧「模板 + 商品绑定」→ 匹配条目(唯一的历史差异落点) |
| `engine.ts` | 条件打分 + 决策(不猜)+ 逐维诊断 |
| `pricing.ts` | 数量 / 单价 / 拼单唯一实现(`work-order-pricing.ts` 改为转发) |
| `product-match.test.ts` | 每来源 fixtures(含抖音误命中回归) |
`worker-platform/work-product-match-mappers.ts` 改为门面:函数签名不变,内部调用新引擎(发单服务、后台匹配测试/重匹配、既有 19 个匹配用例全部无需改动)。
### 8.2 已验证
- **生产数据回放(6000 条近 21 天订单项)**:91kaquan 5845 条、pdd 138 条**决策 100% 一致**(matched/unmatched 分布与命中模板 id 全部相同)。
- 抖音 5 条命中变化,全部是修正:
- `指挥官密钥1个 / 5个`(订单 334081 / 334084 / 334088):`碎梦浮光珍宝礼盒 0.85 元/个、拼单关` → **`指挥官密钥 9.5 元/个、拼单随订单开 5 份`**;
- `突击兵密钥5个`(333380 / 333932):由引流标题误命中的 `指挥官密钥` → **`突击兵`**(与快手侧同商品的判定一致)。
- `npm run check`:format / sql guard / lint / typecheck / 727 用例(0 失败)/ build 全绿。
### 8.3 待办(P2 / P3)
- **P2**:后台「试匹配 / 重匹配」与发单共用同一入口(目前已共用匹配内核,派生字段仍是两套);寄卖建议价、大厅改价改走 `pricing.ts`;匹配触发点收敛到"来源数据补全后一次",去重不再依赖"已有工单就跳过"。
- **P3**:`work_product_match_entries` 建表 + 旧配置迁移(§3.2)+ 后台条目化 UI;之后删除 `compile.ts` 的兼容编译与 `legacyFallbackEnabled` 配置项。
- **存量**:抖音误命中的 3 张工单(132001 / 132003 / 132004,均 `pending_material`、无打手接单)在新代码上线后用后台「重匹配」重跑。
---
## 附录 A:来源 → 上下文映射
| 来源 | sourceKey | shopKeys | itemId / relItemId | skuId / relSkuId | skuCodes | skuNick / skuTexts | itemTitle | promoTitle |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 91 + 快手 | `kuaishou-91` | `kuaishou:<send-code sellerId>`、`kuaishou:<order.shop_id>` | `sendCode.itemId` / `sendCode.relItemId` | `sendCode.skuId` / `sendCode.relSkuId` | `sku_code`、`externalSkuCode`、`out_sku_sn`、`externalItemId` | 有 send-code:`skuNick`;文本候选沿用旧列表 | `sendCode.itemTitle` | `productTitle` |
| PDD | `pdd` | `pdd:<order.shop_id>` | `goods_id` / `goods_id` | — / `out_sku_sn` | 同上(旧列表) | `out_sku_sn` / 旧文本列表 | `goods_name` | — |
| 抖音 | `douyin-mashida` | `douyin:<order.shop_id>` | `productId` / `outProductId` | `skuId` / `outSkuId` | `sku_code`、`externalSkuCode`、`outSkuId`、`outProductId` | `sku_code`(内部 SKU);文本候选**仅** `sku_code`(缺失时 `sku_name`) | **恒为空** | `productName`(=引流标题) |
| 未知来源 | `unknown` | `<provider>:<order.shop_id>` | 快照字段 | 快照字段 | `sku_code` | `sku_code` / `sku_name` | 快照标题 | `productTitle` |
## 附录 B:旧字段 → 新概念
| 旧 | 新 |
| --- | --- |
| `work_product_rules.product_name` + `match_type` | 匹配条目 `skuText` + `skuMatchMode` |
| `work_product_rules.sku_code` | 匹配条目 `outSkuId` |
| `work_product_rules.provider / platform / shop_id` | 匹配条目 `sourceKeys / shopKeys`(全部可空;旧的 provider/platform 残留值一律忽略) |
| `work_product_rule_mappings.seller_ids_json` | 匹配条目 `shopKeys`(命名空间键,可空) |
| `mapping_type = product_default / sku_exact / sku_series` | 条目条件组合(无 SKU 条件 / `outSkuId` 或 `skuText` exact / `skuText` series) |
| `sku_match_mode = fuzzy / exact` | `skuMatchMode = contains / exact`(+ `series` 独立成档) |
| `legacyFallbackEnabled` | **删除**(不再有第二套算法) |