上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」, 跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 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(含新增的物品图回退用例)
606 lines
18 KiB
TypeScript
606 lines
18 KiB
TypeScript
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]'
|
||
}
|