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
@@ -30,6 +30,7 @@ import {
saveAdminWorkerProductMatchConfig,
syncAdminWorkProductRulePrice,
testAdminKuaishouProductMatch,
testAdminProductMatchByOrderItem,
} from '../../../services/worker-platform/index.js'
import { createJsonHandler, requireAdminRoles } from '../session.js'
@@ -335,6 +336,17 @@ router.post(
}),
)
// 按订单项 ID 试匹配:走与发单侧完全一致的来源适配器 + 引擎 + 价格,任何来源都适用。
router.post(
'/worker-platform/product-match-test-item',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => testAdminProductMatchByOrderItem(req.body || {}), {
successMessage: '匹配测试完成',
errorMessage: '匹配测试失败',
scope: '[admin/worker-platform/product-match-test-item]',
}),
)
router.get(
'/worker-platform/product-match-logs',
requireAdminRoles(['admin', 'operator', 'support']),
@@ -0,0 +1,72 @@
import {
normalizeExternalMatchId,
normalizeMatchId,
normalizeMatchText,
normalizeTextList,
} from '../normalize.js'
import { buildShopKey, readItemSnapshotFields, uniqueKeys } from './shared.js'
import type { ProductMatchAdapter } from './types.js'
/**
* 来源:抖音(码速达 / douyin_mashida,platform=doudian)。
*
* 关键约定:
* - **匹配文本只有内部 SKU**:优先 `item.sku_code`(落地口径恒为内部 SKU,
* 见 fulfillment/product-resolution-service.ts),为空时回落 `item.sku_name`;
* 引流商品标题(`productTitle` / `externalSkuName` 那串关键词)一律进 `promoTitle`,永不参与匹配;
* - 店铺键命名空间 `douyin:`(订单 shop_id,例如 douyin:305760014);
* - 文本兜底允许:因为匹配文本已限定为内部 SKU(干净规格名),不再有引流词表误命中的风险。
* 历史单里 `sku_name` 可能是引流标题,但那时 `sku_code` 也是同一个长串,
* 本适配器优先取 `sku_code`,P3 换成显式条目后可完全去掉文本兜底。
*/
export const douyinMashidaAdapter: ProductMatchAdapter = {
sourceKey: 'douyin-mashida',
matches(order) {
const provider = String(order.provider || '')
.trim()
.toLowerCase()
const platform = String(order.platform || '')
.trim()
.toLowerCase()
return provider === 'douyin_mashida' || platform === 'doudian'
},
build(order, item) {
const { snapshot, quantity } = readItemSnapshotFields(item)
const internalSku = normalizeMatchText(item.sku_code || item.sku_name)
const promoTitle = normalizeMatchText(snapshot.productName || snapshot.productTitle || '')
return {
sourceKey: 'douyin-mashida',
provider: String(order.provider || ''),
platform: String(order.platform || ''),
shopKeys: [buildShopKey('douyin', order.shop_id)].filter(Boolean),
hasSellerContext: false,
item: {
itemId: normalizeMatchId(snapshot.productId || snapshot.product_id),
relItemId: normalizeMatchId(snapshot.outProductId || snapshot.out_product_id),
skuId: normalizeMatchId(snapshot.skuId || snapshot.sku_id),
relSkuId: normalizeExternalMatchId(snapshot.outSkuId || snapshot.out_sku_id),
skuCodes: uniqueKeys(
[item.sku_code, snapshot.externalSkuCode, snapshot.outSkuId, snapshot.outProductId].map(
normalizeMatchId,
),
),
skuNick: internalSku,
// 只有内部 SKU 参与文本匹配:sku_name 在历史单里可能是引流长串,仅在 sku_code 缺失时兜底。
skuTexts: normalizeTextList([
String(item.sku_code || '').trim() ? item.sku_code : item.sku_name,
]),
// 抖音的商品标题就是引流词表:只放 promoTitle,itemTitle 留空避免被任何条件命中。
itemTitle: '',
promoTitle,
},
quantity,
raw: snapshot,
}
},
allowsTextFallback() {
return true
},
}
@@ -0,0 +1,74 @@
import { normalizeExternalMatchId, normalizeMatchId, normalizeMatchText } from '../normalize.js'
import {
buildShopKey,
legacySkuCodeCandidates,
legacySkuTextCandidates,
readItemSnapshotFields,
uniqueKeys,
} from './shared.js'
import type { ProductMatchAdapter } from './types.js'
/**
* 来源:91 回调 + 快手 send-code(一个来源,两条数据路)。
*
* - 91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源;
* - 店铺键命名空间 `kuaishou:`:卖家上下文取 send-code sellerId,同时兼容订单 shop_id
* (旧实现规则级 shop_id 判定用的是订单 shop_id);
* - SKU 文本候选沿用旧实现列表,保证 91 侧既网匹配结果不变。
*/
export const kuaishou91Adapter: ProductMatchAdapter = {
sourceKey: 'kuaishou-91',
matches(order) {
const provider = String(order.provider || '')
.trim()
.toLowerCase()
const platform = String(order.platform || '')
.trim()
.toLowerCase()
return provider === '91kaquan' || platform === 'kuaishou'
},
build(order, item) {
const { snapshot, sendCode, outSkuSn, quantity } = readItemSnapshotFields(item)
const sellerId = String(sendCode.sellerId || '').trim()
return {
sourceKey: 'kuaishou-91',
provider: String(order.provider || ''),
platform: String(order.platform || ''),
shopKeys: uniqueKeys([
buildShopKey('kuaishou', sellerId),
buildShopKey('kuaishou', order.shop_id),
]),
hasSellerContext: Boolean(sellerId),
item: {
itemId: normalizeMatchId(sendCode.itemId),
relItemId: normalizeExternalMatchId(
sendCode.relItemId || snapshot.goodsId || snapshot.goods_id,
),
skuId: normalizeMatchId(sendCode.skuId),
relSkuId: normalizeExternalMatchId(sendCode.relSkuId || outSkuSn || snapshot.spec),
skuCodes: legacySkuCodeCandidates({ item, snapshot, outSkuSn }),
skuNick: normalizeMatchText(
sendCode.skuNick ||
sendCode.skuName ||
outSkuSn ||
snapshot.externalSkuName ||
snapshot.spec,
),
skuTexts: legacySkuTextCandidates({ item, snapshot, sendCode, outSkuSn }),
itemTitle: normalizeMatchText(
sendCode.itemTitle || snapshot.goodsName || snapshot.goods_name || '',
),
promoTitle: normalizeMatchText(snapshot.productTitle || ''),
},
quantity,
raw: snapshot,
}
},
allowsTextFallback({ hasSellerContext, legacyFallbackEnabled }) {
// 旧语义:无卖家上下文时无条件兜底;有卖家上下文时需后台显式开启。
return !hasSellerContext || legacyFallbackEnabled
},
}
@@ -0,0 +1,59 @@
import { normalizeExternalMatchId, normalizeMatchId, normalizeMatchText } from '../normalize.js'
import {
buildShopKey,
legacySkuCodeCandidates,
legacySkuTextCandidates,
readItemSnapshotFields,
} from './shared.js'
import type { ProductMatchAdapter } from './types.js'
/**
* 来源:拼多多。
*
* 店铺键命名空间 `pdd:`;SKU 文本候选沿用旧实现(`out_sku_sn` 是拼多多的主匹配键);
* 旧实现里拼多多允许用模板名兜底,这里保持一致(过渡期兼容,P3 换成显式条目后消失)。
*/
export const pddAdapter: ProductMatchAdapter = {
sourceKey: 'pdd',
matches(order) {
const provider = String(order.provider || '')
.trim()
.toLowerCase()
const platform = String(order.platform || '')
.trim()
.toLowerCase()
return provider === 'pdd' || platform === 'pdd'
},
build(order, item) {
const { snapshot, sendCode, outSkuSn, quantity } = readItemSnapshotFields(item)
const goodsId = String(snapshot.goods_id || snapshot.goodsId || '').trim()
const spec = String(snapshot.spec || '').trim()
const goodsName = String(snapshot.goods_name || snapshot.goodsName || '').trim()
return {
sourceKey: 'pdd',
provider: String(order.provider || ''),
platform: String(order.platform || ''),
shopKeys: [buildShopKey('pdd', order.shop_id)].filter(Boolean),
hasSellerContext: false,
item: {
itemId: normalizeMatchId(goodsId),
relItemId: normalizeMatchId(goodsId),
skuId: '',
relSkuId: normalizeExternalMatchId(outSkuSn || spec),
skuCodes: legacySkuCodeCandidates({ item, snapshot, outSkuSn }),
skuNick: normalizeMatchText(outSkuSn || spec || snapshot.externalSkuName || ''),
skuTexts: legacySkuTextCandidates({ item, snapshot, sendCode, outSkuSn }),
itemTitle: normalizeMatchText(goodsName),
promoTitle: '',
},
quantity,
raw: snapshot,
}
},
allowsTextFallback() {
return true
},
}
@@ -0,0 +1,65 @@
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
import { normalizeMatchId, normalizeMatchText, normalizeTextList } from '../normalize.js'
import { buildShopKey } from './shared.js'
import { douyinMashidaAdapter } from './douyin-mashida.js'
import { kuaishou91Adapter } from './kuaishou-91.js'
import { pddAdapter } from './pdd.js'
import type { ProductMatchAdapter } from './types.js'
/**
* 兜底适配器:未知来源。
*
* 只使用订单项自身的标准字段,不做任何文本兜底(宁可未命中,也不猜)。
*/
const unknownAdapter: ProductMatchAdapter = {
sourceKey: 'unknown',
matches() {
return true
},
build(order, item) {
const snapshot =
item.item_snapshot_json && typeof item.item_snapshot_json === 'object'
? (item.item_snapshot_json as Record<string, unknown>)
: {}
return {
sourceKey: 'unknown',
provider: String(order.provider || ''),
platform: String(order.platform || ''),
shopKeys: [buildShopKey(String(order.provider || 'unknown'), order.shop_id)].filter(Boolean),
hasSellerContext: false,
item: {
itemId: normalizeMatchId(snapshot.productId || snapshot.goods_id || snapshot.goodsId),
relItemId: normalizeMatchId(snapshot.outProductId || snapshot.out_sku_sn),
skuId: normalizeMatchId(snapshot.skuId || snapshot.sku_id),
relSkuId: normalizeMatchId(snapshot.outSkuId || snapshot.outSkuSn),
skuCodes: normalizeTextList([item.sku_code]).map(normalizeMatchId).filter(Boolean),
skuNick: normalizeMatchText(item.sku_name || item.sku_code),
skuTexts: normalizeTextList([item.sku_code, item.sku_name]),
itemTitle: normalizeMatchText(snapshot.productName || snapshot.goods_name || ''),
promoTitle: normalizeMatchText(snapshot.productTitle || ''),
},
quantity: Math.max(1, Number(item.quantity || 1) || 1),
raw: snapshot,
}
},
allowsTextFallback() {
return false
},
}
/** 适配器注册表:顺序=优先级(抖音/拼多多/快手91 互斥,最后兜底)。 */
const ADAPTERS: ProductMatchAdapter[] = [douyinMashidaAdapter, pddAdapter, kuaishou91Adapter]
export function resolveProductMatchAdapter(order: OrderRow): ProductMatchAdapter {
return ADAPTERS.find((adapter) => adapter.matches(order)) ?? unknownAdapter
}
export function resolveProductMatchSourceKey(order: OrderRow) {
return resolveProductMatchAdapter(order).sourceKey
}
export type { ProductMatchAdapter }
export type AdapterInput = { order: OrderRow; item: OrderItemRow }
@@ -0,0 +1,74 @@
import type { JsonObject } from '../../../types/json.js'
import type { OrderItemRow } from '../../../types/repository/rows.js'
import { safeParseJson } from '../../admin/admin-query-utils.js'
import { normalizeMatchId, normalizeTextList } from '../normalize.js'
export type ItemSnapshotFields = {
snapshot: JsonObject
sendCode: JsonObject
outSkuSn: string
quantity: number
}
/** 读取订单项快照与快手 send-code 上下文(缺失时为空对象)。 */
export function readItemSnapshotFields(item: OrderItemRow): ItemSnapshotFields {
const snapshot = safeParseJson(item.item_snapshot_json)
const sendCode = safeParseJson(snapshot.kuaishouSendCode)
return {
snapshot,
sendCode,
outSkuSn: String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim(),
quantity: Math.max(1, Number(item.quantity || 1) || 1),
}
}
/**
* 旧实现的 SKU 文本候选列表(顺序即可信度):
* `[sku_name, externalSkuName, out_sku_sn, spec, externalSkuNameNormalized, sendCode.skuNick, sendCode.itemTitle]`
*
* 注意:引流标题(`snapshot.productTitle`)**不在**此列表内;抖音适配器只取内部 SKU。
*/
export function legacySkuTextCandidates(input: {
item: OrderItemRow
snapshot: JsonObject
sendCode: JsonObject
outSkuSn: string
}): string[] {
const { item, snapshot, sendCode, outSkuSn } = input
return normalizeTextList([
item.sku_name,
snapshot.externalSkuName,
outSkuSn,
snapshot.spec,
snapshot.externalSkuNameNormalized,
sendCode.skuNick,
sendCode.itemTitle,
])
}
/** 旧实现的 SKU 编码候选列表:`[sku_code, externalSkuCode, out_sku_sn, externalItemId]`。 */
export function legacySkuCodeCandidates(input: {
item: OrderItemRow
snapshot: JsonObject
outSkuSn: string
}): string[] {
const { item, snapshot, outSkuSn } = input
return Array.from(
new Set(
[item.sku_code, snapshot.externalSkuCode, outSkuSn, snapshot.externalItemId]
.map(normalizeMatchId)
.filter(Boolean),
),
)
}
/** 命名空间化店铺键(带前缀,保持唯一且可跨来源共存)。 */
export function buildShopKey(namespace: string, value: unknown): string {
const id = String(value ?? '').trim()
return id ? `${namespace}:${id}` : ''
}
/** 去重后的非空键列表。 */
export function uniqueKeys(keys: string[]): string[] {
return Array.from(new Set(keys.filter(Boolean)))
}
@@ -0,0 +1,23 @@
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
import type { ProductMatchContext, ProductMatchSourceKey } from '../types.js'
/**
* 来源适配器:把某个来源的订单信息归一成平台无关的 ProductMatchContext。
* 引擎只认上下文,任何平台差异都止步于此层。
*/
export type ProductMatchAdapter = {
sourceKey: ProductMatchSourceKey
/** 该订单是否属于本来源。 */
matches(order: OrderRow): boolean
/** 归一化订单信息。 */
build(order: OrderRow, item: OrderItemRow): ProductMatchContext
/**
* 是否允许「模板名/商品名文本兜底」条目参与该来源的匹配。
*
* 过渡兼容语义(与旧实现等价):
* - 无卖家上下文时旧实现无条件启用兜底;
* - 有卖家上下文(快手 send-code)时需后台显式开关;
* - 抖音恒为 false:抖音商品标题是引流关键词表,文本兜底必然误命中。
*/
allowsTextFallback(input: { hasSellerContext: boolean; legacyFallbackEnabled: boolean }): boolean
}
@@ -0,0 +1,223 @@
import type {
WorkProductRuleMappingRow,
WorkProductRuleRow,
} from '../../repositories/worker-platform/index.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { normalizeMatchId, normalizeMatchIdList, normalizeMatchText } from './normalize.js'
import type { ProductMatchCondition, ProductMatchEntry, ProductMatchTextMode } from './types.js'
export type CompileLegacyEntriesOptions = {
/**
* 是否编译「模板名 / 商品名」文本条件(旧实现的"旧规则兜底")。
* 由来源适配器的 allowsTextFallback 决定;关闭时来源只按确定性条件匹配。
*/
allowTextFallback: boolean
}
/** 条件强度层级:结构化条件 > SKU 精确映射 > 数量系列映射 > 商品默认映射 > 纯文本条件。 */
const STRUCTURED_BASE_SCORE = 300
const MAPPING_BASE_SCORE = {
sku_exact: 1000,
sku_series: 800,
product_default: 500,
} as const
/**
* 把旧的「接单模板 + 商品绑定」编译成匹配条目(过渡兼容层)。
*
* 这一层是**唯一**保留历史差异的地方:新表条目(P3)不经过它,引擎始终只有一套打分。
* 编译必须与 `work-product-match-mappers.ts` 旧实现语义等价,改动需同步回放回归。
*/
export function compileLegacyMatchEntries(
rules: WorkProductRuleRow[],
mappings: WorkProductRuleMappingRow[],
options: CompileLegacyEntriesOptions,
): ProductMatchEntry[] {
const entries: ProductMatchEntry[] = []
const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule]))
for (const rule of rules) {
if (!rule.enabled) continue
entries.push(...buildRuleEntries(rule, options))
}
for (const mapping of mappings) {
if (!mapping.enabled) continue
const rule = rulesById.get(Number(mapping.rule_id))
if (!rule || !rule.enabled) continue
const entry = buildMappingEntry(rule, mapping)
if (entry) entries.push(entry)
}
return entries
}
function buildRuleEntries(
rule: WorkProductRuleRow,
options: CompileLegacyEntriesOptions,
): ProductMatchEntry[] {
const entries: ProductMatchEntry[] = []
const shopKeys = normalizeMatchId(rule.shop_id)
? [`kuaishou:${normalizeMatchId(rule.shop_id)}`]
: []
// 结构化 match_json:旧实现只在有卖家上下文时生效,且要求所有已配置字段全部命中。
const match = safeParseJson(rule.match_json)
const structuredAll: ProductMatchCondition[] = []
const structuredShopKeys = [...shopKeys]
const matchSellerId = normalizeMatchId(match.sellerId)
if (matchSellerId) structuredShopKeys.push(`kuaishou:${matchSellerId}`)
const matchItemId = normalizeMatchId(match.itemId)
if (matchItemId) structuredAll.push({ field: 'itemId', value: matchItemId, mode: 'exact' })
const matchRelItemId = normalizeMatchId(match.relItemId)
if (matchRelItemId)
structuredAll.push({ field: 'relItemId', value: matchRelItemId, mode: 'exact' })
const matchSkuId = normalizeMatchId(match.skuId)
if (matchSkuId) structuredAll.push({ field: 'skuId', value: matchSkuId, mode: 'exact' })
const matchRelSkuId = normalizeMatchId(match.relSkuId)
if (matchRelSkuId) structuredAll.push({ field: 'relSkuId', value: matchRelSkuId, mode: 'exact' })
const matchItemTitle = normalizeMatchText(match.itemTitle)
if (matchItemTitle) {
structuredAll.push({
field: 'itemTitle',
value: matchItemTitle,
mode: match.itemTitleMatchType === 'exact' ? 'exact' : 'contains',
})
}
const matchSkuNick = normalizeMatchText(match.skuNick)
if (matchSkuNick) {
structuredAll.push({
field: 'skuNick',
value: matchSkuNick,
mode: match.skuNickMatchType === 'exact' ? 'exact' : 'contains',
})
}
// 只配了 sellerId 的"空条件结构化"(旧实现会整店命中)不编译:属于历史脏配置,
// 新模型一律要求显式判定维度(见 docs/接单模板匹配引擎重构设计.md §2.3)。
if (structuredAll.length > 0) {
entries.push({
key: `legacy:rule:${rule.id}:structured`,
ruleId: Number(rule.id),
mappingId: null,
rule,
all: structuredAll,
any: [],
bonus: [],
sourceKeys: [],
shopKeys: uniqueKeys(structuredShopKeys),
qtyMin: 0,
qtyMax: 0,
baseScore: STRUCTURED_BASE_SCORE,
fromRuleName: false,
})
}
if (!options.allowTextFallback) return entries
// 模板名/商品名文本条件 + 模板编码条件(旧实现:任一命中即可)。
const any: ProductMatchCondition[] = []
const productName = normalizeMatchText(rule.product_name)
if (productName) {
any.push({
field: 'skuText',
value: productName,
mode: normalizeRuleMatchType(rule.match_type),
})
}
const skuCode = normalizeMatchId(rule.sku_code)
if (skuCode) any.push({ field: 'skuCode', value: skuCode, mode: 'exact' })
if (any.length > 0) {
entries.push({
key: `legacy:rule:${rule.id}:name`,
ruleId: Number(rule.id),
mappingId: null,
rule,
all: [],
any,
bonus: [],
sourceKeys: [],
shopKeys,
qtyMin: 0,
qtyMax: 0,
baseScore: 0,
fromRuleName: true,
})
}
return entries
}
function buildMappingEntry(
rule: WorkProductRuleRow,
mapping: WorkProductRuleMappingRow,
): ProductMatchEntry | null {
const mappingType =
mapping.mapping_type === 'sku_series'
? 'sku_series'
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
? 'sku_exact'
: 'product_default'
const shopKeys = normalizeMatchIdList(mapping.seller_ids_json).map(
(sellerId) => `kuaishou:${sellerId}`,
)
const mappingItemIds = normalizeMatchIdList(mapping.rel_item_id)
const mappingItemTitle = normalizeMatchText(mapping.item_title)
const mappingSkuNick = normalizeMatchText(mapping.sku_nick)
const mappingRelSkuId = normalizeMatchId(mapping.rel_sku_id)
const all: ProductMatchCondition[] = []
const any: ProductMatchCondition[] = []
const bonus: ProductMatchCondition[] = []
if (mappingType === 'product_default') {
// 旧实现:配了商品 ID 就要求商品 ID 命中(标题只加分);只配标题则要求标题命中。
if (mappingItemIds.length > 0) {
for (const itemId of mappingItemIds)
any.push({ field: 'relItemId', value: itemId, mode: 'exact' })
if (mappingItemTitle) {
bonus.push({ field: 'itemTitle', value: mappingItemTitle, mode: 'contains' })
}
} else if (mappingItemTitle) {
any.push({ field: 'itemTitle', value: mappingItemTitle, mode: 'contains' })
}
} else if (mappingType === 'sku_exact') {
// 旧实现:SKU 规格文本或关联 SKU 任一命中即可(两者都命中时得分更高)。
if (mappingRelSkuId) any.push({ field: 'relSkuId', value: mappingRelSkuId, mode: 'exact' })
if (mappingSkuNick) any.push({ field: 'skuNick', value: mappingSkuNick, mode: 'exact' })
} else {
// SKU 数量系列:默认模糊(兼容 秘钥/密钥 写法差异),exact 时要求完全一致。
if (mappingSkuNick) {
any.push({
field: 'skuNick',
value: mappingSkuNick,
mode: mapping.sku_match_mode === 'exact' ? 'series-exact' : 'series',
})
}
}
if (any.length === 0 && all.length === 0) return null
return {
key: `legacy:mapping:${mapping.id}`,
ruleId: Number(rule.id),
mappingId: Number(mapping.id),
rule,
all,
any,
bonus,
sourceKeys: [],
shopKeys,
qtyMin: 0,
qtyMax: 0,
baseScore: MAPPING_BASE_SCORE[mappingType],
fromRuleName: false,
}
}
function normalizeRuleMatchType(value: unknown): ProductMatchTextMode {
return String(value ?? '').trim() === 'exact' ? 'exact' : 'contains'
}
function uniqueKeys(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)))
}
@@ -0,0 +1,49 @@
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { resolveProductMatchAdapter } from './adapters/registry.js'
import { normalizeExternalMatchId } from './normalize.js'
import type { ProductMatchContext } from './types.js'
/** 构建平台无关匹配上下文(来源适配器负责字段归一)。 */
export function buildProductMatchContext(order: OrderRow, item: OrderItemRow): ProductMatchContext {
return resolveProductMatchAdapter(order).build(order, item)
}
/** 该来源是否允许「模板名/商品名文本兜底」条目参与匹配(过渡兼容策略)。 */
export function isTextFallbackAllowed(input: {
order: OrderRow
hasSellerContext: boolean
legacyFallbackEnabled: boolean
}): boolean {
return resolveProductMatchAdapter(input.order).allowsTextFallback({
hasSellerContext: input.hasSellerContext,
legacyFallbackEnabled: input.legacyFallbackEnabled,
})
}
/**
* 兼容旧投影:快手发码上下文(后台匹配测试与日志仍按这些字段展示)。
*/
export function resolveKuaishouWorkProductMatchContext(
item: Pick<OrderItemRow, 'item_snapshot_json'>,
) {
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
return {
sellerId: String(source.sellerId || '').trim(),
itemId: String(source.itemId || '').trim(),
relItemId: normalizeExternalMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: String(source.skuId || snapshot.sku_id || '').trim(),
relSkuId: normalizeExternalMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: String(source.itemTitle || snapshot.goodsName || snapshot.goods_name || '').trim(),
skuNick: String(
source.skuNick ||
source.skuName ||
outSkuSn ||
snapshot.externalSkuName ||
snapshot.spec ||
'',
).trim(),
}
}
@@ -0,0 +1,273 @@
import {
normalizeMatchId,
normalizeMatchText,
normalizeSkuSeriesEquivalence,
normalizeSkuSeriesName,
} from './normalize.js'
import type {
ProductMatchCandidate,
ProductMatchCondition,
ProductMatchConditionField,
ProductMatchContext,
ProductMatchDecision,
ProductMatchDiagnosisItem,
ProductMatchEntry,
} from './types.js'
type EvaluatedCondition = { matched: boolean; weight: number; specificity: number }
/** 标识类字段:按标识语义(小写、'0' 视为空)比较。 */
const ID_FIELDS = new Set<ProductMatchConditionField>([
'skuCode',
'skuId',
'relSkuId',
'itemId',
'relItemId',
])
const EXACT_WEIGHTS: Record<ProductMatchConditionField, number> = {
skuCode: 500,
skuId: 900,
relSkuId: 900,
skuText: 800,
skuNick: 800,
itemId: 300,
relItemId: 300,
itemTitle: 200,
}
const CONTAINS_WEIGHTS: Record<ProductMatchConditionField, number> = {
skuCode: 400,
skuId: 500,
relSkuId: 500,
skuText: 400,
skuNick: 400,
itemId: 200,
relItemId: 200,
itemTitle: 200,
}
/** 数量系列(剥离「5个」后的系列名)命中权重:介于编码精确与文本包含之间。 */
const SERIES_WEIGHT = 600
/** 文本包含命中时的"具体度"加成上限:更长的模板名更具体(旧实现语义,见回放回归)。 */
const SPECIFICITY_CAP = 80
/**
* 匹配引擎:纯函数,只读上下文与条目,不碰数据库、不判平台。
*
* 规则:
* - 来源/店铺条件:配置了就必须命中(未配置=不限);
* - all:全部命中;any:至少命中一个;bonus:命中加分、不命中不淘汰;
* - 最高分唯一 → matched;并列最高 → ambiguous(不猜);无候选 → unmatched。
*/
export function resolveProductMatchDecision(
context: ProductMatchContext,
entries: ProductMatchEntry[],
): ProductMatchDecision {
const passed: Array<{ entry: ProductMatchEntry; score: number }> = []
const diagnosis: ProductMatchDiagnosisItem[] = []
for (const entry of entries) {
const blockedBy: string[] = []
if (entry.sourceKeys.length > 0 && !entry.sourceKeys.includes(context.sourceKey)) {
blockedBy.push(
`source_mismatch(entry=${entry.sourceKeys.join('/')}, order=${context.sourceKey})`,
)
}
if (
entry.shopKeys.length > 0 &&
!entry.shopKeys.some((shopKey) => context.shopKeys.includes(shopKey))
) {
blockedBy.push(
`shop_mismatch(entry=${entry.shopKeys.join('/')}, order=${context.shopKeys.join('/') || '-'})`,
)
}
if (entry.qtyMin > 0 && context.quantity < entry.qtyMin) {
blockedBy.push(`quantity_below_min(entry=${entry.qtyMin}, order=${context.quantity})`)
}
if (entry.qtyMax > 0 && context.quantity > entry.qtyMax) {
blockedBy.push(`quantity_above_max(entry=${entry.qtyMax}, order=${context.quantity})`)
}
const scored: EvaluatedCondition[] = []
for (const condition of entry.all) {
const evaluated = evaluateCondition(condition, context)
if (!evaluated.matched) {
blockedBy.push(`all_not_matched:${describeCondition(condition)}`)
continue
}
scored.push(evaluated)
}
if (entry.any.length > 0) {
const anyMatched = entry.any
.map((condition) => evaluateCondition(condition, context))
.filter((evaluated) => evaluated.matched)
if (anyMatched.length === 0) {
blockedBy.push(`any_not_matched:${entry.any.map(describeCondition).join('|')}`)
} else {
scored.push(...anyMatched)
}
}
if (blockedBy.length > 0) {
diagnosis.push({ key: entry.key, ruleKey: entry.rule.rule_key, blockedBy })
continue
}
for (const condition of entry.bonus) {
const evaluated = evaluateCondition(condition, context)
if (evaluated.matched) scored.push(evaluated)
}
const score =
entry.baseScore +
scored.reduce((sum, evaluated) => sum + evaluated.weight + evaluated.specificity, 0)
passed.push({ entry, score })
}
if (passed.length === 0) {
return {
status: 'unmatched',
rule: null,
mappingId: null,
entryKey: null,
score: 0,
candidates: [],
diagnosis: diagnosis.slice(0, 10),
}
}
passed.sort((left, right) => right.score - left.score || right.entry.ruleId - left.entry.ruleId)
const top = passed[0]
if (!top) {
return {
status: 'unmatched',
rule: null,
mappingId: null,
entryKey: null,
score: 0,
candidates: [],
diagnosis: diagnosis.slice(0, 10),
}
}
const tied = passed.filter((candidate) => candidate.score === top.score)
if (tied.length > 1) {
return {
status: 'ambiguous',
rule: null,
mappingId: null,
entryKey: null,
score: top.score,
candidates: tied.slice(0, 5).map(toCandidate),
diagnosis: diagnosis.slice(0, 10),
}
}
return {
status: 'matched',
rule: top.entry.rule,
mappingId: top.entry.mappingId,
entryKey: top.entry.key,
score: top.score,
candidates: passed.slice(0, 5).map(toCandidate),
diagnosis: [],
}
}
function toCandidate(input: { entry: ProductMatchEntry; score: number }): ProductMatchCandidate {
return {
key: input.entry.key,
ruleKey: input.entry.rule.rule_key,
ruleId: input.entry.ruleId,
mappingId: input.entry.mappingId,
score: input.score,
}
}
function describeCondition(condition: ProductMatchCondition): string {
return `${condition.field}:${condition.mode}:${condition.value}`
}
function contextValues(context: ProductMatchContext, field: ProductMatchConditionField): string[] {
switch (field) {
case 'skuText':
return context.item.skuTexts
case 'skuNick':
return [context.item.skuNick]
case 'skuCode':
return context.item.skuCodes
case 'skuId':
return [context.item.skuId]
case 'relSkuId':
return [context.item.relSkuId]
case 'itemId':
return [context.item.itemId]
case 'relItemId':
return [context.item.relItemId]
case 'itemTitle':
return [context.item.itemTitle]
default:
return []
}
}
function evaluateCondition(
condition: ProductMatchCondition,
context: ProductMatchContext,
): EvaluatedCondition {
const expected = condition.value
if (!expected) return { matched: false, weight: 0, specificity: 0 }
const isIdField = ID_FIELDS.has(condition.field)
const values = contextValues(context, condition.field)
for (const value of values) {
if (!String(value || '').trim()) continue
if (condition.mode === 'series' || condition.mode === 'series-exact') {
// series=fuzzy(兼容 秘钥/密钥 写法差异);series-exact 只剥数量,要求系列名完全一致。
const actualSeries =
condition.mode === 'series'
? normalizeSkuSeriesEquivalence(value)
: normalizeSkuSeriesName(value)
const expectedSeries =
condition.mode === 'series'
? normalizeSkuSeriesEquivalence(expected)
: normalizeSkuSeriesName(expected)
if (!actualSeries || !expectedSeries) continue
const matched =
condition.mode === 'series-exact'
? actualSeries === expectedSeries
: actualSeries.includes(expectedSeries) || expectedSeries.includes(actualSeries)
if (matched) return { matched: true, weight: SERIES_WEIGHT, specificity: 0 }
continue
}
if (condition.mode === 'exact') {
const matched = isIdField
? normalizeMatchId(value) === normalizeMatchId(expected)
: normalizeMatchText(value) === normalizeMatchText(expected)
if (matched) {
return { matched: true, weight: EXACT_WEIGHTS[condition.field], specificity: 0 }
}
continue
}
const matched = isIdField
? normalizeMatchId(value).includes(normalizeMatchId(expected))
: normalizeMatchText(value).includes(normalizeMatchText(expected))
if (matched) {
return {
matched: true,
weight: CONTAINS_WEIGHTS[condition.field],
specificity: isIdField ? 0 : Math.min(SPECIFICITY_CAP, normalizeMatchText(expected).length),
}
}
}
return { matched: false, weight: 0, specificity: 0 }
}
@@ -0,0 +1,45 @@
/**
* 接单模板匹配引擎(平台无关)对外出口。
*
* - 来源适配器:`kuaishou-91`(91 回调 + 快手 send-code 合成一个来源)/ `pdd` / `douyin-mashida`;
* - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维);
* - 价格/数量/拼单:`pricing.ts` 唯一实现。
*
* 设计见 docs/接单模板匹配引擎重构设计.md。
*/
export type {
ProductMatchCandidate,
ProductMatchCondition,
ProductMatchConditionField,
ProductMatchContext,
ProductMatchDecision,
ProductMatchDiagnosisItem,
ProductMatchEntry,
ProductMatchSourceKey,
ProductMatchTextMode,
} from './types.js'
export { compileLegacyMatchEntries, type CompileLegacyEntriesOptions } from './compile.js'
export {
buildProductMatchContext,
isTextFallbackAllowed,
resolveKuaishouWorkProductMatchContext,
} from './context.js'
export { resolveProductMatchDecision } from './engine.js'
export {
resolveProductMatchAdapter,
resolveProductMatchSourceKey,
type ProductMatchAdapter,
} from './adapters/registry.js'
export {
isWorkOrderSharingFilled,
resolveSkuNameQuantity,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from './pricing.js'
export {
normalizeMatchId,
normalizeMatchText,
normalizeSkuSeriesEquivalence,
parseSkuTextQuantity,
} from './normalize.js'
@@ -0,0 +1,122 @@
/**
* 匹配用的文本/标识归一化。语义与旧实现(work-product-match-mappers.ts)保持一致,
* 以免在抽离过程中改变既网匹配结果。
*/
/** 文本归一:NFKC、小写、去空白。 */
export function normalizeMatchText(value: unknown): string {
return String(value ?? '')
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\u3000]+/g, '')
.trim()
}
/** 标识归一:去首尾空格、小写;`'0'` 视为空。 */
export function normalizeMatchId(value: unknown): string {
const normalized = String(value ?? '').trim()
return normalized === '0' ? '' : normalized.toLowerCase()
}
/** 关联标识归一:仅把 `'0'` 视为空,保留大小写(与旧实现一致)。 */
export function normalizeExternalMatchId(value: unknown): string {
const normalized = String(value ?? '').trim()
return normalized === '0' ? '' : normalized
}
/** 去重后的标识列表。 */
export function normalizeMatchIdList(value: unknown): string[] {
const rawValues = Array.isArray(value) ? value : [value]
return Array.from(
new Set(
rawValues
.flatMap((entry) => String(entry ?? '').split(/[,,;;\s\n\r]+/))
.map(normalizeMatchId)
.filter(Boolean),
),
)
}
/** 归一后的非空文本列表(保序去重)。 */
export function normalizeTextList(values: unknown[]): string[] {
const out: string[] = []
for (const value of values) {
const normalized = normalizeMatchText(value)
if (normalized && !out.includes(normalized)) out.push(normalized)
}
return out
}
/**
* 去除 SKU 中的数量规格后得到系列名(「指挥官密钥10个」→「指挥官密钥」)。
*/
export function normalizeSkuSeriesName(value: unknown): string {
return normalizeMatchText(value).replace(
/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g,
'',
)
}
/** 异体字归一:秘钥/密匙/秘匙 → 密钥(同一商品的常见写法差异)。 */
export function normalizeSkuEquivalence(value: string): string {
return value.replace(/秘钥|密匙|秘匙/g, '密钥')
}
/** 数量系列文本(剥数量 + 异体字归一)。 */
export function normalizeSkuSeriesEquivalence(value: unknown): string {
return normalizeSkuEquivalence(normalizeSkuSeriesName(value))
}
/** 从 SKU 文本里解析数量;解析不到返回 null。 */
export function parseSkuTextQuantity(productName: unknown): number | null {
const text = String(productName ?? '').trim()
if (!text) return null
const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/)
if (arabicMatch?.[1]) {
const parsed = Number(arabicMatch[1])
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed
}
const chineseMatch = text.match(/([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/)
if (chineseMatch?.[1]) {
const parsed = parseChineseNumber(chineseMatch[1])
if (parsed > 0) return parsed
}
return null
}
const CHINESE_DIGITS: Record<string, number> = {
零: 0,
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
}
function parseChineseNumber(text: string): number {
let section = 0
let digit = 0
for (const char of text) {
if (char === '十') {
section += (digit || 1) * 10
digit = 0
} else if (char === '百') {
section += (digit || 1) * 100
digit = 0
} else if (char === '千') {
section += (digit || 1) * 1000
digit = 0
} else if (char in CHINESE_DIGITS) {
digit = CHINESE_DIGITS[char] ?? 0
}
}
return section + digit
}
@@ -0,0 +1,69 @@
import { parseSkuTextQuantity } from './normalize.js'
/**
* 数量与计价(全项目唯一实现):数量归一 → 单价 × 数量 / 固定金额 → 拼单份数。
*
* 发单建工单、后台改单、模板改价同步大厅、寄卖建议价全部走这里,避免同口径多处实现。
*/
/** 从商品名/SKU 文本解析数量(解析不到按 1 计)。 */
export function resolveSkuNameQuantity(productName: unknown): number {
return parseSkuTextQuantity(productName) ?? 1
}
/**
* 工单数量归一:优先取 SKU 文本中的规格数量(如「指挥官密钥5个」→ 5),
* 文本无数量时回落到订单项数量字段。
*/
export function resolveWorkOrderQuantity(input: {
skuText: unknown
fallbackQuantity: unknown
}): number {
const parsed = parseSkuTextQuantity(input.skuText)
if (parsed !== null) return parsed
return Math.max(1, Number(input.fallbackQuantity || 1) || 1)
}
export function resolveWorkOrderRulePricing(input: {
skuQuantity: number
unitPriceFen: number
fixedRewardAmount: number
sharingEnabled: boolean
sharingAutoFromOrder: boolean
sharingTotalQuantity: number
sharingUnitReward: number
}) {
const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1)
const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0)
const sharingTotalQuantity = input.sharingAutoFromOrder
? skuQuantity
: Math.max(1, Number(input.sharingTotalQuantity) || 1)
const sharingUnitReward = input.sharingAutoFromOrder
? unitPriceFen
: Math.max(0, Number(input.sharingUnitReward) || 0)
const rewardAmount = input.sharingEnabled
? sharingTotalQuantity * sharingUnitReward
: unitPriceFen > 0
? unitPriceFen * skuQuantity
: Math.max(0, Number(input.fixedRewardAmount) || 0)
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
}
export function resolveWorkOrderSharingEnabled(
configuredEnabled: boolean,
totalQuantity: number,
): boolean {
return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1
}
export function isWorkOrderSharingFilled(
sharingEnabled: boolean,
totalQuantity: number,
joinedQuantity: number,
): boolean {
return (
sharingEnabled &&
Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1)
)
}
@@ -0,0 +1,367 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type {
WorkProductRuleMappingRow,
WorkProductRuleRow,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
buildProductMatchContext,
compileLegacyMatchEntries,
resolveProductMatchDecision,
resolveProductMatchSourceKey,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
} from './index.js'
function buildOrder(overrides: Partial<OrderRow> = {}): OrderRow {
return {
id: 1,
provider: '91kaquan',
platform: 'kuaishou',
shop_id: '3676797936',
shop_name: '测试店铺',
platform_order_id: '2623001922675146',
order_status: 'paid',
pay_status: 'paid',
buyer_id: '',
buyer_name: '',
receiver_contact: '',
total_amount: 9900,
currency: 'CNY',
raw_payload_json: {},
paid_at: null,
created_at: '2026-10-14T00:00:00.000Z',
updated_at: '2026-10-14T00:00:00.000Z',
...overrides,
}
}
function buildItem(overrides: Partial<OrderItemRow> = {}): OrderItemRow {
return {
id: 1,
order_id: 1,
sku_code: '指挥官密钥5个',
sku_name: '指挥官密钥5个',
quantity: 1,
spec_json: {},
item_snapshot_json: {},
created_at: '2026-10-14T00:00:00.000Z',
updated_at: '2026-10-14T00:00:00.000Z',
...overrides,
}
}
function buildRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProductRuleRow {
return {
id: 1,
rule_key: 'rule-1',
provider: '',
platform: '',
shop_id: '',
sku_code: '',
product_name: '',
match_type: 'contains',
category_id: null,
enabled: true,
auto_create: true,
reward_amount: 0,
unit_price_fen: 0,
required_deposit_amount: 0,
deposit_threshold_amount: 20000,
sharing_enabled: false,
sharing_total_quantity: 1,
sharing_unit_reward: 0,
sharing_auto_from_order: false,
timeout_minutes: 0,
timeout_policy: 'reopen',
requirement_json: {},
match_json: {},
sort_order: 100,
created_at: '2026-10-14T00:00:00.000Z',
updated_at: '2026-10-14T00:00:00.000Z',
...overrides,
}
}
function buildMapping(
overrides: Partial<WorkProductRuleMappingRow> = {},
): WorkProductRuleMappingRow {
return {
id: 1,
rule_id: 1,
seller_ids_json: ['3676797936'],
rel_item_id: '',
item_title: '',
rel_sku_id: '',
sku_nick: '',
sku_match_mode: 'fuzzy',
mapping_type: 'sku_series',
enabled: true,
created_at: '2026-10-14T00:00:00.000Z',
updated_at: '2026-10-14T00:00:00.000Z',
...overrides,
}
}
/** 抖音真实形态:内部 SKU 干净,商品标题是引流关键词表。 */
function buildDouyinItem(): OrderItemRow {
const promoTitle = '和平精英指挥官密钥秘钥出售隐藏款密钥碎梦浮光珍宝礼盒潮汐礼券'
return buildItem({
id: 334088,
order_id: 334139,
sku_code: '指挥官密钥5个',
sku_name: '指挥官密钥5个',
item_snapshot_json: {
source: 'douyin_mashida',
shopId: '305760014',
productId: '3845284495162999020',
skuId: '3381288076340261',
outSkuId: '',
code: '指挥官密钥5个',
productName: promoTitle,
productTitle: promoTitle,
externalSkuName: promoTitle,
externalSkuCode: '指挥官密钥5个',
},
})
}
const douyinOrder = buildOrder({
id: 334139,
provider: 'douyin_mashida',
platform: 'doudian',
shop_id: '305760014',
shop_name: '向日葵游戏交易店',
})
const commanderRule = buildRule({
id: 9,
rule_key: 'bb',
product_name: '指挥官密钥',
unit_price_fen: 950,
sharing_enabled: true,
sharing_auto_from_order: true,
})
const giftBoxRule = buildRule({
id: 15,
rule_key: 'cc',
product_name: '碎梦浮光珍宝礼盒',
unit_price_fen: 85,
})
function decide(input: {
order: OrderRow
item: OrderItemRow
rules: WorkProductRuleRow[]
mappings?: WorkProductRuleMappingRow[]
legacyFallbackEnabled?: boolean
}) {
const context = buildProductMatchContext(input.order, input.item)
const entries = compileLegacyMatchEntries(input.rules, input.mappings ?? [], {
allowTextFallback: sourceAllowsTextFallback(input.order, context.hasSellerContext),
})
return { context, decision: resolveProductMatchDecision(context, entries) }
}
function sourceAllowsTextFallback(order: OrderRow, hasSellerContext: boolean): boolean {
// 与门面(work-product-match-mappers.ts)保持同一策略:这里只测"允许兜底"的路径。
if (order.provider === 'douyin_mashida' || order.provider === 'pdd') return true
return !hasSellerContext
}
test('抖音:引流标题不参与匹配,按内部 SKU 命中正确模板(不再命中碎梦浮光珍宝礼盒)', () => {
const { context, decision } = decide({
order: douyinOrder,
item: buildDouyinItem(),
rules: [giftBoxRule, commanderRule],
})
assert.equal(context.sourceKey, 'douyin-mashida')
assert.deepEqual(context.shopKeys, ['douyin:305760014'])
assert.deepEqual(context.item.skuTexts, ['指挥官密钥5个'])
assert.equal(context.item.itemTitle, '')
assert.ok(context.item.promoTitle.includes('碎梦浮光珍宝礼盒'))
assert.equal(decision.status, 'matched')
assert.equal(decision.rule?.rule_key, 'bb')
})
test('抖音:命中指挥官模板后按数量计价并开拼单(9.5 元/个 × 5 份)', () => {
const item = buildDouyinItem()
const skuQuantity = resolveWorkOrderQuantity({
skuText: item.sku_code,
fallbackQuantity: item.quantity,
})
const pricing = resolveWorkOrderRulePricing({
skuQuantity,
unitPriceFen: commanderRule.unit_price_fen,
fixedRewardAmount: commanderRule.reward_amount,
sharingEnabled: commanderRule.sharing_enabled === true,
sharingAutoFromOrder: commanderRule.sharing_auto_from_order === true,
sharingTotalQuantity: commanderRule.sharing_total_quantity,
sharingUnitReward: commanderRule.sharing_unit_reward,
})
assert.equal(skuQuantity, 5)
assert.equal(pricing.sharingTotalQuantity, 5)
assert.equal(pricing.sharingUnitReward, 950)
assert.equal(pricing.rewardAmount, 4750)
})
test('店铺键按来源命名空间隔离:抖音订单不会命中快手店铺条件', () => {
const mapping = buildMapping({
rule_id: commanderRule.id,
seller_ids_json: ['305760014'],
sku_nick: '指挥官密钥',
})
const { decision } = decide({
order: douyinOrder,
item: buildDouyinItem(),
rules: [commanderRule],
mappings: [mapping],
})
// 快手命名空间的映射不参与抖音匹配;命中来自抖音自身的 SKU 文本条件。
assert.equal(decision.status, 'matched')
assert.equal(decision.mappingId, null)
})
test('抖音:内部 SKU 之外无文本可用时未命中(不猜),并给出店铺维度诊断', () => {
const rule = buildRule({ id: 15, rule_key: 'cc', product_name: '碎梦浮光珍宝礼盒' })
const mapping = buildMapping({
rule_id: rule.id,
seller_ids_json: ['1088713114'],
sku_nick: '碎梦浮光珍宝礼盒',
})
const { decision } = decide({
order: douyinOrder,
item: buildDouyinItem(),
rules: [rule],
mappings: [mapping],
})
assert.equal(decision.status, 'unmatched')
assert.ok(
decision.diagnosis.some((item) => item.blockedBy.some((r) => r.startsWith('shop_mismatch'))),
)
})
test('拼多多:out_sku_sn 是主匹配键', () => {
const rule = buildRule({ id: 517, rule_key: 'pdd-rule', product_name: '安卓血宴1钻' })
const order = buildOrder({ provider: 'pdd', platform: 'pdd', shop_id: 'xiangrikui' })
const item = buildItem({
sku_code: '安卓血宴1钻',
sku_name: '安卓血宴1钻',
item_snapshot_json: {
goods_id: '961856378217',
goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴',
spec: '安卓区(qv互通),苏尔南-血宴1钻',
out_sku_sn: '安卓血宴1钻',
},
})
const { context, decision } = decide({ order, item, rules: [rule] })
assert.deepEqual(context.shopKeys, ['pdd:xiangrikui'])
assert.equal(decision.status, 'matched')
assert.equal(decision.rule?.id, 517)
})
test('快手91:有卖家上下文且未开兜底时,模板名条件不参与匹配', () => {
const context = buildProductMatchContext(
buildOrder(),
buildItem({
sku_name: '1个精英尊尚专属礼盒',
item_snapshot_json: {
kuaishouSendCode: {
sellerId: '3676797936',
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
skuNick: '1个精英尊尚专属礼盒',
},
},
}),
)
const entries = compileLegacyMatchEntries(
[buildRule({ id: 2, rule_key: 'soldier', product_name: '特种兵' })],
[],
{ allowTextFallback: !context.hasSellerContext },
)
assert.equal(context.hasSellerContext, true)
const decision = resolveProductMatchDecision(context, entries)
assert.equal(decision.status, 'unmatched')
assert.deepEqual(decision.candidates, [])
})
test('快手91:映射受店铺条件约束,跨店铺不命中', () => {
const buildSenderItem = (sellerId: string) =>
buildItem({
sku_name: '指挥官密钥10个',
item_snapshot_json: {
kuaishouSendCode: { sellerId, skuNick: '指挥官密钥10个' },
},
})
const mapping = buildMapping({
rule_id: commanderRule.id,
seller_ids_json: ['3676797936'],
sku_nick: '指挥官密钥',
mapping_type: 'sku_series',
})
const hit = decide({
order: buildOrder({ shop_id: '3676797936' }),
item: buildSenderItem('3676797936'),
rules: [commanderRule],
mappings: [mapping],
})
assert.equal(hit.decision.status, 'matched')
assert.equal(hit.decision.mappingId, 1)
const miss = decide({
order: buildOrder({ shop_id: '9999999999' }),
item: buildSenderItem('9999999999'),
rules: [commanderRule],
mappings: [mapping],
})
assert.equal(miss.decision.status, 'unmatched')
})
test('并列最高分不猜:进入 ambiguous', () => {
const item = buildItem({
sku_code: '1个精英尊尚专属礼盒',
sku_name: '1个精英尊尚专属礼盒',
})
const order = buildOrder({ provider: 'pdd', platform: 'pdd' })
const { decision } = decide({
order,
item,
rules: [
buildRule({ id: 1, rule_key: 'same-a', product_name: '精英尊尚专属礼盒' }),
buildRule({ id: 2, rule_key: 'same-b', product_name: '精英尊尚专属礼盒' }),
],
})
assert.equal(decision.status, 'ambiguous')
assert.equal(decision.rule, null)
assert.equal(decision.candidates.length, 2)
})
test('来源判定:91 / 拼多多 / 抖音各自归到唯一来源', () => {
assert.equal(resolveProductMatchSourceKey(buildOrder()), 'kuaishou-91')
assert.equal(
resolveProductMatchSourceKey(buildOrder({ provider: 'pdd', platform: 'pdd' })),
'pdd',
)
assert.equal(
resolveProductMatchSourceKey(buildOrder({ provider: 'douyin_mashida', platform: 'doudian' })),
'douyin-mashida',
)
})
test('数量归一:文本规格优先,其次订单项数量', () => {
assert.equal(resolveWorkOrderQuantity({ skuText: '指挥官密钥5个', fallbackQuantity: 1 }), 5)
assert.equal(resolveWorkOrderQuantity({ skuText: '指挥官密钥三个', fallbackQuantity: 1 }), 3)
assert.equal(resolveWorkOrderQuantity({ skuText: '套装-牛仔情诗', fallbackQuantity: 3 }), 3)
})
@@ -0,0 +1,122 @@
import type { JsonObject } from '../../types/json.js'
import type { WorkProductRuleRow } from '../../repositories/worker-platform/index.js'
/**
* 匹配来源(适配器粒度)。
*
* 注意粒度是「来源」而不是「平台」:`kuaishou-91` 覆盖 91 回调与快手 send-code 两条数据路
* (91=订单骨架权威源,快手=数据补全源与兜底源),它们共享同一份匹配上下文与店铺键空间。
*/
export type ProductMatchSourceKey = 'kuaishou-91' | 'pdd' | 'douyin-mashida' | 'unknown'
/** SKU/文本类条件的匹配方式。series = 数量系列(剥离「5个」),series-exact 额外要求剥离后完全相等。 */
export type ProductMatchTextMode = 'exact' | 'contains' | 'series' | 'series-exact'
/** 条件可作用的上下文字段(全部来自适配器归一化后的订单信息)。 */
export type ProductMatchConditionField =
| 'skuText'
| 'skuNick'
| 'skuCode'
| 'skuId'
| 'relSkuId'
| 'itemId'
| 'relItemId'
| 'itemTitle'
export type ProductMatchCondition = {
field: ProductMatchConditionField
value: string
mode: ProductMatchTextMode
}
/**
* 平台无关的匹配上下文。适配器只负责把来源原始字段归一到这里;
* 引擎只读这里,不读快照、不判平台。
*/
export type ProductMatchContext = {
sourceKey: ProductMatchSourceKey
provider: string
platform: string
/** 店铺键(命名空间化,如 kuaishou:4661340439 / pdd:xiangrikui / douyin:305760014);空数组=该来源无店铺上下文。 */
shopKeys: string[]
/** 是否具备卖家上下文(快手 send-code 卖家 ID);过渡期用于决定是否允许文本兜底条目。 */
hasSellerContext: boolean
item: {
/** 平台商品 ID */
itemId: string
/** 关联商品 ID(快手 relItemId / 拼多多 goods_id 等) */
relItemId: string
/** 平台 SKU ID */
skuId: string
/** 关联 SKU ID(快手 relSkuId 等) */
relSkuId: string
/** 商家外部 SKU 编码 / 平台编码候选(用于编码精确匹配) */
skuCodes: string[]
/** SKU 规格文本(单值,结构化条件用) */
skuNick: string
/** SKU 文本候选(文本条件用,顺序=可信度);不含引流标题 */
skuTexts: string[]
/** 商品标题(默认不参与匹配,需条件显式声明) */
itemTitle: string
/** 引流标题(关键词堆砌):只用于展示与日志,永不参与匹配 */
promoTitle: string
}
quantity: number
/** 原始载荷:只写日志。 */
raw: JsonObject
}
/** 匹配条目:作用域条件(AND)+ 匹配条件(all=AND、any=OR)。 */
export type ProductMatchEntry = {
/** 条目标识:新表 `entry:<id>`;兼容编译 `legacy:rule:<id>` / `legacy:mapping:<id>`。 */
key: string
ruleId: number
mappingId: number | null
rule: WorkProductRuleRow
/** 必须全部命中(AND)。 */
all: ProductMatchCondition[]
/** 至少命中一个(OR);空数组=无条件。 */
any: ProductMatchCondition[]
/** 加分条件:命中加分、不命中不淘汰(用于旧实现的 +400/+200 之类加成)。 */
bonus: ProductMatchCondition[]
/** 适用来源;空数组=不限来源。 */
sourceKeys: ProductMatchSourceKey[]
/** 适用店铺键;空数组=不限店铺。 */
shopKeys: string[]
/** 数量区间(0=不限)。 */
qtyMin: number
qtyMax: number
/**
* 条目基础分:命中即得。
* 用于表达"条件强度"层级(结构化/映射类条件 > 纯文本条件),避免文本条件与确定性条件同分。
*/
baseScore: number
/** 兼容编译标记:条件来自旧「模板名/商品名」文本兜底。 */
fromRuleName: boolean
}
export type ProductMatchCandidate = {
key: string
ruleKey: string
ruleId: number
mappingId: number | null
score: number
}
/** 未命中诊断:条目被哪一维挡住。 */
export type ProductMatchDiagnosisItem = {
key: string
ruleKey: string
blockedBy: string[]
}
export type ProductMatchDecision = {
status: 'matched' | 'unmatched' | 'ambiguous'
rule: WorkProductRuleRow | null
mappingId: number | null
entryKey: string | null
score: number
candidates: ProductMatchCandidate[]
/** 仅在 unmatched / ambiguous 时填充(最多 10 条)。 */
diagnosis: ProductMatchDiagnosisItem[]
}
@@ -0,0 +1,136 @@
/**
* 后台「按订单项试匹配」:用生产同一套链路(来源适配器 → 引擎 → 价格)重放一张已存在的订单项,
* 直接给出命中模板、数量、单价、接单总额与拼单结果,用于上线前验证与排障。
*
* 与旧的 `product-match-test`(按粘贴的原始报文自行推导字段)不同:这里读真实 `orders` / `order_items`,
* 因此抖音、拼多多、91+快手 都走各自适配器,结果与发单侧完全一致。
*/
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import {
listWorkProductRuleMappings,
listWorkProductRules,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import {
buildProductMatchContext,
compileLegacyMatchEntries,
isTextFallbackAllowed,
resolveProductMatchDecision,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from '../product-match/index.js'
import { mapWorkProductRule } from './work-product-match-mappers.js'
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
export async function testAdminProductMatchByOrderItem(payload: JsonObject = {}) {
const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id)
if (!orderItemId) {
throw createHttpError('缺少订单项 ID', {
statusCode: 400,
errorCode: 'order_item_id_required',
})
}
const item = await getOrderItemById(orderItemId)
if (!item) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'order_item_not_found',
})
}
const order = await getOrderById(item.order_id)
if (!order) {
throw createHttpError('订单不存在', { statusCode: 404, errorCode: 'order_not_found' })
}
const [rules, mappings] = await Promise.all([
listWorkProductRules({ enabled: true }),
listWorkProductRuleMappings({ enabled: true }),
])
const config = getWorkerProductMatchConfig()
const context = buildProductMatchContext(order, item)
const allowTextFallback = isTextFallbackAllowed({
order,
hasSellerContext: context.hasSellerContext,
legacyFallbackEnabled: config.legacyFallbackEnabled,
})
const entries = compileLegacyMatchEntries(rules, mappings, { allowTextFallback })
const decision = resolveProductMatchDecision(context, entries)
return {
order: {
id: Number(order.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
platformOrderId: order.platform_order_id,
totalAmount: Number(order.total_amount || 0),
},
orderItem: {
id: Number(item.id),
skuCode: item.sku_code,
skuName: item.sku_name,
quantity: Number(item.quantity || 1),
matchSkuTexts: context.item.skuTexts,
},
sourceKey: context.sourceKey,
hasSellerContext: context.hasSellerContext,
allowTextFallback,
shopKeys: context.shopKeys,
status: decision.status,
rule: decision.rule ? mapWorkProductRule(decision.rule) : null,
mappingId: decision.mappingId,
entryKey: decision.entryKey,
score: decision.score,
candidates: decision.candidates,
diagnosis: decision.diagnosis,
plan: decision.rule ? buildWorkOrderPlan(decision.rule, item) : null,
counts: { enabledRules: rules.length, enabledMappings: mappings.length },
}
}
/** 与发单侧同口径的工单计划:数量 / 单价 / 接单总额 / 拼单。 */
function buildWorkOrderPlan(
rule: Awaited<ReturnType<typeof listWorkProductRules>>[number],
item: { sku_code: string; sku_name: string; quantity: number },
) {
const skuQuantity = resolveWorkOrderQuantity({
skuText: item.sku_name || item.sku_code,
fallbackQuantity: item.quantity,
})
const unitPriceFen = Number(rule.unit_price_fen || 0)
const pricing = resolveWorkOrderRulePricing({
skuQuantity,
unitPriceFen,
fixedRewardAmount: Number(rule.reward_amount || 0),
sharingEnabled: rule.sharing_enabled === true,
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
})
const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode)
? false
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, pricing.sharingTotalQuantity)
return {
skuQuantity,
unitPriceFen,
pricingMode: unitPriceFen > 0 ? 'unit' : 'fixed',
rewardAmount: pricing.rewardAmount,
sharingEnabled,
sharingTotalQuantity: sharingEnabled ? pricing.sharingTotalQuantity : 1,
sharingUnitReward: sharingEnabled ? pricing.sharingUnitReward : 0,
acceptanceMode: isFriendGiftAcceptanceMode(rule.acceptance_mode) ? 'friend_gift' : 'standard',
}
}
function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0) return null
return parsed
}
@@ -26,6 +26,7 @@ export * from './admin-worker-finance-service.js'
export * from './admin-worker-catalog-service.js'
export * from './admin-worker-leaderboard-service.js'
export * from './admin-kuaishou-match-service.js'
export * from './admin-product-match-test-service.js'
export * from './admin-worker-config-service.js'
export * from './admin-worker-user-service.js'
export * from './admin-worker-risk-service.js'
@@ -1,96 +1,11 @@
export function resolveSkuNameQuantity(productName: unknown): number {
const text = String(productName || '').trim()
if (!text) return 1
const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/)
if (arabicMatch) {
const parsed = Number(arabicMatch[1])
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed
}
const chineseMatch = text.match(/([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/)
if (chineseMatch?.[1]) {
const parsed = parseChineseNumber(chineseMatch[1])
if (parsed > 0) return parsed
}
return 1
}
export function resolveWorkOrderRulePricing(input: {
skuQuantity: number
unitPriceFen: number
fixedRewardAmount: number
sharingEnabled: boolean
sharingAutoFromOrder: boolean
sharingTotalQuantity: number
sharingUnitReward: number
}) {
const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1)
const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0)
const sharingTotalQuantity = input.sharingAutoFromOrder
? skuQuantity
: Math.max(1, Number(input.sharingTotalQuantity) || 1)
const sharingUnitReward = input.sharingAutoFromOrder
? unitPriceFen
: Math.max(0, Number(input.sharingUnitReward) || 0)
const rewardAmount = input.sharingEnabled
? sharingTotalQuantity * sharingUnitReward
: unitPriceFen > 0
? unitPriceFen * skuQuantity
: Math.max(0, Number(input.fixedRewardAmount) || 0)
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
}
export function resolveWorkOrderSharingEnabled(
configuredEnabled: boolean,
totalQuantity: number,
): boolean {
return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1
}
export function isWorkOrderSharingFilled(
sharingEnabled: boolean,
totalQuantity: number,
joinedQuantity: number,
): boolean {
return (
sharingEnabled &&
Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1)
)
}
const CHINESE_DIGITS: Record<string, number> = {
零: 0,
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
}
function parseChineseNumber(text: string): number {
let section = 0
let digit = 0
for (const char of text) {
if (char === '十') {
section += (digit || 1) * 10
digit = 0
} else if (char === '百') {
section += (digit || 1) * 100
digit = 0
} else if (char === '千') {
section += (digit || 1) * 1000
digit = 0
} else if (char in CHINESE_DIGITS) {
digit = CHINESE_DIGITS[char] ?? 0
}
}
return section + digit
}
/**
* 价格/数量计算已迁到 `services/product-match/pricing.ts`(匹配引擎的一部分)。
* 本文件保留为转发层,避免调用方一次性大改;新代码请直接从 product-match 引入。
*/
export {
isWorkOrderSharingFilled,
resolveSkuNameQuantity,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from '../product-match/pricing.js'
@@ -1,17 +1,39 @@
/**
* 接单模板匹配的门面(过渡期)。
*
* 实现在 `services/product-match/`(来源适配器 + 引擎 + 价格),本文件只保留:
* - 旧的对外函数签名(发单服务、后台匹配测试/重匹配、测试都在用);
* - 旧配置 → 匹配条目的兼容编译调用;
* - 未命中诊断(把条目被哪一维挡住翻译成旧的拒绝原因文案)。
*
* 新增来源请改 `services/product-match/adapters/`,不要在这里加平台分支。
*/
import {
type WorkProductRuleMappingRow,
type WorkProductRuleRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
buildProductMatchContext,
compileLegacyMatchEntries,
isTextFallbackAllowed,
normalizeMatchId,
normalizeMatchText,
resolveKuaishouWorkProductMatchContext,
resolveProductMatchDecision,
type ProductMatchDiagnosisItem,
} from '../product-match/index.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { normalizeRequirementFields } from './work-order-requirement-fields.js'
export { resolveKuaishouWorkProductMatchContext }
export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
if (!rule) return null
const requirement = safeParseJson(rule.requirement_json)
@@ -78,17 +100,13 @@ export type WorkProductRuleMatchDecision = {
reason: 'matched' | 'unmatched' | 'ambiguous'
score: number
candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }>
/** 未命中/歧义时:每个候选条目差在哪一维(最多 10 条)。 */
diagnosis?: ProductMatchDiagnosisItem[]
}
export type KuaishouWorkProductMatchContext = {
sellerId: string
itemId: string
relItemId: string
skuId: string
relSkuId: string
itemTitle: string
skuNick: string
}
export type KuaishouWorkProductMatchContext = ReturnType<
typeof resolveKuaishouWorkProductMatchContext
>
export function resolveMatchingProductRuleDecision(
order: OrderRow,
@@ -97,280 +115,29 @@ export function resolveMatchingProductRuleDecision(
mappings: WorkProductRuleMappingRow[] = [],
options: { legacyFallbackEnabled?: boolean } = {},
): WorkProductRuleMatchDecision {
const context = resolveKuaishouWorkProductMatchContext(item)
const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule]))
// 快手发码订单默认只允许确定性商品映射;非快手订单保留原有规则,避免影响其他来源。
const useLegacyFallback = !context.sellerId || options.legacyFallbackEnabled === true
const candidates = [
...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)),
...(useLegacyFallback ? rules.map((rule) => scoreWorkProductRule(order, item, rule)) : []),
]
.filter(
(candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } =>
Boolean(candidate),
)
.sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id))
const top = candidates[0]
if (!top) {
return { rule: null, mappingId: null, reason: 'unmatched', score: 0, candidates: [] }
}
const tied = candidates.filter((candidate) => candidate.score === top.score)
if (tied.length > 1) {
return {
rule: null,
mappingId: null,
reason: 'ambiguous',
score: top.score,
candidates: tied.map((candidate) => ({
ruleKey: candidate.rule.rule_key,
score: candidate.score,
mappingId: candidate.mappingId || null,
})),
}
}
const context = buildProductMatchContext(order, item)
const allowTextFallback = isTextFallbackAllowed({
order,
hasSellerContext: context.hasSellerContext,
legacyFallbackEnabled: options.legacyFallbackEnabled === true,
})
const entries = compileLegacyMatchEntries(rules, mappings, { allowTextFallback })
const decision = resolveProductMatchDecision(context, entries)
return {
rule: top.rule,
mappingId: top.mappingId || null,
reason: 'matched',
score: top.score,
candidates: candidates.slice(0, 5).map((candidate) => ({
ruleKey: candidate.rule.rule_key,
rule: decision.rule,
mappingId: decision.mappingId,
reason: decision.status,
score: decision.score,
candidates: decision.candidates.map((candidate) => ({
ruleKey: candidate.ruleKey,
score: candidate.score,
mappingId: candidate.mappingId || null,
mappingId: candidate.mappingId,
})),
diagnosis: decision.diagnosis,
}
}
export function resolveKuaishouWorkProductMatchContext(
item: Pick<OrderItemRow, 'item_snapshot_json'>,
): KuaishouWorkProductMatchContext {
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
return {
sellerId: String(source.sellerId || '').trim(),
itemId: String(source.itemId || '').trim(),
relItemId: normalizeExternalMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: String(source.skuId || snapshot.sku_id || '').trim(),
relSkuId: normalizeExternalMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: String(source.itemTitle || snapshot.goodsName || snapshot.goods_name || '').trim(),
skuNick: String(
source.skuNick ||
source.skuName ||
outSkuSn ||
snapshot.externalSkuName ||
snapshot.spec ||
'',
).trim(),
}
}
function scoreWorkProductRuleMapping(
context: KuaishouWorkProductMatchContext,
mapping: WorkProductRuleMappingRow,
rulesById: Map<number, WorkProductRuleRow>,
) {
if (!mapping.enabled) return null
const rule = rulesById.get(Number(mapping.rule_id))
if (!rule || !rule.enabled) return null
if (
!mappingSellerIds(mapping.seller_ids_json).some((sellerId) =>
sameMatchId(context.sellerId, sellerId),
)
) {
return null
}
const mappingType =
mapping.mapping_type === 'sku_series'
? 'sku_series'
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
? 'sku_exact'
: 'product_default'
const mappedItemIds = normalizeMatchIdList(mapping.rel_item_id)
const mappedTitle = normalizeMatchText(mapping.item_title)
const hasContextItemId = Boolean(normalizeMatchId(context.relItemId))
const itemIdMatched = mappedItemIds.some((mappedItemId) =>
sameMatchId(context.relItemId, mappedItemId),
)
const titleMatched = Boolean(
mappedTitle && matchNameCondition(normalizeMatchText(context.itemTitle), mappedTitle, true),
)
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
if (mappingType === 'product_default' || hasProductScope) {
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。
if (
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
(mappedItemIds.length === 0 && !titleMatched)
) {
return null
}
}
let score = mappingType === 'sku_exact' ? 10_000 : mappingType === 'sku_series' ? 8_000 : 5_000
if (itemIdMatched) score += 400
if (titleMatched) score += 200
if (mappingType === 'sku_exact') {
const mappedSkuId = normalizeMatchId(mapping.rel_sku_id)
const mappedSkuNick = normalizeMatchText(mapping.sku_nick)
if (!mappedSkuId && !mappedSkuNick) return null
const skuIdMatched = Boolean(mappedSkuId && sameMatchId(context.relSkuId, mappedSkuId))
const skuNickMatched = Boolean(
mappedSkuNick && matchNameCondition(normalizeMatchText(context.skuNick), mappedSkuNick, true),
)
if (!skuIdMatched && !skuNickMatched) {
return null
}
if (skuIdMatched) score += 400
if (skuNickMatched) score += 200
}
if (mappingType === 'sku_series') {
const skuMatchMode = mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy'
if (!matchSkuSeriesName(context.skuNick, mapping.sku_nick, skuMatchMode)) return null
score += 200
}
return { rule, score, mappingId: Number(mapping.id) }
}
function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) {
if (!matchesRuleProviderPlatform(rule, order)) return null
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const context = {
sellerId: normalizeMatchId(source.sellerId),
itemId: normalizeMatchId(source.itemId),
relItemId: normalizeMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: normalizeMatchId(source.skuId),
relSkuId: normalizeMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: normalizeMatchText(
source.itemTitle || snapshot.goodsName || snapshot.goods_name || '',
),
// 拼多多等平台:结构化匹配也要读 out_sku_sn,避免有编码却未命中
skuNick: normalizeMatchText(
source.skuNick || source.skuName || outSkuSn || snapshot.externalSkuName || snapshot.spec,
),
}
const ruleShopId = normalizeMatchId(rule.shop_id)
if (
ruleShopId &&
ruleShopId !== normalizeMatchId(order.shop_id) &&
ruleShopId !== context.sellerId
) {
return null
}
const match = safeParseJson(rule.match_json)
const hasStructuredMatch = Object.entries(match).some(
([key, value]) =>
!['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(),
)
// 结构化 match_json(sellerId/skuNick 等)是快手发码路径;拼多多等无店铺上下文的订单
// 若误入该分支,常因缺 sellerId 直接失败,且不会回退到模板名/out_sku_sn。
// 仅当订单带有店铺上下文时才走结构化匹配。
if (hasStructuredMatch && context.sellerId) {
let score = 100
if (!matchCondition(context.sellerId, match.sellerId)) return null
if (match.sellerId) score += 1000
if (!matchCondition(context.itemId, match.itemId)) return null
if (match.itemId) score += 800
if (!matchCondition(context.relItemId, match.relItemId)) return null
if (match.relItemId) score += 700
if (!matchCondition(context.skuId, match.skuId)) return null
if (match.skuId) score += 1000
if (!matchCondition(context.relSkuId, match.relSkuId)) return null
if (match.relSkuId) score += 900
const itemTitle = normalizeMatchText(match.itemTitle)
if (itemTitle) {
const exact = match.itemTitleMatchType === 'exact'
if (!matchNameCondition(context.itemTitle, itemTitle, exact)) return null
score += exact ? 600 : 250
}
const skuNick = normalizeMatchText(match.skuNick)
if (skuNick) {
const exact = match.skuNickMatchType === 'exact'
if (!matchNameCondition(context.skuNick, skuNick, exact)) return null
score += exact ? 800 : 350
}
return { rule, score }
}
const skuCodes = [item.sku_code, snapshot.externalSkuCode, outSkuSn, snapshot.externalItemId]
.map(normalizeMatchId)
.filter(Boolean)
const skuNames = [
item.sku_name,
snapshot.externalSkuName,
outSkuSn,
snapshot.spec,
snapshot.externalSkuNameNormalized,
source.skuNick,
source.itemTitle,
]
.map(normalizeMatchText)
.filter(Boolean)
const ruleSkuCode = normalizeMatchId(rule.sku_code)
const ruleProductName = normalizeMatchText(rule.product_name)
const skuCodeMatched = Boolean(ruleSkuCode && skuCodes.includes(ruleSkuCode))
const exactProductName = Boolean(
ruleProductName && (skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName)),
)
const containsProductName = Boolean(
ruleProductName &&
!exactProductName &&
rule.match_type !== 'exact' &&
(skuNames.some((value) => value.includes(ruleProductName)) ||
skuCodes.some((value) => value.includes(ruleProductName))),
)
// sku_code 与 product_name 任一命中即可
if (!skuCodeMatched && !exactProductName && !containsProductName) return null
// 同分冲突常见场景:短名「安卓血宴5钻」contains 命中长编码「安卓血宴5钻带2璀璨」
// 精确相等优先;contains 则更长的模板名更具体、分更高
let score = 100
if (exactProductName) {
score = 250
} else if (containsProductName) {
score = 100 + Math.min(80, ruleProductName.length)
}
if (skuCodeMatched) score += exactProductName || containsProductName ? 40 : 120
return { rule, score }
}
/** 拼多多常复用原快手模板;来源/平台残留 91kaquan、kuaishou 时不拦截 out_sku_sn 模板名匹配。 */
function matchesRuleProviderPlatform(rule: WorkProductRuleRow, order: OrderRow) {
if (
matchesOptionalText(rule.provider, order.provider) &&
matchesOptionalText(rule.platform, order.platform)
) {
return true
}
const orderProvider = String(order.provider || '')
.trim()
.toLowerCase()
const orderPlatform = String(order.platform || '')
.trim()
.toLowerCase()
const orderIsPdd = orderProvider === 'pdd' || orderPlatform === 'pdd'
if (!orderIsPdd) return false
const ruleProvider = String(rule.provider || '')
.trim()
.toLowerCase()
const rulePlatform = String(rule.platform || '')
.trim()
.toLowerCase()
const providerOk = !ruleProvider || ruleProvider === 'pdd' || ruleProvider === '91kaquan'
const platformOk = !rulePlatform || rulePlatform === 'pdd' || rulePlatform === 'kuaishou'
return providerOk && platformOk
}
export type WorkProductRuleRejectSample = {
ruleKey: string
productName: string
@@ -381,18 +148,27 @@ export type WorkProductRuleRejectSample = {
reason: string
}
/** 诊断未命中原因:找出与订单 SKU/标题相近但被过滤的模板。 */
/** 诊断未命中原因:找出与订单 SKU/标题相近但被挡住的模板。 */
export function diagnoseUnmatchedProductRules(
order: OrderRow,
item: OrderItemRow,
rules: WorkProductRuleRow[],
limit = 8,
): WorkProductRuleRejectSample[] {
const context = buildProductMatchContext(order, item)
// 诊断始终编译文本条件,才能解释"为什么模板名没命中"。
const entries = compileLegacyMatchEntries(rules, [], { allowTextFallback: true })
const decision = resolveProductMatchDecision(context, entries)
const blockedByEntryKey = new Map(
decision.diagnosis.map((entry) => [entry.key, entry.blockedBy] as const),
)
const snapshot = safeParseJson(item.item_snapshot_json)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const needle = normalizeMatchText(
outSkuSn || item.sku_name || item.sku_code || snapshot.spec || '',
)
const samples: WorkProductRuleRejectSample[] = []
for (const rule of rules) {
if (!rule.enabled) continue
@@ -402,32 +178,13 @@ export function diagnoseUnmatchedProductRules(
!needle ||
productName.includes(needle) ||
needle.includes(productName) ||
(skuCode && (skuCode.includes(needle) || needle.includes(skuCode)))
(skuCode !== '' && (skuCode.includes(needle) || needle.includes(skuCode)))
if (!related && needle) continue
const scored = scoreWorkProductRule(order, item, rule)
if (scored) continue
let reason = 'name_or_sku_not_matched'
if (!matchesRuleProviderPlatform(rule, order)) {
reason = `provider_platform_mismatch(rule=${rule.provider || '-'}/${rule.platform || '-'}, order=${order.provider || '-'}/${order.platform || '-'})`
} else {
const ruleShopId = normalizeMatchId(rule.shop_id)
const source = safeParseJson(snapshot.kuaishouSendCode)
const sellerId = normalizeMatchId(source.sellerId)
if (ruleShopId && ruleShopId !== normalizeMatchId(order.shop_id) && ruleShopId !== sellerId) {
reason = `shop_id_mismatch(rule=${rule.shop_id}, order=${order.shop_id || '-'})`
} else {
const match = safeParseJson(rule.match_json)
const hasStructuredMatch = Object.entries(match).some(
([key, value]) =>
!['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(),
)
if (hasStructuredMatch && sellerId) {
reason = 'structured_match_failed'
}
}
}
const blockedBy = entries
.filter((entry) => entry.ruleId === Number(rule.id))
.flatMap((entry) => blockedByEntryKey.get(entry.key) ?? [])
if (blockedBy.length === 0) continue
samples.push({
ruleKey: rule.rule_key,
@@ -436,88 +193,29 @@ export function diagnoseUnmatchedProductRules(
platform: rule.platform || '',
shopId: rule.shop_id || '',
skuCode: rule.sku_code || '',
reason,
reason: summarizeRejectReason(blockedBy),
})
if (samples.length >= limit) break
}
return samples
}
function matchCondition(actual: string, expected: unknown) {
const value = normalizeMatchId(expected)
return !value || actual === value
}
function matchNameCondition(actual: string, expected: string, exact: boolean) {
return Boolean(actual) && (exact ? actual === expected : actual.includes(expected))
}
function normalizeMatchId(value: unknown) {
const normalized = String(value || '').trim()
return normalized === '0' ? '' : normalized.toLowerCase()
}
function normalizeMatchIdList(value: unknown) {
const rawValues = Array.isArray(value) ? value : [value]
return Array.from(
new Set(
rawValues
.flatMap((entry) => String(entry || '').split(/[,,;;\s\n\r]+/))
.map(normalizeMatchId)
.filter(Boolean),
),
function summarizeRejectReason(blockedBy: string[]): string {
const shopBlocked = blockedBy.find((reason) => reason.startsWith('shop_mismatch'))
if (shopBlocked) return shopBlocked
const sourceBlocked = blockedBy.find((reason) => reason.startsWith('source_mismatch'))
if (sourceBlocked) return sourceBlocked
const quantityBlocked = blockedBy.find((reason) => reason.startsWith('quantity_'))
if (quantityBlocked) return quantityBlocked
const structuredBlocked = blockedBy.find(
(reason) =>
reason.includes(':skuId:') ||
reason.includes(':relSkuId:') ||
reason.includes(':itemId:') ||
reason.includes(':relItemId:'),
)
}
function normalizeExternalMatchId(value: unknown) {
const normalized = String(value || '').trim()
return normalized === '0' ? '' : normalized
}
function sameMatchId(left: unknown, right: unknown) {
const normalizedLeft = normalizeMatchId(left)
const normalizedRight = normalizeMatchId(right)
return Boolean(normalizedLeft) && normalizedLeft === normalizedRight
}
function normalizeMatchText(value: unknown) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\u3000]+/g, '')
.trim()
}
function mappingSellerIds(value: unknown) {
const parsed = Array.isArray(value) ? value : safeParseJson(value)
const values = Array.isArray(parsed) ? parsed : []
return Array.from(new Set(values.map(normalizeMatchId).filter(Boolean)))
}
/**
* 去除 SKU 中的数量规格后得到系列名。
* 例如“指挥官密钥10个”和“1个盛夏礼卡”分别归一为“指挥官密钥”“盛夏礼卡”。
*/
function normalizeSkuSeriesName(value: unknown) {
return normalizeMatchText(value).replace(
/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g,
'',
)
}
function matchSkuSeriesName(actual: unknown, expected: unknown, matchMode: 'fuzzy' | 'exact') {
const normalizedActual = normalizeSkuSeriesName(actual)
const normalizedExpected = normalizeSkuSeriesName(expected)
if (!normalizedActual || !normalizedExpected) return false
if (matchMode === 'exact') return normalizedActual === normalizedExpected
const fuzzyActual = normalizeFuzzySkuSeriesName(normalizedActual)
const fuzzyExpected = normalizeFuzzySkuSeriesName(normalizedExpected)
return fuzzyActual.includes(fuzzyExpected) || fuzzyExpected.includes(fuzzyActual)
}
function normalizeFuzzySkuSeriesName(value: string) {
return value.replace(/秘钥|密匙|秘匙/g, '密钥')
if (structuredBlocked) return 'structured_match_failed'
return 'name_or_sku_not_matched'
}
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
@@ -38,11 +38,14 @@ import {
cleanupAdminWorkProductMatchLogs,
saveAdminWorkerProductMatchConfig,
testAdminKuaishouProductMatch,
testAdminProductMatchByItem,
type AdminProductMatchTestByItemResult,
} from '@/services/admin'
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatMoney } from './shared'
export default function ProductMatchPanel() {
const { message } = App.useApp()
@@ -52,6 +55,11 @@ export default function ProductMatchPanel() {
const [testResult, setTestResult] = useState<
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
>(null)
const [testItemId, setTestItemId] = useState<number | null>(null)
const [itemTestResult, setItemTestResult] = useState<AdminProductMatchTestByItemResult | null>(
null,
)
const [testingByItem, setTestingByItem] = useState(false)
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | null>(null)
const [activeTab, setActiveTab] = useState('sources')
const [logStatus, setLogStatus] = useState('')
@@ -180,6 +188,22 @@ export default function ProductMatchPanel() {
}
}
async function runItemMatchTest() {
if (!testItemId) {
message.warning('请输入订单项 ID')
return
}
setTestingByItem(true)
try {
const response = await testAdminProductMatchByItem(testItemId)
setItemTestResult(response.data)
} catch (error) {
message.error(error instanceof Error ? error.message : '试匹配失败')
} finally {
setTestingByItem(false)
}
}
async function updateLegacyFallback(legacyFallbackEnabled: boolean) {
try {
await saveAdminWorkerProductMatchConfig({ legacyFallbackEnabled })
@@ -353,6 +377,94 @@ export default function ProductMatchPanel() {
label: '匹配测试',
children: (
<div className="page-stack">
<Card size="small" title="按订单项试匹配(与发单侧同一套链路)">
<Space wrap>
<InputNumber
min={1}
style={{ width: 200 }}
placeholder="订单项 ID,如 334088"
value={testItemId}
onChange={(value) => setTestItemId(typeof value === 'number' ? value : null)}
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={testingByItem}
onClick={() => void runItemMatchTest()}
>
试匹配
</Button>
<Typography.Text type="secondary">
读真实订单项 → 来源适配器 → 匹配引擎 → 数量/单价/拼单;抖音、拼多多、91
都适用。
</Typography.Text>
</Space>
{itemTestResult ? (
<div className="page-stack" style={{ marginTop: 12 }}>
<Descriptions bordered size="small" column={{ xs: 1, md: 3 }}>
<Descriptions.Item label="来源">
{itemTestResult.sourceKey}
</Descriptions.Item>
<Descriptions.Item label="结果">
<MatchStatusTag status={itemTestResult.status} />
</Descriptions.Item>
<Descriptions.Item label="命中模板">
{itemTestResult.rule?.productName || itemTestResult.rule?.ruleKey || '-'}
</Descriptions.Item>
<Descriptions.Item label="匹配文本">
{itemTestResult.orderItem.matchSkuTexts.join(' / ') || '-'}
</Descriptions.Item>
<Descriptions.Item label="店铺键">
{itemTestResult.shopKeys.join(' / ') || '-'}
</Descriptions.Item>
<Descriptions.Item label="文本兜底">
{itemTestResult.allowTextFallback ? '允许' : '不允许'}
</Descriptions.Item>
<Descriptions.Item label="数量">
{itemTestResult.plan ? `${itemTestResult.plan.skuQuantity} 份` : '-'}
</Descriptions.Item>
<Descriptions.Item
label={
itemTestResult.plan?.pricingMode === 'unit' ? '按件单价' : '固定金额'
}
>
{itemTestResult.plan
? formatMoney(
itemTestResult.plan.pricingMode === 'unit'
? itemTestResult.plan.unitPriceFen
: itemTestResult.plan.rewardAmount,
)
: '-'}
</Descriptions.Item>
<Descriptions.Item label="接单总额">
<Typography.Text strong style={{ color: '#3f8600' }}>
{itemTestResult.plan
? formatMoney(itemTestResult.plan.rewardAmount)
: '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="拼单">
{itemTestResult.plan?.sharingEnabled
? `开 · ${itemTestResult.plan.sharingTotalQuantity} 份 × ${formatMoney(itemTestResult.plan.sharingUnitReward)}`
: '关'}
</Descriptions.Item>
<Descriptions.Item label="命中条目">
{itemTestResult.entryKey || '-'}
</Descriptions.Item>
<Descriptions.Item label="店铺">
{itemTestResult.order.shopName || itemTestResult.order.shopId}
</Descriptions.Item>
</Descriptions>
<JsonPreview
value={{
candidates: itemTestResult.candidates,
diagnosis: itemTestResult.diagnosis,
counts: itemTestResult.counts,
}}
/>
</div>
) : null}
</Card>
<Space wrap>
<Select
showSearch
@@ -385,6 +385,55 @@ export function testAdminKuaishouProductMatch(rawPayload: string) {
}>('/api/v1/admin/worker-platform/product-match-test', { rawPayload })
}
export type AdminProductMatchTestByItemResult = {
order: {
id: number
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
totalAmount: number
}
orderItem: {
id: number
skuCode: string
skuName: string
quantity: number
matchSkuTexts: string[]
}
sourceKey: string
hasSellerContext: boolean
allowTextFallback: boolean
shopKeys: string[]
status: 'matched' | 'unmatched' | 'ambiguous'
rule: WorkProductRule | null
mappingId: number | null
entryKey: string | null
score: number
candidates: Array<{ key: string; ruleKey: string; score: number; mappingId: number | null }>
diagnosis: Array<{ key: string; ruleKey: string; blockedBy: string[] }>
plan: {
skuQuantity: number
unitPriceFen: number
pricingMode: 'unit' | 'fixed'
rewardAmount: number
sharingEnabled: boolean
sharingTotalQuantity: number
sharingUnitReward: number
acceptanceMode: string
} | null
counts: { enabledRules: number; enabledMappings: number }
}
/** 按订单项 ID 试匹配:与发单侧完全一致的来源适配器 + 引擎 + 价格(任何来源都适用)。 */
export function testAdminProductMatchByItem(orderItemId: number) {
return apiPost<AdminProductMatchTestByItemResult>(
'/api/v1/admin/worker-platform/product-match-test-item',
{ orderItemId },
)
}
export function fetchAdminWorkProductMatchLogs(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkProductMatchLog>>(
'/api/v1/admin/worker-platform/product-match-logs',
+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` | **删除**(不再有第二套算法) |