Files
order_site/apps/backend/src/services/fulfillment/routing-config-service.ts
T
yml2213 4fce07a621 refactor(douyin-mashida)!: 纠偏——删掉自建的皮肤通道,改回复用 affiliate_dash
上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」,
跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 UID == 买家填的 UID」),
而且核销还触发了老通知逻辑,给买家**又发了一条 /redeem** —— 买家收到两条链接。
本次按用户要求改回**完全复用仓库现成的 affiliate_dash 通道**(= 旧的快手皮肤自动发货)。

**删掉**
- 执行器 `douyin_mashida_skin`(含 registry/types/planner/bootstrap/路由白名单注册)
- `services/claim/claim-skin-service.ts`、`routes/claim-skin.ts`、`/api/v1/claim-skin` 挂载
- 迁移 096 的 `claim_submission_json` 全链路(task-repo / rows / inputs / task-detail /
  admin-read-service 的 claimSubmission),并新增 **097 兜底迁移**把该列 DROP 掉
- 顺带清掉上一轮专为已删接口加的死代码(utils/http.ts 的 buildMessageSuccessPayload 等)

**改回复用**
- `resolveOrderItemClaimLink`:通用解析(只看订单项、不看执行器)→
  `buildConfiguredClaimUrl(claimLinkBase, token)`;多履约单元取 task id 最小并告警;
  **绝不回退** hash 形态的 `buildClaimUrl`
- `cert_link` 与付款后那条**唯一**消息共用同一个解析结果
- **核销后不再给抖音买家发第二条消息**(`shouldNotifyBuyerAfterVerify` 只放行非抖音 provider)
- 买家侧走既有流程:输 UID → 二维码 → 扫码绑定 → 比对 UID → delivered/REDEEMED;
  在 delivered 分支 best-effort 核销平台券(幂等、失败只 warn,不拖慢买家响应)
- 后台回显链接改按 **order.provider** 判断(抖音用 `claimLinkBase`)

**SQL 守卫**:允许「删除历史迁移」,但强制要求「同时新增兜底迁移」配对
(修改历史迁移仍禁止),并把改动来源换成 `git status --porcelain`(否则未跟踪的新迁移看不见)

`npm run check`:tests 671 / pass 668 / fail 0 / skipped 3(含新增的物品图回退用例)
2026-10-09 16:13:39 +08:00

