优化履约路由并重命名乐玩通道
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js'
|
||||
import { normalizeCloudtentaclesMatchName } from '../order/cloudtentacles-match-utils.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export type FulfillmentRoutingRuleMatchType = 'exact' | 'contains'
|
||||
|
||||
export type FulfillmentRoutingExecutorConfig = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type FulfillmentRoutingRule = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
productName: string
|
||||
normalizedProductName: 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.KUAISHOU_CLOUD,
|
||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
]
|
||||
|
||||
const DEFAULT_EXECUTOR_PRIORITY = [
|
||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||
]
|
||||
|
||||
export function getFulfillmentRoutingConfigFilePath() {
|
||||
return FULFILLMENT_ROUTING_CONFIG_FILE_PATH
|
||||
}
|
||||
|
||||
export function getFulfillmentRoutingConfig(): FulfillmentRoutingConfig {
|
||||
return readJsonFile(
|
||||
FULFILLMENT_ROUTING_CONFIG_FILE_PATH,
|
||||
createDefaultFulfillmentRoutingConfig,
|
||||
normalizeFulfillmentRoutingConfig,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig {
|
||||
return writeJsonFile(
|
||||
FULFILLMENT_ROUTING_CONFIG_FILE_PATH,
|
||||
rawValue,
|
||||
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 unmatchedExecutorKey = normalizeExecutorKey(source.unmatchedExecutorKey)
|
||||
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
defaultExecutorPriority:
|
||||
defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY],
|
||||
unmatchedExecutorKey:
|
||||
unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ? unmatchedExecutorKey : '',
|
||||
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,
|
||||
candidates,
|
||||
config = getFulfillmentRoutingConfig(),
|
||||
}: {
|
||||
productName: unknown
|
||||
candidates: FulfillmentRoutingCandidate[]
|
||||
config?: FulfillmentRoutingConfig
|
||||
}): FulfillmentRouteResult {
|
||||
const normalizedConfig = normalizeFulfillmentRoutingConfig(config)
|
||||
const normalizedCandidates = normalizeRoutingCandidates(candidates)
|
||||
const skipped: FulfillmentRouteResult['skipped'] = []
|
||||
|
||||
if (normalizedConfig.enabled === false) {
|
||||
return resolveByExecutorPriority({
|
||||
priority: DEFAULT_EXECUTOR_PRIORITY,
|
||||
candidates: normalizedCandidates,
|
||||
config: createDefaultFulfillmentRoutingConfig(),
|
||||
skipped,
|
||||
reasonPrefix: '履约路由未启用,使用历史顺序',
|
||||
})
|
||||
}
|
||||
|
||||
const matchedRule = findMatchedRoutingRule(productName, normalizedConfig.rules)
|
||||
if (matchedRule) {
|
||||
const selected = resolveExecutorSelection({
|
||||
executorKey: matchedRule.executorKey,
|
||||
candidates: normalizedCandidates,
|
||||
config: normalizedConfig,
|
||||
skipped,
|
||||
reason: '命中商品路由规则',
|
||||
matchedRule,
|
||||
})
|
||||
if (selected.selectedExecutorKey) {
|
||||
return selected
|
||||
}
|
||||
|
||||
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'],
|
||||
): FulfillmentRouteResult {
|
||||
return {
|
||||
selectedExecutorKey: '',
|
||||
selectedRuleId: '',
|
||||
reason: '',
|
||||
matchedRule: null,
|
||||
candidates,
|
||||
skipped,
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingRule[]) {
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return rules.find((rule) => {
|
||||
if (rule.enabled === false || !rule.normalizedProductName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (rule.matchType === 'contains') {
|
||||
return normalizedProductName.includes(rule.normalizedProductName)
|
||||
}
|
||||
|
||||
return normalizedProductName === rule.normalizedProductName
|
||||
}) || null
|
||||
}
|
||||
|
||||
function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const productName = String(source.productName || source.name || '').trim()
|
||||
const normalizedProductName =
|
||||
normalizeCloudtentaclesMatchName(source.normalizedProductName) ||
|
||||
normalizeCloudtentaclesMatchName(productName)
|
||||
const executorKey = normalizeExecutorKey(source.executorKey)
|
||||
|
||||
if (!productName || !normalizedProductName || !executorKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(source.id || `${normalizedProductName}:${executorKey}`).trim() ||
|
||||
`${normalizedProductName}:${executorKey}`,
|
||||
enabled: source.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName,
|
||||
matchType: source.matchType === 'contains' ? 'contains' : 'exact',
|
||||
executorKey,
|
||||
priority: normalizeInteger(source.priority, 100),
|
||||
notes: String(source.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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: '',
|
||||
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]'
|
||||
}
|
||||
Reference in New Issue
Block a user