diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index de2959eb..dd34f61a 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -4,6 +4,7 @@ export const APP_CONFIG_KEYS = { fulfillmentRouting: 'fulfillment_routing', workerFinance: 'worker_finance', workerPlatformNotifications: 'worker_platform_notifications', + workerProductMatch: 'worker_product_match', cloudtentaclesSources: 'cloudtentacles_sources', cloudtentaclesSession: 'cloudtentacles_session', cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 3c7879d0..c2e5f451 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -14,6 +14,7 @@ import { deleteAdminWorkOrder, deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, + getAdminWorkerProductMatchConfig, getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkOrderEvents, @@ -36,6 +37,7 @@ import { resolveAdminProblemWorkOrder, reviewAdminWorkerUser, saveAdminWorkerFinanceConfig, + saveAdminWorkerProductMatchConfig, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerWithdrawalAccount, saveAdminWorkCategory, @@ -229,6 +231,32 @@ router.delete( ), ) +router.get( + '/worker-platform/product-match-config', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => getAdminWorkerProductMatchConfig(), { + successMessage: 'ok', + errorMessage: '读取商品匹配配置失败', + scope: '[admin/worker-platform/product-match-config]', + }), +) + +router.post( + '/worker-platform/product-match-config', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminWorkerProductMatchConfig(req.body || {}), { + successMessage: '商品匹配配置已保存', + errorMessage: '保存商品匹配配置失败', + scope: '[admin/worker-platform/product-match-config]', + audit: (_req, data) => ({ + action: 'worker_product_match_config_saved', + targetType: 'worker_product_match_config', + targetId: 'worker_product_match', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.get( '/worker-platform/product-match-sources', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 40e2c20d..69098252 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -72,6 +72,10 @@ import { getWorkerPlatformNotificationConfig, saveWorkerPlatformNotificationConfig, } from './worker-platform-notification-config-service.js' +import { + getWorkerProductMatchConfig, + saveWorkerProductMatchConfig, +} from './worker-product-match-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkOrderMaterialAdminNotification, @@ -465,7 +469,9 @@ export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) { listWorkProductRules({ enabled: true }), listWorkProductRuleMappings({ enabled: true }), ]) - const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings) + const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings, { + legacyFallbackEnabled: getWorkerProductMatchConfig().legacyFallbackEnabled, + }) const context = resolveKuaishouWorkProductMatchContext(item) return { context, @@ -972,6 +978,14 @@ export async function saveAdminWorkerPlatformNotificationConfig(payload: JsonObj return saveWorkerPlatformNotificationConfig(payload) } +export async function getAdminWorkerProductMatchConfig() { + return getWorkerProductMatchConfig() +} + +export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}) { + return saveWorkerProductMatchConfig(payload) +} + export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) @@ -2002,11 +2016,14 @@ export async function syncWorkerOrdersForSourceOrder( listWorkProductRules({ enabled: true }), listWorkProductRuleMappings({ enabled: true }), ]) + const productMatchConfig = getWorkerProductMatchConfig() const created: WorkOrderRow[] = [] const skipped: Array<{ orderItemId: number; reason: string }> = [] for (const item of orderItems) { - const match = resolveMatchingProductRuleDecision(order, item, rules, mappings) + const match = resolveMatchingProductRuleDecision(order, item, rules, mappings, { + legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled, + }) const matchContext = resolveKuaishouWorkProductMatchContext(item) if (matchContext.sellerId) { const snapshot = safeParseJson(item.item_snapshot_json) diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index ab2019ed..0e895e14 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -1,3 +1,4 @@ export * from './mappers.js' export * from './worker-service.js' export * from './admin-service.js' +export * from './worker-product-match-config-service.js' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index f21434a3..47b11873 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -611,8 +611,9 @@ export function resolveMatchingProductRule( item: OrderItemRow, rules: WorkProductRuleRow[], mappings: WorkProductRuleMappingRow[] = [], + options: { legacyFallbackEnabled?: boolean } = {}, ) { - return resolveMatchingProductRuleDecision(order, item, rules, mappings).rule + return resolveMatchingProductRuleDecision(order, item, rules, mappings, options).rule } export type WorkProductRuleMatchDecision = { @@ -638,12 +639,15 @@ export function resolveMatchingProductRuleDecision( item: OrderItemRow, rules: WorkProductRuleRow[], 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)), - ...rules.map((rule) => scoreWorkProductRule(order, item, rule)), + ...(useLegacyFallback ? rules.map((rule) => scoreWorkProductRule(order, item, rule)) : []), ] .filter( (candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } => diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index b092e2fa..6dc84e9f 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -158,41 +158,63 @@ test('排行榜打手名称仅展示首字符', () => { assert.equal(maskWorkerLeaderboardDisplayName(''), '****') }) -test('快手具体 SKU 精确规则优先于大标题包含规则', () => { - const decision = resolveMatchingProductRuleDecision(buildOrderRow(), buildOrderItemRow(), [ - buildProductRule(), - buildProductRule({ - id: 2, - rule_key: 'gift-box', - product_name: '精英尊尚专属礼盒', - match_json: { - sellerId: '3676797936', - itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥', - skuNick: '1个精英尊尚专属礼盒', - itemTitleMatchType: 'exact', - skuNickMatchType: 'exact', - }, - }), - ]) +test('开启旧规则兼容匹配后,快手具体 SKU 精确规则优先于大标题包含规则', () => { + const decision = resolveMatchingProductRuleDecision( + buildOrderRow(), + buildOrderItemRow(), + [ + buildProductRule(), + buildProductRule({ + id: 2, + rule_key: 'gift-box', + product_name: '精英尊尚专属礼盒', + match_json: { + sellerId: '3676797936', + itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥', + skuNick: '1个精英尊尚专属礼盒', + itemTitleMatchType: 'exact', + skuNickMatchType: 'exact', + }, + }), + ], + [], + { legacyFallbackEnabled: true }, + ) assert.equal(decision.reason, 'matched') assert.equal(decision.rule?.rule_key, 'gift-box') }) -test('快手同优先级精确规则进入冲突状态', () => { +test('开启旧规则兼容匹配后,快手同优先级精确规则进入冲突状态', () => { const match = { sellerId: '3676797936', skuId: '189872452606936', } - const decision = resolveMatchingProductRuleDecision(buildOrderRow(), buildOrderItemRow(), [ - buildProductRule({ rule_key: 'gift-box-a', match_json: match }), - buildProductRule({ id: 2, rule_key: 'gift-box-b', match_json: match }), - ]) + const decision = resolveMatchingProductRuleDecision( + buildOrderRow(), + buildOrderItemRow(), + [ + buildProductRule({ rule_key: 'gift-box-a', match_json: match }), + buildProductRule({ id: 2, rule_key: 'gift-box-b', match_json: match }), + ], + [], + { legacyFallbackEnabled: true }, + ) assert.equal(decision.reason, 'ambiguous') assert.equal(decision.rule, null) }) +test('快手订单默认不使用旧模板大标题匹配', () => { + const decision = resolveMatchingProductRuleDecision(buildOrderRow(), buildOrderItemRow(), [ + buildProductRule({ rule_key: 'special-soldier', product_name: '特种兵' }), + ]) + + assert.equal(decision.reason, 'unmatched') + assert.equal(decision.rule, null) + assert.deepEqual(decision.candidates, []) +}) + test('快手 SKU 覆盖映射优先于商品默认映射', () => { const defaultRule = buildProductRule({ rule_key: 'commander-default', product_name: '' }) const skuRule = buildProductRule({ id: 2, rule_key: 'gift-box-sku', product_name: '' }) diff --git a/apps/backend/src/services/worker-platform/worker-product-match-config-service.ts b/apps/backend/src/services/worker-platform/worker-product-match-config-service.ts new file mode 100644 index 00000000..33d65876 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-product-match-config-service.ts @@ -0,0 +1,42 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +export type WorkerProductMatchConfig = { + legacyFallbackEnabled: boolean +} + +export function getWorkerProductMatchConfig(): WorkerProductMatchConfig { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerProductMatch, + fallback: createDefaultWorkerProductMatchConfig, + normalize: normalizeWorkerProductMatchConfig, + }) +} + +export async function saveWorkerProductMatchConfig( + rawValue: unknown, +): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerProductMatch, + value: rawValue, + normalize: normalizeWorkerProductMatchConfig, + }) +} + +export function createDefaultWorkerProductMatchConfig(): WorkerProductMatchConfig { + return { legacyFallbackEnabled: false } +} + +export function normalizeWorkerProductMatchConfig(rawValue: unknown): WorkerProductMatchConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + return { + // 默认关闭旧规则,避免大标题中的公共词触发错误接单。 + legacyFallbackEnabled: + typeof source.legacyFallbackEnabled === 'boolean' ? source.legacyFallbackEnabled : false, + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx index ea501dba..6e260ea5 100644 --- a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -26,16 +26,19 @@ import { deleteAdminWorkProductRuleMapping, fetchAdminKuaishouIndustryShops, fetchAdminKuaishouMatchSources, + fetchAdminWorkerProductMatchConfig, fetchAdminWorkProductMatchLogs, fetchAdminWorkProductRuleMappings, fetchAdminWorkProductRules, saveAdminWorkProductRuleMapping, + saveAdminWorkerProductMatchConfig, testAdminKuaishouProductMatch, } from '@/services/admin' import type { KuaishouMatchSource, WorkProductMatchLog, WorkProductRuleMapping, + WorkerProductMatchConfig, } from '@/types/worker-platform' import type { AdminKuaishouIndustryShopOption } from '@/types/admin' import { formatAdminDateTime } from '@/utils/admin-time' @@ -89,12 +92,17 @@ export default function ProductMatchPanel() { queryKey: ['admin-worker-platform-product-match-logs'], queryFn: () => fetchAdminWorkProductMatchLogs(100), }) + const matchConfigQuery = useQuery({ + queryKey: ['admin-worker-platform-product-match-config'], + queryFn: fetchAdminWorkerProductMatchConfig, + }) const rules = rulesQuery.data?.data.items || [] const mappings = mappingsQuery.data?.data.items || [] const sources = sourcesQuery.data?.data.items || [] const logs = logsQuery.data?.data.items || [] const shops = shopsQuery.data?.data.shops || [] + const matchConfig = matchConfigQuery.data?.data const shopNameBySellerId = createShopNameBySellerId(shops) const sellerIds = new Set([ ...shops.map((shop) => shop.sellerId), @@ -209,6 +217,19 @@ export default function ProductMatchPanel() { } } + async function updateLegacyFallback(legacyFallbackEnabled: boolean) { + const nextConfig: WorkerProductMatchConfig = { legacyFallbackEnabled } + try { + await saveAdminWorkerProductMatchConfig(nextConfig) + message.success(legacyFallbackEnabled ? '旧规则兼容匹配已开启' : '旧规则兼容匹配已关闭') + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-product-match-config'], + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '保存商品匹配配置失败') + } + } + const mappingColumns: TableColumnsType = [ { title: '店铺 / 商品', @@ -391,6 +412,15 @@ export default function ProductMatchPanel() { return ( + + + 启用旧规则兼容匹配 + 默认关闭;关闭后快手订单仅按商品映射匹配 + ('/api/v1/admin/worker-platform/product-match-config') +} + +export function saveAdminWorkerProductMatchConfig(payload: WorkerProductMatchConfig) { + return apiPost( + '/api/v1/admin/worker-platform/product-match-config', + payload, + ) +} + export function fetchAdminKuaishouMatchSources(limit = 100) { return apiGet<{ items: KuaishouMatchSource[] }>( '/api/v1/admin/worker-platform/product-match-sources', diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 2eae9423..de870ed9 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -137,6 +137,10 @@ export type WorkerPlatformNotificationConfig = { > } +export type WorkerProductMatchConfig = { + legacyFallbackEnabled: boolean +} + export type WorkerWithdrawalAccount = { accountChannel: string accountName: string