606 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path'
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import { PROJECT_ROOT } from '../../config/runtime.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
import { normalizeProductMatchName } from '../order/product-match-utils.js'
import type { JsonObject } from '../../types/json.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
export type FulfillmentRoutingRuleMatchType = 'exact' | 'contains'
/**
* 规则匹配维度:
* - `product_name`:按**商品名**匹配(历史规则,语义完全不变);
* - `internal_sku`:按**内部 SKU**匹配(如抖店 `sku_order_list[].code` = 「幸运币2个」)。
* 内部 SKU 用 `normalizeInternalSkuMatchKey` 归一(NFKC + 去空白 + 小写),
* 这样全角数字/多余空格也能命中权威映射表里的写法。
*/
export type FulfillmentRoutingRuleMatchField = 'product_name' | 'internal_sku'
export type FulfillmentRoutingExecutorConfig = {
enabled: boolean
}
export type FulfillmentRoutingRule = {
id: string
enabled: boolean
matchField: FulfillmentRoutingRuleMatchField
productName: string
normalizedProductName: string
internalSku: string
normalizedInternalSku: string
matchType: FulfillmentRoutingRuleMatchType
executorKey: string
priority: number
notes: string
}
export type FulfillmentRoutingConfig = {
enabled: boolean
defaultExecutorPriority: string[]
unmatchedExecutorKey: string
executors: Record<string, FulfillmentRoutingExecutorConfig>
rules: FulfillmentRoutingRule[]
}
export type FulfillmentRoutingCandidate = {
executorKey: string
available: boolean
reason?: string
}
export type FulfillmentRouteResult = {
selectedExecutorKey: string
selectedRuleId: string
reason: string
matchedRule: null | {
id: string
productName: string
executorKey: string
priority: number
}
candidates: FulfillmentRoutingCandidate[]
skipped: Array<{
executorKey: string
reason: string
}>
}
const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join(
PROJECT_ROOT,
'data',
'fulfillment-routing-config.json',
)
const ROUTABLE_EXECUTOR_KEYS: string[] = [
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
]
const DEFAULT_EXECUTOR_PRIORITY = [FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH]
export function getFulfillmentRoutingConfigFilePath() {
return FULFILLMENT_ROUTING_CONFIG_FILE_PATH
}
export function getFulfillmentRoutingConfig(): FulfillmentRoutingConfig {
return readAppConfigEntry({
configKey: APP_CONFIG_KEYS.fulfillmentRouting,
legacyFilePath: FULFILLMENT_ROUTING_CONFIG_FILE_PATH,
fallback: createDefaultFulfillmentRoutingConfig,
normalize: normalizeFulfillmentRoutingConfig,
})
}
export function saveFulfillmentRoutingConfig(rawValue: unknown): Promise<FulfillmentRoutingConfig> {
return saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.fulfillmentRouting,
legacyFilePath: FULFILLMENT_ROUTING_CONFIG_FILE_PATH,
value: rawValue,
normalize: normalizeFulfillmentRoutingConfig,
})
}
export function normalizeFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
const sourceExecutors = isPlainObject(source.executors) ? source.executors : {}
const rules = Array.isArray(source.rules) ? source.rules : []
const defaultExecutorPriority = normalizeExecutorPriority(
source.defaultExecutorPriority || source.executorPriority,
)
const hasExplicitUnmatched = Object.prototype.hasOwnProperty.call(source, 'unmatchedExecutorKey')
const unmatchedExecutorKey = normalizeExecutorKey(source.unmatchedExecutorKey)
return {
enabled: source.enabled !== false,
defaultExecutorPriority:
defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY],
// 缺省回落人工;显式传非 manual 值则关闭未命中回落。
unmatchedExecutorKey: hasExplicitUnmatched
? unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
? unmatchedExecutorKey
: ''
: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
executors: ROUTABLE_EXECUTOR_KEYS.reduce<Record<string, FulfillmentRoutingExecutorConfig>>(
(result, executorKey) => {
const executorConfig = isPlainObject(sourceExecutors[executorKey])
? (sourceExecutors[executorKey] as JsonObject)
: {}
result[executorKey] = {
enabled: executorConfig.enabled !== false,
}
return result
},
{},
),
rules: rules
.map((rule) => normalizeFulfillmentRoutingRule(rule))
.filter((rule): rule is FulfillmentRoutingRule => Boolean(rule))
.sort((left, right) => right.priority - left.priority),
}
}
export function resolveFulfillmentRoute({
productName,
internalSku = '',
candidates,
config = getFulfillmentRoutingConfig(),
preferredExecutorKey = '',
}: {
productName: unknown
internalSku?: unknown
candidates: FulfillmentRoutingCandidate[]
config?: FulfillmentRoutingConfig
preferredExecutorKey?: string
}): FulfillmentRouteResult {
const normalizedConfig = normalizeFulfillmentRoutingConfig(config)
const normalizedCandidates = normalizeRoutingCandidates(candidates)
const skipped: FulfillmentRouteResult['skipped'] = []
if (normalizedConfig.enabled === false) {
// 总开关关闭 = 停止自动履约:不再回退默认配置,订单进入待补全队列(isConfigured=false)
return createEmptyRouteResult(normalizedCandidates, skipped, {
reason: '履约路由未启用,自动发货已停止',
})
}
// 指定匹配(显式映射)最高优先:命中且通道可用时直接选择,不受全局优先级影响。
const preferred = normalizeExecutorKey(preferredExecutorKey)
if (preferred) {
const selected = resolveExecutorSelection({
executorKey: preferred,
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reason: '命中指定匹配(显式映射),优先选择',
matchedRule: null,
})
if (selected.selectedExecutorKey) {
return selected
}
}
// 匹配顺序刻意保持:先按商品名(历史规则,行为完全不变),再按内部 SKU(新增维度)。
const matchedRule =
findMatchedRoutingRule(productName, normalizedConfig.rules) ||
findMatchedInternalSkuRoutingRule(internalSku, normalizedConfig.rules)
if (matchedRule) {
const selected = resolveExecutorSelection({
executorKey: matchedRule.executorKey,
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reason: '命中商品路由规则',
matchedRule,
})
if (selected.selectedExecutorKey) {
return selected
}
const fallback = resolveByExecutorPriority({
priority: normalizedConfig.defaultExecutorPriority.filter(
(executorKey) => executorKey !== matchedRule.executorKey,
),
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reasonPrefix: '商品路由规则目标不可用,按全局优先级回退',
})
if (fallback.selectedExecutorKey) {
return fallback
}
return {
...selected,
reason: selected.reason || '商品路由规则目标通道不可用,且未命中其他履约通道',
}
}
const selected = resolveByExecutorPriority({
priority: normalizedConfig.defaultExecutorPriority,
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reasonPrefix: '按全局优先级命中',
})
if (selected.selectedExecutorKey) {
return selected
}
if (normalizedConfig.unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
const fallback = resolveExecutorSelection({
executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
candidates: normalizedCandidates,
config: normalizedConfig,
skipped,
reason: '未命中自动履约通道,进入人工履约',
matchedRule: null,
})
if (fallback.selectedExecutorKey) {
return fallback
}
}
return {
selectedExecutorKey: '',
selectedRuleId: '',
reason: '未命中可用履约通道',
matchedRule: null,
candidates: normalizedCandidates,
skipped,
}
}
function resolveByExecutorPriority({
priority,
candidates,
config,
skipped,
reasonPrefix,
}: {
priority: string[]
candidates: FulfillmentRoutingCandidate[]
config: FulfillmentRoutingConfig
skipped: FulfillmentRouteResult['skipped']
reasonPrefix: string
}) {
for (const executorKey of priority) {
const selected = resolveExecutorSelection({
executorKey,
candidates,
config,
skipped,
reason: reasonPrefix,
matchedRule: null,
})
if (selected.selectedExecutorKey) {
return selected
}
}
return {
selectedExecutorKey: '',
selectedRuleId: '',
reason: '',
matchedRule: null,
candidates,
skipped,
}
}
function resolveExecutorSelection({
executorKey,
candidates,
config,
skipped,
reason,
matchedRule,
}: {
executorKey: string
candidates: FulfillmentRoutingCandidate[]
config: FulfillmentRoutingConfig
skipped: FulfillmentRouteResult['skipped']
reason: string
matchedRule: FulfillmentRoutingRule | null
}): FulfillmentRouteResult {
const normalizedExecutorKey = normalizeExecutorKey(executorKey)
if (!normalizedExecutorKey) {
return createEmptyRouteResult(candidates, skipped)
}
if (config.executors[normalizedExecutorKey]?.enabled === false) {
skipped.push({
executorKey: normalizedExecutorKey,
reason: '履约通道已停用',
})
return createEmptyRouteResult(candidates, skipped)
}
if (normalizedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
return createSelectedRouteResult({
executorKey: normalizedExecutorKey,
reason,
matchedRule,
candidates,
skipped,
})
}
const candidate = candidates.find((item) => item.executorKey === normalizedExecutorKey)
if (!candidate?.available) {
skipped.push({
executorKey: normalizedExecutorKey,
reason: candidate?.reason || '未命中该通道商品配置',
})
return createEmptyRouteResult(candidates, skipped)
}
return createSelectedRouteResult({
executorKey: normalizedExecutorKey,
reason,
matchedRule,
candidates,
skipped,
})
}
function createSelectedRouteResult({
executorKey,
reason,
matchedRule,
candidates,
skipped,
}: {
executorKey: string
reason: string
matchedRule: FulfillmentRoutingRule | null
candidates: FulfillmentRoutingCandidate[]
skipped: FulfillmentRouteResult['skipped']
}): FulfillmentRouteResult {
return {
selectedExecutorKey: executorKey,
selectedRuleId: matchedRule?.id || '',
reason,
matchedRule: matchedRule
? {
id: matchedRule.id,
productName: matchedRule.productName,
executorKey: matchedRule.executorKey,
priority: matchedRule.priority,
}
: null,
candidates,
skipped,
}
}
function createEmptyRouteResult(
candidates: FulfillmentRoutingCandidate[],
skipped: FulfillmentRouteResult['skipped'],
options: { reason?: string } = {},
): FulfillmentRouteResult {
return {
selectedExecutorKey: '',
selectedRuleId: '',
reason: options.reason || '',
matchedRule: null,
candidates,
skipped,
}
}
function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingRule[]) {
const normalizedProductName = normalizeProductMatchName(productName)
if (!normalizedProductName) {
return null
}
return (
rules.find((rule) => {
if (rule.enabled === false || rule.matchField !== 'product_name') {
return false
}
if (!rule.normalizedProductName) {
return false
}
if (rule.matchType === 'contains') {
return normalizedProductName.includes(rule.normalizedProductName)
}
return normalizedProductName === rule.normalizedProductName
}) || null
)
}
/** 内部 SKU 维度的规则匹配:与商品名规则同样的 exact/contains 语义。 */
function findMatchedInternalSkuRoutingRule(internalSku: unknown, rules: FulfillmentRoutingRule[]) {
const normalizedInternalSku = normalizeInternalSkuMatchKey(internalSku)
if (!normalizedInternalSku) {
return null
}
return (
rules.find((rule) => {
if (rule.enabled === false || rule.matchField !== 'internal_sku') {
return false
}
if (!rule.normalizedInternalSku) {
return false
}
if (rule.matchType === 'contains') {
return normalizedInternalSku.includes(rule.normalizedInternalSku)
}
return normalizedInternalSku === rule.normalizedInternalSku
}) || null
)
}
/**
* 内部 SKU 归一化:NFKC(全角→半角)→ 去所有空白 → 小写。
* 与权威映射表 `AFFILIATE_DASH_SKU_MAPPING` 的查询归一化保持同一套语义。
*/
export function normalizeInternalSkuMatchKey(value: unknown): string {
return String(value || '')
.normalize('NFKC')
.replace(/\s+/g, '')
.toLowerCase()
}
/**
* 规则归一化。
*
* 兼容旧配置:没有 `matchField` / `internalSku` 的规则一律视为**商品名**规则,行为与之前完全一致
* (id、归一化键、matchType、priority 的算法都没动)。
* 新维度通过 `internalSku`(或显式 `matchField: 'internal_sku'`)声明。
*/
function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null {
const source = isPlainObject(rawValue) ? rawValue : {}
const matchField = resolveRuleMatchField(source)
const executorKey = normalizeExecutorKey(source.executorKey)
if (matchField === 'internal_sku') {
const internalSku = String(source.internalSku ?? source.sku ?? '').trim()
const normalizedInternalSku =
normalizeInternalSkuMatchKey(source.normalizedInternalSku) ||
normalizeInternalSkuMatchKey(internalSku)
if (!internalSku || !normalizedInternalSku || !executorKey) {
return null
}
return {
id:
String(source.id || `${normalizedInternalSku}:${executorKey}`).trim() ||
`${normalizedInternalSku}:${executorKey}`,
enabled: source.enabled !== false,
matchField: 'internal_sku',
// 展示与诊断仍走 productName 字段(值为内部 SKU);normalizedProductName 留空,
// 保证商品名匹配器永远不会把它当成商品名规则命中。
productName: internalSku,
normalizedProductName: '',
internalSku,
normalizedInternalSku,
matchType: source.matchType === 'contains' ? 'contains' : 'exact',
executorKey,
priority: normalizeInteger(source.priority, 100),
notes: String(source.notes || '').trim(),
}
}
const productName = String(source.productName || source.name || '').trim()
const normalizedProductName =
normalizeProductMatchName(source.normalizedProductName) ||
normalizeProductMatchName(productName)
if (!productName || !normalizedProductName || !executorKey) {
return null
}
return {
id:
String(source.id || `${normalizedProductName}:${executorKey}`).trim() ||
`${normalizedProductName}:${executorKey}`,
enabled: source.enabled !== false,
matchField: 'product_name',
productName,
normalizedProductName,
internalSku: '',
normalizedInternalSku: '',
matchType: source.matchType === 'contains' ? 'contains' : 'exact',
executorKey,
priority: normalizeInteger(source.priority, 100),
notes: String(source.notes || '').trim(),
}
}
function resolveRuleMatchField(source: JsonObject): FulfillmentRoutingRuleMatchField {
if (String(source.matchField || '').trim() === 'internal_sku') {
return 'internal_sku'
}
const hasProductName = Boolean(String(source.productName || source.name || '').trim())
const hasInternalSku = Boolean(String(source.internalSku ?? source.sku ?? '').trim())
return !hasProductName && hasInternalSku ? 'internal_sku' : 'product_name'
}
function normalizeRoutingCandidates(candidates: FulfillmentRoutingCandidate[]) {
const result = new Map<string, FulfillmentRoutingCandidate>()
for (const candidate of Array.isArray(candidates) ? candidates : []) {
const executorKey = normalizeExecutorKey(candidate.executorKey)
if (!executorKey || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
continue
}
result.set(executorKey, {
executorKey,
available: candidate.available === true,
reason: String(candidate.reason || '').trim(),
})
}
return Array.from(result.values())
}
function normalizeExecutorPriority(value: unknown) {
const rawItems = Array.isArray(value) ? value : []
const seen = new Set<string>()
const items: string[] = []
for (const item of rawItems) {
const executorKey = normalizeExecutorKey(item)
if (
!executorKey ||
seen.has(executorKey) ||
executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
) {
continue
}
seen.add(executorKey)
items.push(executorKey)
}
// 历史配置不会自动拥有后来新增的履约通道;补全默认通道,开关仍由 executors 控制。
for (const executorKey of DEFAULT_EXECUTOR_PRIORITY) {
if (!seen.has(executorKey)) {
seen.add(executorKey)
items.push(executorKey)
}
}
return items
}
function normalizeExecutorKey(value: unknown) {
const executorKey = String(value || '').trim()
return ROUTABLE_EXECUTOR_KEYS.includes(executorKey) ? executorKey : ''
}
function normalizeInteger(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function createDefaultFulfillmentRoutingConfig(): FulfillmentRoutingConfig {
return {
enabled: true,
defaultExecutorPriority: [...DEFAULT_EXECUTOR_PRIORITY],
unmatchedExecutorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
executors: ROUTABLE_EXECUTOR_KEYS.reduce<Record<string, FulfillmentRoutingExecutorConfig>>(
(result, executorKey) => {
result[executorKey] = { enabled: true }
return result
},
{},
),
rules: [],
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}