diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 5520261d..d1a05d1c 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -84,6 +84,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { timeoutMs: 10000, notifyUrl: '', timestampToleranceSeconds: 300, + preferredMatchEnabled: true, skuMapping: {}, }, cloudtentacles: { diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts new file mode 100644 index 00000000..c00f337f --- /dev/null +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -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-仓鼠灰灰') +}) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index be38bb2e..85ab0fe1 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -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' diff --git a/apps/backend/src/services/fulfillment/routing-config-service.test.ts b/apps/backend/src/services/fulfillment/routing-config-service.test.ts index ce5ebb85..1cc2a4f1 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.test.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.test.ts @@ -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, '按全局优先级命中') +}) diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts index d5911aab..8bd220a0 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -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({ diff --git a/apps/backend/src/services/platforms/affiliate-dash/config.ts b/apps/backend/src/services/platforms/affiliate-dash/config.ts index ebea0645..02f8f95f 100644 --- a/apps/backend/src/services/platforms/affiliate-dash/config.ts +++ b/apps/backend/src/services/platforms/affiliate-dash/config.ts @@ -29,6 +29,7 @@ export function getAffiliateDashConfig(overrides: Partial