diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts index c00f337f..65727905 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { matchAffiliateDashSku, + matchAffiliateDashSkuPassthrough, normalizeAffiliateDashMappingKey, } from './product-resolution-service.js' @@ -32,3 +33,21 @@ test('normalizeAffiliateDashMappingKey NFKC + 去空白 + 小写', () => { assert.equal(normalizeAffiliateDashMappingKey(' 幸运币90个 '), '幸运币90个') assert.equal(normalizeAffiliateDashMappingKey('M416-仓鼠灰灰'), 'm416-仓鼠灰灰') }) + +test('matchAffiliateDashSkuPassthrough sku 一致时命中', () => { + const skus = new Set(['suit_alan_walker', 'lucky_coin_x90']) + assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', skus), true) + assert.equal(matchAffiliateDashSkuPassthrough('lucky_coin_x90', skus), true) +}) + +test('matchAffiliateDashSkuPassthrough 不在商品列表时未命中', () => { + const skus = new Set(['suit_alan_walker']) + assert.equal(matchAffiliateDashSkuPassthrough('not_a_real_sku', skus), false) + assert.equal(matchAffiliateDashSkuPassthrough('', skus), false) + assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', null), false) + assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', undefined), false) +}) + +test('matchAffiliateDashSkuPassthrough 空集合未命中', () => { + assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', new Set()), false) +}) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 85ab0fe1..b9e66f30 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 { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js' import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js' import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' @@ -42,7 +43,7 @@ export type AffiliateDashSkuMatch = { sku: string productNo: string skuName: string - matchMode: 'affiliate_dash_sku_mapping' + matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough' } type FulfillmentItemCandidate = { @@ -234,7 +235,7 @@ async function resolveConfiguredItemCandidate({ if (isOpen91KuaishouOrder(provider, platform)) { const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName) - const affiliateDashMatch = resolveAffiliateDashSkuByProductNo(item) + const affiliateDashMatch = await resolveAffiliateDashSkuByProductNo(item) // 指定匹配(显式映射)命中时优先走 affiliate_dash(可配置关闭,默认开) const affiliateDashConfig = getAffiliateDashConfig() const preferredExecutorKey = @@ -310,7 +311,9 @@ async function resolveConfiguredItemCandidate({ } } -function resolveAffiliateDashSkuByProductNo(item: FulfillmentItem): AffiliateDashSkuMatch | null { +async function resolveAffiliateDashSkuByProductNo( + item: FulfillmentItem, +): Promise { const config = getAffiliateDashConfig() if (config.enabled === false || !config.baseUrl) { return null @@ -322,16 +325,62 @@ function resolveAffiliateDashSkuByProductNo(item: FulfillmentItem): AffiliateDas item.externalSkuCode, item.externalItemId, ]) - const sku = productNo ? matchAffiliateDashSku(config.skuMapping, String(productNo)) : '' - if (!productNo || !sku) { + if (!productNo) { return null } - return { - sku, - productNo: String(productNo), - skuName: String(item.externalSkuName || '').trim(), - matchMode: 'affiliate_dash_sku_mapping', + // ① 指定映射(运营显式配置) + const mappedSku = matchAffiliateDashSku(config.skuMapping, String(productNo)) + if (mappedSku) { + return { + sku: mappedSku, + productNo: String(productNo), + skuName: String(item.externalSkuName || '').trim(), + matchMode: 'affiliate_dash_sku_mapping', + } + } + + // ② 透传:91 商品编码 == affiliate_dash 商品 sku 时直接命中(需商品列表校验,缓存 5 分钟) + const rawProductNo = String(productNo).trim() + const skuSet = await getAffiliateDashSkuSet() + if (matchAffiliateDashSkuPassthrough(rawProductNo, skuSet)) { + return { + sku: rawProductNo, + productNo: rawProductNo, + skuName: String(item.externalSkuName || '').trim(), + matchMode: 'affiliate_dash_sku_passthrough', + } + } + + return null +} + +/** 透传判定:productNo 与 affiliate_dash 商品 sku 完全一致。 */ +export function matchAffiliateDashSkuPassthrough( + productNo: string, + availableSkus: ReadonlySet | null | undefined, +): boolean { + const raw = String(productNo || '').trim() + return Boolean(raw && availableSkus?.has(raw)) +} + +const AFFILIATE_DASH_SKU_CACHE_TTL_MS = 5 * 60 * 1000 +let affiliateDashSkuCache: { skus: Set; fetchedAt: number } | null = null + +async function getAffiliateDashSkuSet(): Promise> { + const now = Date.now() + if (affiliateDashSkuCache && now - affiliateDashSkuCache.fetchedAt < AFFILIATE_DASH_SKU_CACHE_TTL_MS) { + return affiliateDashSkuCache.skus + } + + try { + const result = await listAllAffiliateDashProducts() + const skus = new Set(result.list.map((product) => String(product.sku || '').trim()).filter(Boolean)) + affiliateDashSkuCache = { skus, fetchedAt: now } + return skus + } catch { + // 拉取失败:返回上次缓存(即使过期)或空集合,匹配 miss 走其他通道,不阻塞下单 + return affiliateDashSkuCache?.skus || new Set() } } diff --git a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md index bf926e58..876ee676 100644 --- a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md +++ b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md @@ -688,3 +688,27 @@ order_site 处理要求: | 神秘新商品(未映射) | (无)回退 | — | **验证**:typecheck 通过;后端全量 221 测试通过(新增 9 个)。 + +--- + +## 23. 透传优化:91 编码 == affiliate_dash sku 自动命中(v2.8 · 已完成) + +**需求**:91 商品编码可直接设置为英文(= affiliate_dash sku,如 `suit_alan_walker`),映射表未命中时**自动透传**,无需在 order_site 维护映射表。 + +### 23.1 匹配层级(`resolveAffiliateDashSkuByProductNo`,改为 async) + +1. **① 指定映射**(`matchAffiliateDashSku`):skuMapping 精确/归一命中 → `matchMode='affiliate_dash_sku_mapping'` +2. **② 透传**(`matchAffiliateDashSkuPassthrough`):映射未命中时,productNo 与 affiliate_dash 商品 sku 完全一致 → `matchMode='affiliate_dash_sku_passthrough'` +3. 两层任一命中都触发「指定匹配最高优先」(`preferredExecutorKey=affiliate_dash`),规则/全局优先级不抢占 + +**商品列表缓存**:`getAffiliateDashSkuSet()` 内存缓存 5 分钟(`listAllAffiliateDashProducts` 拉全量 33 个 sku);拉取失败返回上次缓存或空集合——匹配 miss 走其他通道,**不阻塞下单**。 + +### 23.2 行为验证(线上商品列表) + +| 输入(91 编码) | executor | sku | matchMode | +| --- | --- | --- | --- | +| `suit_alan_walker`(真实商品) | affiliate_dash | suit_alan_walker | affiliate_dash_sku_passthrough | +| `suit_cloud_bear`(真实商品) | affiliate_dash | suit_cloud_bear | affiliate_dash_sku_passthrough | +| `not_a_real_sku` | (无)回退 | — | — | + +**验证**:typecheck 通过;后端全量 224 测试通过(透传纯函数测试 +3)。