优化履约路由并重命名乐玩通道

This commit is contained in:
yml2213
2026-07-09 10:49:33 +08:00
parent 71602683d2
commit 4d5b0d6fc6
28 changed files with 1448 additions and 53 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
快手轻量后端服务。
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
后端负责订单入库、履约任务编排、kuaishou-lewan / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
## 快速启动
@@ -65,7 +65,7 @@ npm test
- `src/app.ts`Express 应用组装
- `src/routes`API 路由
- `src/services/order`:订单、商品匹配和履约任务编排
- `src/services/fulfillment`快手 Cloud 履约编排
- `src/services/fulfillment`kuaishou-lewan 履约编排
- `src/services/admin`:后台读写、权限、配置管理
- `src/repositories`:数据库访问
- `src/db/migrations`:新库初始化脚本
@@ -176,7 +176,7 @@ try {
updated_at: now,
})
console.log('已生成快手 Cloud 领取 mock 数据:')
console.log('已生成 kuaishou-lewan 领取 mock 数据:')
console.log(` 91订单号:${orderNo}`)
console.log(` 商品:${productName}`)
console.log(` 当前步骤:${step}`)
@@ -197,7 +197,7 @@ async function ensureKuaishouCloudProfile(timestamp: string) {
const profile = await upsertFulfillmentProfile({
profileKey: 'kuaishou_ct_assisted',
name: '快手 cloud 履约',
name: 'kuaishou-lewan 履约',
executorKey: 'kuaishou_ct_assisted',
requiresClaim: false,
autoDispatch: false,
@@ -243,7 +243,7 @@ function buildMockTaskContext({
return {
profileKey: 'kuaishou_ct_assisted',
profileName: '快手 cloud 履约',
profileName: 'kuaishou-lewan 履约',
skuCode: productName,
skuName: productName,
kuaishouCloudFulfillment: {
@@ -426,7 +426,7 @@ function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) {
function printHelp() {
console.log(`
生成快手 Cloud 领取 mock 数据:
生成 kuaishou-lewan 领取 mock 数据:
npm run mock:claim
npm run mock:claim -- --step=binding
+1 -1
View File
@@ -10,7 +10,7 @@ import {
shouldEnsureKuaishouCloudClaimLink,
} from './task-status.js'
test('任务状态机声明快手 Cloud 主流程跳转', () => {
test('任务状态机声明 kuaishou-lewan 主流程跳转', () => {
assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true)
assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true)
@@ -7,6 +7,7 @@ import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js";
import notificationsRouter from "./platform-config/notifications.js";
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
const router = Router();
@@ -16,6 +17,7 @@ router.use("/platform-config", kuaishouIndustryRouter);
router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", kuaishouFeifeiRouter);
router.use("/platform-config", ninetyoneRouter);
router.use("/platform-config", fulfillmentRoutingRouter);
router.use("/platform-config", cloudtentaclesRouter);
export default router;
@@ -0,0 +1,59 @@
import { Router } from 'express'
import {
getAdminFulfillmentRoutingConfig,
previewAdminFulfillmentRouting,
updateAdminFulfillmentRoutingConfig,
} from '../../../services/admin/platform-config/fulfillment-routing-service.js'
import { createJsonHandler } from '../session.js'
import type { JsonRecord } from '../../../types/json.js'
const router = Router()
router.get(
'/fulfillment-routing',
createJsonHandler(() => getAdminFulfillmentRoutingConfig(), {
successMessage: 'ok',
errorMessage: '读取履约路由配置失败',
scope: '[admin/platform-config/fulfillment-routing]',
}),
)
router.post(
'/fulfillment-routing',
createJsonHandler(
(req) => updateAdminFulfillmentRoutingConfig(req.body as JsonRecord),
{
successMessage: '履约路由配置已保存',
errorMessage: '保存履约路由配置失败',
scope: '[admin/platform-config/fulfillment-routing]',
audit: (_req, data) => {
const result = data as JsonRecord
return {
action: 'platform_fulfillment_routing_updated',
targetType: 'platform_config',
targetId: 'fulfillment_routing',
data: {
filePath: String(result.filePath || '').trim(),
enabled: result.enabled !== false,
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
},
}
},
},
),
)
router.post(
'/fulfillment-routing/preview',
createJsonHandler(
(req) => previewAdminFulfillmentRouting(req.body as JsonRecord),
{
successMessage: 'ok',
errorMessage: '预览履约路由失败',
scope: '[admin/platform-config/fulfillment-routing/preview]',
},
),
)
export default router
@@ -0,0 +1,51 @@
import {
getFulfillmentRoutingConfig,
getFulfillmentRoutingConfigFilePath,
saveFulfillmentRoutingConfig,
} from '../../fulfillment/routing-config-service.js'
import { resolveOrderItemForFulfillment } from '../../fulfillment/product-resolution-service.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
import type { JsonRecord } from '../../../types/json.js'
export function getAdminFulfillmentRoutingConfig() {
return {
filePath: getFulfillmentRoutingConfigFilePath(),
...getFulfillmentRoutingConfig(),
}
}
export function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) {
return {
filePath: getFulfillmentRoutingConfigFilePath(),
...saveFulfillmentRoutingConfig(payload),
}
}
export async function previewAdminFulfillmentRouting(payload: JsonRecord = {}) {
const productName = String(payload.productName || payload.skuName || '').trim()
const item = await resolveOrderItemForFulfillment({
provider: '91kaquan',
platform: 'kuaishou',
item: {
itemId: productName,
externalItemId: productName,
skuCode: productName,
skuName: productName,
externalSkuCode: productName,
externalSkuName: productName,
quantity: 1,
},
})
const snapshot = item.snapshot || {}
return {
productName,
normalizedProductName: normalizeCloudtentaclesMatchName(productName),
resolvedSkuCode: item.skuCode,
resolvedSkuName: item.skuName,
isConfigured: item.isConfigured,
fulfillmentRoute: snapshot.fulfillmentRoute || null,
cloudtentacles: snapshot.cloudtentacles || null,
kuaishouFeifei: snapshot.kuaishouFeifei || null,
}
}
@@ -80,7 +80,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
@@ -261,7 +261,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
@@ -397,7 +397,7 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
@@ -522,7 +522,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
@@ -29,7 +29,7 @@ const CORE_PROFILES: CoreProfile[] = [
},
{
profileKey: 'kuaishou_ct_assisted',
name: '快手 cloud 履约',
name: 'kuaishou-lewan 履约',
executorKey: 'kuaishou_ct_assisted',
requiresClaim: false,
autoDispatch: false,
@@ -56,7 +56,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
}
if (executorKey !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
@@ -440,7 +440,7 @@ export async function rebindKuaishouCloudClaimRole(token: unknown) {
const context = await getClaimContext(token)
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
@@ -633,7 +633,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
@@ -711,7 +711,7 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
@@ -196,7 +196,7 @@ export async function ensureTaskClaimLink(task: TaskRow) {
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
@@ -411,7 +411,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
@@ -571,7 +571,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
@@ -852,7 +852,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
@@ -1008,7 +1008,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
@@ -70,7 +70,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
options: JsonObject = {}
) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
statusCode: 409,
errorCode: "kuaishou_cloud_task_invalid",
});
@@ -211,7 +211,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
updated_at: now,
});
if (!updatedTask) {
throw createHttpError("快手 Cloud 发货状态更新失败", {
throw createHttpError("kuaishou-lewan 发货状态更新失败", {
statusCode: 500,
errorCode: "kuaishou_cloud_dispatch_update_failed",
});
@@ -749,7 +749,7 @@ export async function returnKuaishouCloudFulfillmentTask(
options: JsonObject = {}
) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
statusCode: 409,
errorCode: "kuaishou_cloud_task_invalid",
});
@@ -4,7 +4,7 @@ import test from 'node:test'
import { planFulfillmentTaskForOrderItem } from './planner.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履约计划', async () => {
test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 kuaishou-lewan 履约计划', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder(),
item: createOrderItem({
@@ -23,7 +23,7 @@ test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履
getProfileByKey: async (profileKey) => ({
id: 9,
profile_key: profileKey,
name: '快手 cloud 履约',
name: 'kuaishou-lewan 履约',
executor_key: profileKey,
requires_claim: true,
auto_dispatch: true,
@@ -76,6 +76,74 @@ test('planFulfillmentTaskForOrderItem 为 feifei 商品生成 feifei 履约计
assert.equal((plan.context.kuaishouFeifei as any).productName, '测试皮肤')
})
test('planFulfillmentTaskForOrderItem 尊重履约路由选择 feifei', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder(),
item: createOrderItem({
sku_code: 'FF-ROUTE',
sku_name: '同时命中商品',
item_snapshot_json: {
fulfillmentRoute: {
selectedExecutorKey: 'kuaishou_feifei',
},
cloudtentacles: {
cloudSourceKeys: ['account-a'],
cloudSkuId: 74,
cloudSkuName: 'Cloud 商品',
},
kuaishouFeifei: {
productCode: 'FF-ROUTE',
skuName: 'feifei 商品',
matchMode: 'kuaishou_feifei_name',
},
},
}),
getProfileByKey: async (profileKey) => ({
id: 10,
profile_key: profileKey,
name: '测试履约',
executor_key: profileKey,
requires_claim: false,
auto_dispatch: true,
}),
})
assert.ok(plan)
assert.equal(plan.executorKey, 'kuaishou_feifei')
assert.equal(plan.context.kuaishouCloudFulfillment, null)
assert.equal((plan.context.kuaishouFeifei as any).productCode, 'FF-ROUTE')
})
test('planFulfillmentTaskForOrderItem 支持履约路由转人工', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder(),
item: createOrderItem({
sku_code: 'MANUAL-1',
sku_name: '人工商品',
item_snapshot_json: {
fulfillmentRoute: {
selectedExecutorKey: 'manual_dispatch',
},
},
}),
getProfileByKey: async (profileKey) => ({
id: 1,
profile_key: profileKey,
name: '人工发货',
executor_key: 'manual_dispatch',
requires_claim: true,
auto_dispatch: true,
}),
})
assert.ok(plan)
assert.equal(plan.executorKey, 'manual_dispatch')
assert.equal(plan.requiresClaim, false)
assert.equal(plan.autoDispatch, false)
assert.equal(plan.context.kuaishouCloudFulfillment, null)
assert.equal(plan.context.kuaishouFeifei, null)
})
test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder(),
@@ -231,6 +231,21 @@ async function resolveDynamicFulfillmentProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
): Promise<FulfillmentBindingLike | null> {
const selectedExecutorKey = resolveSelectedFulfillmentExecutorKey(item)
if (selectedExecutorKey) {
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) {
return resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) {
return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
}
}
return (
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
@@ -272,7 +287,7 @@ async function resolveDynamicCloudtentaclesProfile(
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(),
profile_name: String(profile.profile_name || profile.name || 'kuaishou-lewan 履约').trim(),
executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
requires_claim: false,
auto_dispatch: false,
@@ -343,6 +358,42 @@ async function resolveDynamicKuaishouFeifeiProfile(
}
}
async function resolveDynamicManualDispatchProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
): Promise<FulfillmentBindingLike | null> {
const profile = await getProfileByKey('manual_review')
if (!profile) {
return null
}
return {
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: 'manual_review',
profile_name: String(profile.profile_name || profile.name || '人工发货').trim(),
executor_key: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
requires_claim: false,
auto_dispatch: false,
config_json: {
flowType: 'manual_dispatch',
configId: `manual_dispatch:${item.sku_code || item.id}`,
manualDispatch: {
source: 'fulfillment_routing',
skuCode: item.sku_code,
skuName: item.sku_name,
},
notes: '履约路由规则指定人工履约',
},
}
}
function resolveSelectedFulfillmentExecutorKey(item: OrderItemRow) {
const snapshot = parseJsonObject(item.item_snapshot_json)
const route = parseJsonObject(snapshot.fulfillmentRoute)
return String(route.selectedExecutorKey || '').trim()
}
function parseJsonObject(value: unknown): JsonObject {
if (!value) {
return {}
@@ -7,6 +7,11 @@ import {
resolveKuaishouFeifeiProductByName,
type KuaishouFeifeiProductMatch,
} from '../platforms/kuaishou-feifei/product-rule-service.js'
import {
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
export type FulfillmentItem = {
itemId?: string
@@ -39,6 +44,7 @@ type FulfillmentItemCandidate = {
resolvedSkuCode: string
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
fulfillmentRoute: FulfillmentRouteResult | null
isConfigured: boolean
}
@@ -70,17 +76,27 @@ export async function resolveOrderItemForFulfillment({
externalSkuNameNormalized,
cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
fulfillmentRoute,
} = candidate
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
? cloudtentaclesNameMatch?.cloudSkuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.productCode
: '',
externalSkuCode,
externalItemId,
])
const resolvedSkuName = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.skuName,
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
? cloudtentaclesNameMatch?.cloudSkuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.skuName
: '',
item.skuName,
externalSkuName,
resolvedSkuCode,
@@ -92,7 +108,13 @@ export async function resolveOrderItemForFulfillment({
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
matchMode: cloudtentaclesNameMatch?.matchMode || '',
matchMode:
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
? cloudtentaclesNameMatch?.matchMode || ''
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.matchMode || ''
: '',
fulfillmentRoute,
cloudtentacles: cloudtentaclesNameMatch
? {
matchMode: cloudtentaclesNameMatch.matchMode,
@@ -187,12 +209,30 @@ async function resolveConfiguredItemCandidate({
if (isOpen91KuaishouOrder(provider, platform)) {
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
? null
: resolveKuaishouFeifeiProductByName(externalSkuName)
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
const fulfillmentRoute = resolveFulfillmentRoute({
productName: externalSkuName,
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
available: Boolean(cloudtentaclesNameMatch),
reason: cloudtentaclesNameMatch ? '' : '未命中 cloudtentacles 商品或账号不可用',
},
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
available: Boolean(kuaishouFeifeiMatch),
reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则',
},
],
})
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
? cloudtentaclesNameMatch?.cloudSkuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.productCode
: '',
externalSkuCode,
externalItemId,
])
@@ -205,7 +245,8 @@ async function resolveConfiguredItemCandidate({
resolvedSkuCode,
cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
fulfillmentRoute,
isConfigured: Boolean(selectedExecutorKey),
}
}
@@ -222,6 +263,7 @@ async function resolveConfiguredItemCandidate({
resolvedSkuCode,
cloudtentaclesNameMatch: null,
kuaishouFeifeiMatch: null,
fulfillmentRoute: null,
isConfigured: false,
}
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
normalizeFulfillmentRoutingConfig,
resolveFulfillmentRoute,
} from './routing-config-service.js'
test('resolveFulfillmentRoute 默认保持 kuaishou-lewan 优先', () => {
const result = resolveFulfillmentRoute({
productName: '测试商品',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: true },
],
config: normalizeFulfillmentRoutingConfig({}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted')
assert.equal(result.reason, '按全局优先级命中')
})
test('resolveFulfillmentRoute 全局关闭 kuaishou-lewan 后选择 feifei', () => {
const result = resolveFulfillmentRoute({
productName: '测试商品',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: true },
],
config: normalizeFulfillmentRoutingConfig({
executors: {
kuaishou_ct_assisted: { enabled: false },
kuaishou_feifei: { enabled: true },
},
}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_feifei')
assert.deepEqual(result.skipped[0], {
executorKey: 'kuaishou_ct_assisted',
reason: '履约通道已停用',
})
})
test('resolveFulfillmentRoute 商品规则可以强制走 feifei', () => {
const result = resolveFulfillmentRoute({
productName: '荣耀套装',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: true },
],
config: normalizeFulfillmentRoutingConfig({
rules: [
{
enabled: true,
productName: '荣耀套装',
executorKey: 'kuaishou_feifei',
priority: 200,
},
],
}),
})
assert.equal(result.selectedExecutorKey, 'kuaishou_feifei')
assert.equal(result.selectedRuleId, '荣耀套装:kuaishou_feifei')
assert.equal(result.reason, '命中商品路由规则')
})
test('resolveFulfillmentRoute 商品规则可以强制转人工', () => {
const result = resolveFulfillmentRoute({
productName: '临时停用商品',
candidates: [
{ executorKey: 'kuaishou_ct_assisted', available: true },
{ executorKey: 'kuaishou_feifei', available: false },
],
config: normalizeFulfillmentRoutingConfig({
rules: [
{
enabled: true,
productName: '临时停用',
matchType: 'contains',
executorKey: 'manual_dispatch',
priority: 300,
},
],
}),
})
assert.equal(result.selectedExecutorKey, 'manual_dispatch')
assert.equal(result.reason, '命中商品路由规则')
})
@@ -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]'
}
@@ -107,7 +107,7 @@ export function notifyKuaishouCloudAssetNotEnough({
const binding = toRecord(flowRecord.binding)
return notifyInternalSafely({
title: '快手 Cloud 余额不足',
title: 'kuaishou-lewan 余额不足',
body: [
formatTaskLine(taskRecord),
`当前余额:${Number(assetBefore || 0)}`,
@@ -162,8 +162,8 @@ export function notifyCloudtentaclesAssetLow({
return notifyInternalSafely({
title: normalizedAccountLabel
? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}`
: '快手 Cloud 余额低于阈值',
? `kuaishou-lewan 余额低于阈值:${normalizedAccountLabel}`
: 'kuaishou-lewan 余额低于阈值',
body: [
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
`当前余额:${Number(asset || 0)}`,
@@ -205,7 +205,7 @@ export function notifyKuaishouCloudBindUrlRefreshFailed({
const taskRecord = toRecord(task)
return notifyInternalSafely({
title: '快手 Cloud 链接刷新失败',
title: 'kuaishou-lewan 链接刷新失败',
body: [
formatTaskLine(taskRecord),
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
@@ -45,7 +45,7 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles
getFulfillmentProfileByKey: async () => ({
id: 9,
profile_key: 'kuaishou_ct_assisted',
profile_name: '快手 cloud 履约',
profile_name: 'kuaishou-lewan 履约',
executor_key: 'kuaishou_ct_assisted',
}),
createTask: async (input: any) => {
@@ -105,7 +105,7 @@ test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles
getFulfillmentProfileByKey: async () => ({
id: 9,
profile_key: 'kuaishou_ct_assisted',
profile_name: '快手 cloud 履约',
profile_name: 'kuaishou-lewan 履约',
executor_key: 'kuaishou_ct_assisted',
}),
createTask: async (input: any) => {