# 接单模板匹配引擎重构设计 > 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。 > > 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。 > > **来源口径(2026-10-17 确认)**:全系统只有三个匹配来源——`kuaishou-91`、`pdd`、`douyin-mashida`。其中 **91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**:91 是订单骨架权威源,快手 send-code 是补全/兜底源;它们共享店铺键空间,**不是两个来源,也不要拆成两个适配器**。 --- ## 1. 现状盘点(全部为生产实测 / 代码实证) ### 1.1 当前链路 ```mermaid graph LR A["kuaishou-91
91 回调(骨架)+ 快手 send-code(补全)"] --> ORD[("orders / order_items")] C["PDD 同步"] --> ORD D["抖音订单"] --> ORD ORD --> CTX["匹配上下文构造
resolveKuaishouWorkProductMatchContext"] CTX --> ENG["打分
work-product-match-mappers"] CFG[("work_product_rules
+ work_product_rule_mappings")] --> ENG ENG --> WO["work_orders:价格 / 拼单 / 时限 / 要求字段"] ENG --> LOG[("work_product_match_logs")] ADM["后台:匹配测试 / 重匹配"] --> ENG OTH["寄卖建议价 / 模板改价同步大厅"] -.-> CFG ``` ### 1.2 四个匹配消费点(不是四个来源) > 这里的“四个”是**代码里曾经各自实现匹配的消费点**,不是订单来源数量。订单来源始终只有 `kuaishou-91 / pdd / douyin-mashida` 三个。 | 消费者 | 位置 | 现在怎么匹配 | 问题 | | --- | --- | --- | --- | | 发单建工单 | `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:97`)→ **先到先匹配,后到的补全数据不会纠正错误结果**。注意:91 与快手 send-code 属于同一个 `kuaishou-91` 来源,这里是同一来源的两条到达通道,不是两个来源。 ### 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=91kaquan` 是订单通道标识;匹配层仍统一归到 `sourceKey=kuaishou-91`。不要把 `provider` 当成匹配来源数量。 | 订单 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["来源适配器
kuaishou-91 / pdd / douyin-mashida"] --> M["ProductMatchContext
平台无关标准字段"] M --> E["引擎(纯函数)
条件打分 + 决策"] CFG[("模板 work_product_rules
+ 匹配条目 work_product_match_entries")] --> E E --> P["WorkOrderPlan
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/.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`(2026-10-14 当时):format / sql guard / lint / typecheck / 727 用例(0 失败)/ build 全绿;2026-10-17 复核后端 740 项(737 pass / 3 skipped)、前端 143 项。 ### 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:`、`kuaishou:` | `sendCode.itemId` / `sendCode.relItemId` | `sendCode.skuId` / `sendCode.relSkuId` | `sku_code`、`externalSkuCode`、`out_sku_sn`、`externalItemId` | 有 send-code:`skuNick`;文本候选沿用旧列表 | `sendCode.itemTitle` | `productTitle` | | PDD | `pdd` | `pdd:` | `goods_id` / `goods_id` | — / `out_sku_sn` | 同上(旧列表) | `out_sku_sn` / 旧文本列表 | `goods_name` | — | | 抖音 | `douyin-mashida` | `douyin:` | `productId` / `outProductId` | `skuId` / `outSkuId` | `sku_code`、`externalSkuCode`、`outSkuId`、`outProductId` | `sku_code`(内部 SKU);文本候选**仅** `sku_code`(缺失时 `sku_name`) | **恒为空** | `productName`(=引流标题) | | 未知来源 | `unknown` | `:` | 快照字段 | 快照字段 | `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` | **删除**(不再有第二套算法) |