优化履约匹配:指定匹配(91中文名→sku映射)最高优先 + 映射键归一化

- 91 商品编码全部为中文名(幸运币90个等),运营按中文名→affiliate_dash sku 配置显式映射
- resolveFulfillmentRoute 新增 preferredExecutorKey:映射命中强制优先(规则之前),停用/不可用回退原逻辑
- matchAffiliateDashSku 归一化:NFKC 全角→半角、去空白、小写,先精确再遍历
- preferredMatchEnabled 配置(默认 true,可一键回退,不耽误现有 cloud/feifei 生产)
- 测试 +9(221 全绿);行为验证:映射命中/全角/空格命中,未映射回退
This commit is contained in:
yml2213
2026-08-05 16:44:37 +08:00
parent 5f5506bb19
commit 769d41655a
9 changed files with 249 additions and 3 deletions
+1
View File
@@ -84,6 +84,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
timeoutMs: 10000,
notifyUrl: '',
timestampToleranceSeconds: 300,
preferredMatchEnabled: true,
skuMapping: {},
},
cloudtentacles: {
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
matchAffiliateDashSku,
normalizeAffiliateDashMappingKey,
} from './product-resolution-service.js'
test('matchAffiliateDashSku 原样精确命中', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '幸运币90个'), 'lucky_coin_x90')
})
test('matchAffiliateDashSku 全角数字/空格归一命中', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '幸运币90个'), 'lucky_coin_x90')
assert.equal(matchAffiliateDashSku(mapping, ' 幸运币 90 个 '), 'lucky_coin_x90')
})
test('matchAffiliateDashSku 大小写归一命中', () => {
const mapping = { '星际漫游服装礼包': 'pack_star_roam_outfit' }
assert.equal(matchAffiliateDashSku(mapping, '星际漫游服装礼包'), 'pack_star_roam_outfit')
})
test('matchAffiliateDashSku 未命中返回空串', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '神秘新商品'), '')
assert.equal(matchAffiliateDashSku(mapping, ''), '')
})
test('normalizeAffiliateDashMappingKey NFKC + 去空白 + 小写', () => {
assert.equal(normalizeAffiliateDashMappingKey(' 幸运币90个 '), '幸运币90个')
assert.equal(normalizeAffiliateDashMappingKey('M416-仓鼠灰灰'), 'm416-仓鼠灰灰')
})
@@ -12,6 +12,7 @@ import {
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
export type FulfillmentItem = {
@@ -234,8 +235,15 @@ async function resolveConfiguredItemCandidate({
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
const affiliateDashMatch = resolveAffiliateDashSkuByProductNo(item)
// 指定匹配(显式映射)命中时优先走 affiliate_dash(可配置关闭,默认开)
const affiliateDashConfig = getAffiliateDashConfig()
const preferredExecutorKey =
affiliateDashMatch && affiliateDashConfig.preferredMatchEnabled !== false
? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
: ''
const fulfillmentRoute = resolveFulfillmentRoute({
productName: externalSkuName,
preferredExecutorKey,
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
@@ -314,9 +322,7 @@ function resolveAffiliateDashSkuByProductNo(item: FulfillmentItem): AffiliateDas
item.externalSkuCode,
item.externalItemId,
])
const sku = productNo
? String(config.skuMapping[String(productNo)] || '').trim()
: ''
const sku = productNo ? matchAffiliateDashSku(config.skuMapping, String(productNo)) : ''
if (!productNo || !sku) {
return null
}
@@ -329,6 +335,40 @@ function resolveAffiliateDashSkuByProductNo(item: FulfillmentItem): AffiliateDas
}
}
/**
* 91 商品编码为中文名(如「幸运币90个」),映射键查询做归一化:
* NFKC(全角→半角)→ 去所有空白 → 小写。先原样精确,再归一化遍历。
*/
export function matchAffiliateDashSku(
mapping: AffiliateDashSkuMapping,
productNo: string,
): string {
const raw = String(productNo || '').trim()
if (!raw) {
return ''
}
const direct = String(mapping[raw] || '').trim()
if (direct) {
return direct
}
const normalized = normalizeAffiliateDashMappingKey(raw)
for (const [key, sku] of Object.entries(mapping || {})) {
if (normalizeAffiliateDashMappingKey(key) === normalized) {
return String(sku || '').trim()
}
}
return ''
}
export function normalizeAffiliateDashMappingKey(value: unknown): string {
return String(value || '')
.normalize('NFKC')
.replace(/\s+/g, '')
.toLowerCase()
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
return String(provider || '').trim() === '91kaquan' &&
String(platform || '').trim() === 'kuaishou'
@@ -89,3 +89,71 @@ test('resolveFulfillmentRoute 商品规则可以强制转人工', () => {
assert.equal(result.selectedExecutorKey, 'manual_dispatch')
assert.equal(result.reason, '命中商品路由规则')
})
test('resolveFulfillmentRoute 指定匹配(显式映射)最高优先', () => {
const result = resolveFulfillmentRoute({
productName: '幸运币90个',
preferredExecutorKey: 'affiliate_dash',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true, reason: 'cloud 名字命中' },
{ executorKey: 'kuaishou_feifei', available: true, reason: 'feifei 名字命中' },
{ executorKey: 'affiliate_dash', available: true },
],
config: normalizeFulfillmentRoutingConfig({}),
})
assert.equal(result.selectedExecutorKey, 'affiliate_dash')
assert.equal(result.reason, '命中指定匹配(显式映射),优先选择')
})
test('resolveFulfillmentRoute 指定匹配命中但通道停用时回退全局优先级', () => {
const result = resolveFulfillmentRoute({
productName: '幸运币90个',
preferredExecutorKey: 'affiliate_dash',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: true },
{ executorKey: 'affiliate_dash', available: true },
],
config: normalizeFulfillmentRoutingConfig({
executors: {
affiliate_dash: { enabled: false },
},
}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted')
assert.ok(result.skipped.some((item) => item.executorKey === 'affiliate_dash'))
})
test('resolveFulfillmentRoute 指定匹配命中但商品不可用时回退全局优先级', () => {
const result = resolveFulfillmentRoute({
productName: '幸运币90个',
preferredExecutorKey: 'affiliate_dash',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: false },
{ executorKey: 'affiliate_dash', available: false, reason: '未命中 sku 映射' },
],
config: normalizeFulfillmentRoutingConfig({}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted')
assert.ok(result.skipped.some((item) => item.executorKey === 'affiliate_dash'))
})
test('resolveFulfillmentRoute 无指定匹配时按全局优先级选择', () => {
const result = resolveFulfillmentRoute({
productName: '幸运币90个',
preferredExecutorKey: '',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: false },
{ executorKey: 'affiliate_dash', available: true },
],
config: normalizeFulfillmentRoutingConfig({}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted')
assert.equal(result.reason, '按全局优先级命中')
})
@@ -137,10 +137,12 @@ export function resolveFulfillmentRoute({
productName,
candidates,
config = getFulfillmentRoutingConfig(),
preferredExecutorKey = '',
}: {
productName: unknown
candidates: FulfillmentRoutingCandidate[]
config?: FulfillmentRoutingConfig
preferredExecutorKey?: string
}): FulfillmentRouteResult {
const normalizedConfig = normalizeFulfillmentRoutingConfig(config)
const normalizedCandidates = normalizeRoutingCandidates(candidates)
@@ -156,6 +158,22 @@ export function resolveFulfillmentRoute({
})
}
// 指定匹配(显式映射)最高优先:命中且通道可用时直接选择,不受全局优先级影响。
const preferred = normalizeExecutorKey(preferredExecutorKey)
if (preferred) {
const selected = resolveExecutorSelection({
executorKey: preferred,
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reason: '命中指定匹配(显式映射),优先选择',
matchedRule: null,
})
if (selected.selectedExecutorKey) {
return selected
}
}
const matchedRule = findMatchedRoutingRule(productName, normalizedConfig.rules)
if (matchedRule) {
const selected = resolveExecutorSelection({
@@ -29,6 +29,7 @@ export function getAffiliateDashConfig(overrides: Partial<AffiliateDashRuntimeCo
notifyUrl: String(config.notifyUrl || '').trim(),
timestampToleranceSeconds:
Math.max(1, Number(config.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS) || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS),
preferredMatchEnabled: config.preferredMatchEnabled !== false,
skuMapping: isRecord(config.skuMapping) ? config.skuMapping : {},
}
}
@@ -78,6 +79,8 @@ function mergeAffiliateDashConfig(
savedValue.timestampToleranceSeconds ||
runtimeValue.timestampToleranceSeconds ||
AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS,
preferredMatchEnabled:
savedValue.preferredMatchEnabled ?? runtimeValue.preferredMatchEnabled ?? true,
skuMapping: pickSkuMapping(savedValue.skuMapping, runtimeValue.skuMapping),
}
: {
@@ -90,6 +93,7 @@ function mergeAffiliateDashConfig(
notifyUrl: runtimeValue.notifyUrl || '',
timestampToleranceSeconds:
runtimeValue.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS,
preferredMatchEnabled: runtimeValue.preferredMatchEnabled ?? true,
skuMapping: pickSkuMapping(undefined, runtimeValue.skuMapping),
}
@@ -103,6 +107,7 @@ function mergeAffiliateDashConfig(
notifyUrl: overrides.notifyUrl ?? base.notifyUrl,
timestampToleranceSeconds:
overrides.timestampToleranceSeconds ?? base.timestampToleranceSeconds,
preferredMatchEnabled: overrides.preferredMatchEnabled ?? base.preferredMatchEnabled,
skuMapping: pickSkuMapping(overrides.skuMapping, base.skuMapping),
}
}
@@ -19,6 +19,7 @@ export type AffiliateDashSourceConfig = {
timeoutMs: number
notifyUrl: string
timestampToleranceSeconds: number
preferredMatchEnabled: boolean
skuMapping: AffiliateDashSkuMapping
}
@@ -54,6 +55,7 @@ export function normalizeAffiliateDashSourceConfig(rawValue: unknown): Affiliate
timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000),
notifyUrl: String(source.notifyUrl || '').trim(),
timestampToleranceSeconds: normalizePositiveInteger(source.timestampToleranceSeconds, 300),
preferredMatchEnabled: source.preferredMatchEnabled !== false,
skuMapping: normalizeSkuMapping(source.skuMapping),
}
}
@@ -84,6 +86,7 @@ function createDefaultAffiliateDashSourceConfig(): AffiliateDashSourceConfig {
timeoutMs: 10000,
notifyUrl: '',
timestampToleranceSeconds: 300,
preferredMatchEnabled: true,
skuMapping: {},
}
}
+1
View File
@@ -128,6 +128,7 @@ export type RuntimeConfig = {
timeoutMs: number
notifyUrl: string
timestampToleranceSeconds: number
preferredMatchEnabled: boolean
skuMapping: AffiliateDashSkuMapping
}
}