refactor(fulfillment): 移除飞飞/乐玩通道,仅保留 affiliate_dash
新单只路由 affiliate_dash(未命中走 manual_dispatch);历史飞飞/乐玩任务保留只读 stub,避免订单重同步失败。同步清理 cloudtentacles 调度、Admin 写路径与相关 env。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
normalizeCloudtentaclesMatchName,
|
||||
resolveCloudtentaclesSkuByProductName,
|
||||
} from './cloudtentacles-name-match-service.js'
|
||||
|
||||
test('normalizeCloudtentaclesMatchName normalizes punctuation and spaces', () => {
|
||||
assert.equal(
|
||||
normalizeCloudtentaclesMatchName(' 荣耀勋章礼包(30个) '),
|
||||
normalizeCloudtentaclesMatchName('荣耀勋章礼包(30个)'),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sources', async () => {
|
||||
const result = await resolveCloudtentaclesSkuByProductName('荣耀勋章礼包(30个)', {
|
||||
listCloudtentaclesSources: () => ({
|
||||
enabled: true,
|
||||
sources: [
|
||||
{
|
||||
key: 'account-a',
|
||||
label: '账号 A',
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCloudtentaclesSessionStateByKey: () => ({
|
||||
token: 'token-a',
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
username: 'user-a',
|
||||
phone: '',
|
||||
loggedInAt: '2026-05-27T12:00:00.000Z',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
}),
|
||||
listCloudtentaclesSku: async () => ({
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
itemCount: 1,
|
||||
rawItems: [],
|
||||
items: [
|
||||
{
|
||||
id: 74,
|
||||
name: '荣耀勋章礼包(30个)',
|
||||
inventory: 13837,
|
||||
price: 300,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result?.cloudSkuId, 74)
|
||||
assert.equal(result?.cloudSkuName, '荣耀勋章礼包(30个)')
|
||||
assert.deepEqual(result?.cloudSourceKeys, ['account-a'])
|
||||
assert.equal(result?.matchMode, 'cloudtentacles_name')
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesSkuByProductName 多账号匹配不提前固定账号', async () => {
|
||||
const result = await resolveCloudtentaclesSkuByProductName('商品A', {
|
||||
listCloudtentaclesSources: () => ({
|
||||
enabled: true,
|
||||
sources: [
|
||||
{ key: 'account-a', enabled: true },
|
||||
{ key: 'account-b', enabled: true },
|
||||
],
|
||||
}),
|
||||
getCloudtentaclesSessionStateByKey: (key) => ({ token: `token-${key}` }),
|
||||
listCloudtentaclesSku: async () => ({
|
||||
items: [{ id: 28, name: '商品A', inventory: 10, price: 100 }],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.deepEqual(result?.cloudSourceKeys, ['account-a', 'account-b'])
|
||||
assert.equal(result?.resolvedSourceKey, '')
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => {
|
||||
const result = await resolveCloudtentaclesSkuByProductName('套装1', {
|
||||
listCloudtentaclesSources: () => ({
|
||||
enabled: true,
|
||||
sources: [
|
||||
{
|
||||
key: 'account-a',
|
||||
label: '账号 A',
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCloudtentaclesSessionStateByKey: () => ({
|
||||
token: 'token-a',
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
username: 'user-a',
|
||||
phone: '',
|
||||
loggedInAt: '2026-05-27T12:00:00.000Z',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
}),
|
||||
listCloudtentaclesSku: async () => ({
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
itemCount: 3,
|
||||
rawItems: [],
|
||||
items: [
|
||||
{
|
||||
id: 28,
|
||||
name: '商品A',
|
||||
inventory: 10,
|
||||
price: 360,
|
||||
},
|
||||
{
|
||||
id: 29,
|
||||
name: '商品B',
|
||||
inventory: 20,
|
||||
price: 888,
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
name: '套装1',
|
||||
inventory: 1,
|
||||
price: 999,
|
||||
},
|
||||
],
|
||||
}),
|
||||
resolveCloudtentaclesOverrideRule: () => ({
|
||||
id: 'combo-1',
|
||||
enabled: true,
|
||||
productName: '套装1',
|
||||
normalizedProductName: normalizeCloudtentaclesMatchName('套装1'),
|
||||
sourceKey: '',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
],
|
||||
notes: '',
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result?.matchMode, 'cloudtentacles_override')
|
||||
assert.equal(result?.cloudSkuId, 28)
|
||||
assert.equal(result?.cloudSkuName, '商品A')
|
||||
assert.equal(result?.resolvedSourceKey, '')
|
||||
assert.deepEqual(result?.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 29,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1,302 +0,0 @@
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js'
|
||||
import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js'
|
||||
import {
|
||||
normalizeCloudtentaclesDeviceId,
|
||||
normalizeCloudtentaclesDeviceType,
|
||||
} from '../platforms/cloudtentacles/defaults.js'
|
||||
import { listCloudtentaclesSku } from '../platforms/cloudtentacles/catalog-service.js'
|
||||
import {
|
||||
resolveCloudtentaclesOverrideRule,
|
||||
type CloudtentaclesOverrideRule,
|
||||
} from './cloudtentacles-override-rule-service.js'
|
||||
import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
|
||||
|
||||
type CloudtentaclesSource = {
|
||||
key?: string
|
||||
label?: string
|
||||
enabled?: boolean
|
||||
baseUrl?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}
|
||||
|
||||
type CloudtentaclesSession = {
|
||||
token?: string
|
||||
baseUrl?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}
|
||||
|
||||
type CloudtentaclesSku = {
|
||||
id?: number
|
||||
name?: string
|
||||
inventory?: number
|
||||
price?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type CloudtentaclesNameMatchDeps = {
|
||||
listCloudtentaclesSources?: typeof listCloudtentaclesSources
|
||||
getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey
|
||||
listCloudtentaclesSku?: typeof listCloudtentaclesSku
|
||||
resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule
|
||||
}
|
||||
|
||||
export type CloudtentaclesResolvedDeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type CloudtentaclesNameMatchResult = {
|
||||
matchMode: 'cloudtentacles_name' | 'cloudtentacles_override'
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
cloudSkuPrice: number
|
||||
cloudSkuInventory: number
|
||||
cloudSourceKeys: string[]
|
||||
resolvedSourceKey: string
|
||||
deliveryItems: CloudtentaclesResolvedDeliveryItem[]
|
||||
skuSnapshot: JsonObject
|
||||
}
|
||||
|
||||
export async function resolveCloudtentaclesSkuByProductName(
|
||||
productName: unknown,
|
||||
deps: CloudtentaclesNameMatchDeps = {},
|
||||
): Promise<CloudtentaclesNameMatchResult | null> {
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources
|
||||
const getSessionByKey =
|
||||
deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
|
||||
const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku
|
||||
const resolveOverrideRule =
|
||||
deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule
|
||||
const sourcesConfig = listSources()
|
||||
|
||||
if (sourcesConfig.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : [])
|
||||
.map((source: CloudtentaclesSource) =>
|
||||
buildCloudtentaclesSourceContext(source, getSessionByKey),
|
||||
)
|
||||
.filter(
|
||||
(context): context is NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>> =>
|
||||
Boolean(context),
|
||||
)
|
||||
|
||||
if (sourceContexts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const overrideRule = resolveOverrideRule(productName)
|
||||
const overrideMatch = overrideRule
|
||||
? await resolveOverrideMatch(
|
||||
productName,
|
||||
normalizedProductName,
|
||||
overrideRule,
|
||||
sourceContexts,
|
||||
listSku,
|
||||
)
|
||||
: null
|
||||
if (overrideMatch) {
|
||||
return overrideMatch
|
||||
}
|
||||
|
||||
const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey)
|
||||
|
||||
for (const context of sourceContexts) {
|
||||
try {
|
||||
const skuList = await listSku(context)
|
||||
const matchedSku = findCloudtentaclesSkuByName(
|
||||
Array.isArray(skuList.items) ? skuList.items : [],
|
||||
productName,
|
||||
)
|
||||
|
||||
if (!matchedSku) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cloudSkuId = Number(matchedSku.id || 0)
|
||||
const cloudSkuName = String(matchedSku.name || '').trim()
|
||||
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
||||
continue
|
||||
}
|
||||
|
||||
return {
|
||||
matchMode: 'cloudtentacles_name',
|
||||
productName: String(productName || '').trim(),
|
||||
normalizedProductName,
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
cloudSkuPrice: Number(matchedSku.price || 0) || 0,
|
||||
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
||||
cloudSourceKeys,
|
||||
// Matching only discovers compatible accounts. The fulfillment selector
|
||||
// chooses the least-loaded account immediately before taking a number.
|
||||
resolvedSourceKey: '',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
skuSnapshot: { ...matchedSku },
|
||||
}
|
||||
} catch {
|
||||
// 当前账号不可用时继续尝试下一个已登录账号。
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildCloudtentaclesSourceContext(
|
||||
source: CloudtentaclesSource,
|
||||
getSessionByKey: typeof getCloudtentaclesSessionStateByKey,
|
||||
) {
|
||||
const sourceKey = String(source.key || '').trim()
|
||||
if (!sourceKey || source.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = getSessionByKey(sourceKey) as CloudtentaclesSession | null
|
||||
const token = String(session?.token || '').trim()
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
accountLabel: String(source.label || sourceKey).trim() || sourceKey,
|
||||
baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(),
|
||||
token,
|
||||
deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(session?.deviceType ?? source.deviceType),
|
||||
}
|
||||
}
|
||||
|
||||
function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: unknown) {
|
||||
const rawName = String(productName || '').trim()
|
||||
const normalizedName = normalizeCloudtentaclesMatchName(rawName)
|
||||
|
||||
return (
|
||||
items.find((item) => String(item.name || '').trim() === rawName) ||
|
||||
items.find((item) => normalizeCloudtentaclesMatchName(item.name) === normalizedName) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveOverrideMatch(
|
||||
productName: unknown,
|
||||
normalizedProductName: string,
|
||||
rule: CloudtentaclesOverrideRule,
|
||||
sourceContexts: Array<NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>>>,
|
||||
listSku: typeof listCloudtentaclesSku,
|
||||
): Promise<CloudtentaclesNameMatchResult | null> {
|
||||
const candidates = rule.sourceKey
|
||||
? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey)
|
||||
: sourceContexts
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const context of candidates) {
|
||||
try {
|
||||
const skuList = await listSku(context)
|
||||
const items = Array.isArray(skuList.items) ? skuList.items : []
|
||||
const deliveryItems = resolveOverrideDeliveryItems(rule, items)
|
||||
|
||||
if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const firstDeliveryItem = deliveryItems[0]
|
||||
const firstSku =
|
||||
items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {}
|
||||
const cloudSourceKeys = rule.sourceKey
|
||||
? [context.sourceKey]
|
||||
: sourceContexts.map((sourceContext) => sourceContext.sourceKey)
|
||||
|
||||
return {
|
||||
matchMode: 'cloudtentacles_override',
|
||||
productName: String(productName || '').trim(),
|
||||
normalizedProductName,
|
||||
cloudSkuId: firstDeliveryItem.cloudSkuId,
|
||||
cloudSkuName: firstDeliveryItem.cloudSkuName,
|
||||
cloudSkuPrice: Number(firstSku.price || 0) || 0,
|
||||
cloudSkuInventory: Number(firstSku.inventory || 0) || 0,
|
||||
cloudSourceKeys,
|
||||
resolvedSourceKey: rule.sourceKey ? context.sourceKey : '',
|
||||
deliveryItems,
|
||||
skuSnapshot: {
|
||||
overrideRuleId: rule.id,
|
||||
overrideRuleProductName: rule.productName,
|
||||
deliveryItems,
|
||||
firstSku,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
// 当前账号不可用时继续尝试下一个已登录账号。
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveOverrideDeliveryItems(
|
||||
rule: CloudtentaclesOverrideRule,
|
||||
skuItems: CloudtentaclesSku[],
|
||||
) {
|
||||
const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = []
|
||||
|
||||
for (const ruleItem of rule.deliveryItems) {
|
||||
const matchedSku =
|
||||
ruleItem.cloudSkuId > 0
|
||||
? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId)
|
||||
: findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName)
|
||||
const cloudSkuId = Number(matchedSku?.id || 0)
|
||||
const cloudSkuName = String(matchedSku?.name || '').trim()
|
||||
|
||||
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
||||
return []
|
||||
}
|
||||
|
||||
resolvedItems.push({
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: Math.max(1, Number(ruleItem.quantity || 1)),
|
||||
})
|
||||
}
|
||||
|
||||
return mergeResolvedDeliveryItems(resolvedItems)
|
||||
}
|
||||
|
||||
function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) {
|
||||
const merged = new Map<number, CloudtentaclesResolvedDeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const existing = merged.get(item.cloudSkuId)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
continue
|
||||
}
|
||||
|
||||
merged.set(item.cloudSkuId, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
export { normalizeCloudtentaclesMatchName }
|
||||
@@ -1,68 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { normalizeCloudtentaclesOverrideRuleConfig } from './cloudtentacles-override-rule-service.js'
|
||||
|
||||
test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => {
|
||||
const config = normalizeCloudtentaclesOverrideRuleConfig({
|
||||
enabled: true,
|
||||
rules: [
|
||||
{
|
||||
productName: '套装1',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 2,
|
||||
},
|
||||
{
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.equal(config.enabled, true)
|
||||
assert.equal(config.rules.length, 1)
|
||||
assert.deepEqual(config.rules[0]?.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 3,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('normalizeCloudtentaclesOverrideRuleConfig drops invalid rules', () => {
|
||||
const config = normalizeCloudtentaclesOverrideRuleConfig({
|
||||
rules: [
|
||||
{
|
||||
productName: '',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
productName: '有效商品',
|
||||
deliveryItems: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(config.rules, [])
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
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 { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
|
||||
|
||||
const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'cloudtentacles-override-rules.json',
|
||||
)
|
||||
|
||||
export type CloudtentaclesOverrideDeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type CloudtentaclesOverrideRule = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
sourceKey: string
|
||||
deliveryItems: CloudtentaclesOverrideDeliveryItem[]
|
||||
notes: string
|
||||
}
|
||||
|
||||
export type CloudtentaclesOverrideRuleConfig = {
|
||||
enabled: boolean
|
||||
rules: CloudtentaclesOverrideRule[]
|
||||
}
|
||||
|
||||
export function getCloudtentaclesOverrideRuleFilePath() {
|
||||
return CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH
|
||||
}
|
||||
|
||||
export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig {
|
||||
return readAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules,
|
||||
legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH,
|
||||
fallback: createDefaultCloudtentaclesOverrideRuleConfig,
|
||||
normalize: normalizeCloudtentaclesOverrideRuleConfig,
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCloudtentaclesOverrideRuleConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<CloudtentaclesOverrideRuleConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules,
|
||||
legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH,
|
||||
value: rawValue,
|
||||
normalize: normalizeCloudtentaclesOverrideRuleConfig,
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveCloudtentaclesOverrideRule(productName: unknown) {
|
||||
const config = getCloudtentaclesOverrideRuleConfig()
|
||||
if (config.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
config.rules.find(
|
||||
(rule) =>
|
||||
rule.enabled !== false &&
|
||||
rule.normalizedProductName === normalizedProductName &&
|
||||
rule.deliveryItems.length > 0,
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesOverrideRuleConfig(
|
||||
rawValue: unknown,
|
||||
): CloudtentaclesOverrideRuleConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rules = Array.isArray(source.rules) ? source.rules : []
|
||||
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
rules: rules
|
||||
.map((item: unknown) => normalizeCloudtentaclesOverrideRule(item))
|
||||
.filter((item): item is CloudtentaclesOverrideRule => Boolean(item)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCloudtentaclesOverrideRule(rawValue: unknown): CloudtentaclesOverrideRule | null {
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const productName = String(rawValue.productName || rawValue.name || '').trim()
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
const deliveryItems = normalizeDeliveryItems(rawValue.deliveryItems)
|
||||
|
||||
if (!productName || !normalizedProductName || deliveryItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(rawValue.id || normalizedProductName).trim() || normalizedProductName,
|
||||
enabled: rawValue.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName,
|
||||
sourceKey: String(rawValue.sourceKey || '').trim(),
|
||||
deliveryItems,
|
||||
notes: String(rawValue.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeliveryItems(value: unknown) {
|
||||
const rawItems = Array.isArray(value) ? value : []
|
||||
const items = rawItems
|
||||
.map((item) => normalizeDeliveryItem(item))
|
||||
.filter((item): item is CloudtentaclesOverrideDeliveryItem => Boolean(item))
|
||||
|
||||
return mergeDeliveryItems(items)
|
||||
}
|
||||
|
||||
function normalizeDeliveryItem(value: unknown): CloudtentaclesOverrideDeliveryItem | null {
|
||||
const source = isPlainObject(value) ? value : {}
|
||||
const cloudSkuId = normalizePositiveInteger(source.cloudSkuId || source.skuId, 0)
|
||||
const cloudSkuName = String(source.cloudSkuName || source.skuName || '').trim()
|
||||
const quantity = normalizePositiveInteger(source.quantity, 1)
|
||||
|
||||
if (!cloudSkuId && !cloudSkuName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) {
|
||||
const merged = new Map<string, CloudtentaclesOverrideDeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const key =
|
||||
item.cloudSkuId > 0
|
||||
? `id:${item.cloudSkuId}`
|
||||
: `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}`
|
||||
const existing = merged.get(key)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
existing.cloudSkuId = existing.cloudSkuId || item.cloudSkuId
|
||||
continue
|
||||
}
|
||||
|
||||
merged.set(key, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function createDefaultCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function stubCreatedTask(id: number, input: CreatedTaskInput): TaskRow {
|
||||
} as TaskRow
|
||||
}
|
||||
|
||||
test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles 账号', async () => {
|
||||
test('syncDeliveryTasksForOrder 为 affiliate_dash 商品创建任务上下文', async () => {
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
|
||||
await syncDeliveryTasksForOrderWithDeps(
|
||||
@@ -113,34 +113,15 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles
|
||||
stubOrderItem({
|
||||
id: 10,
|
||||
order_id: 1,
|
||||
sku_code: 'SKU-1',
|
||||
sku_name: '荣耀勋章礼包(30个)',
|
||||
sku_code: 'AFF-1001',
|
||||
sku_name: '幸运币90个',
|
||||
quantity: 1,
|
||||
item_snapshot_json: {
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys: ['account-a', 'account-b'],
|
||||
resolvedSourceKey: 'account-a',
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: '荣耀勋章礼包(30个)',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: '荣耀勋章礼包(30个)',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as JsonObject,
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-1001', '幸运币90个'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => [],
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 9,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: 'kuaishou-lewan 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
}),
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(100, input as CreatedTaskInput)
|
||||
@@ -151,61 +132,12 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(createdInputs[0]?.executorKey, 'affiliate_dash')
|
||||
const context = JSON.parse(String(createdInputs[0]?.contextJson || '{}')) as JsonObject
|
||||
const fulfillment = context.kuaishouCloudFulfillment as JsonObject
|
||||
const binding = fulfillment.binding as JsonObject
|
||||
assert.deepEqual(binding.cloudSourceKeys, ['account-a', 'account-b'])
|
||||
assert.equal(binding.resolvedSourceKey, '')
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles 账号', async () => {
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
|
||||
await syncDeliveryTasksForOrderWithDeps(
|
||||
stubOrder({
|
||||
id: 2,
|
||||
pay_status: 'pending_payment',
|
||||
platform_order_id: '2614900602069170',
|
||||
}),
|
||||
[
|
||||
stubOrderItem({
|
||||
id: 11,
|
||||
order_id: 2,
|
||||
sku_code: 'SKU-1',
|
||||
sku_name: '荣耀勋章礼包(30个)',
|
||||
quantity: 1,
|
||||
item_snapshot_json: {
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys: ['account-a'],
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: '荣耀勋章礼包(30个)',
|
||||
},
|
||||
} as JsonObject,
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => [],
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 9,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: 'kuaishou-lewan 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
}),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(101, input as CreatedTaskInput)
|
||||
},
|
||||
nowIso: () => '2026-05-29T09:05:46.658Z',
|
||||
randomId: () => 'DT-test',
|
||||
createTaskEvent: async () => null,
|
||||
},
|
||||
)
|
||||
|
||||
const context = JSON.parse(String(createdInputs[0]?.contextJson || '{}')) as JsonObject
|
||||
const fulfillment = context.kuaishouCloudFulfillment as JsonObject
|
||||
const binding = fulfillment.binding as JsonObject
|
||||
assert.deepEqual(binding.cloudSourceKeys, ['account-a'])
|
||||
assert.equal(binding.resolvedSourceKey, 'account-a')
|
||||
const affiliateDash = context.affiliateDash as JsonObject
|
||||
assert.equal(affiliateDash.sku, 'AFF-1001')
|
||||
assert.equal(affiliateDash.productName, '幸运币90个')
|
||||
assert.equal(affiliateDash.flowType, 'affiliate_dash')
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任务', async () => {
|
||||
@@ -213,7 +145,7 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任
|
||||
orderId: 3,
|
||||
orderItemId: 20,
|
||||
unitIndex: 1,
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
executorKey: 'affiliate_dash',
|
||||
})
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
const taskEvents: Array<{ taskId: number; eventType: string }> = []
|
||||
@@ -225,18 +157,18 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任
|
||||
id: 20,
|
||||
order_id: 3,
|
||||
sku_code: 'SKU-existing',
|
||||
item_snapshot_json: buildCloudSnapshot(74, '商品 A'),
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
|
||||
}),
|
||||
stubOrderItem({
|
||||
id: 21,
|
||||
order_id: 3,
|
||||
sku_code: 'SKU-missing',
|
||||
item_snapshot_json: buildCloudSnapshot(75, '商品 B'),
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-B', '商品 B'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => [existingTask],
|
||||
getFulfillmentProfileByKey: async () => buildCloudProfile(),
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(202, input as CreatedTaskInput)
|
||||
@@ -268,7 +200,7 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async ()
|
||||
orderId: 4,
|
||||
orderItemId: 30,
|
||||
unitIndex: 1,
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
executorKey: 'affiliate_dash',
|
||||
})
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
|
||||
@@ -280,12 +212,12 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async ()
|
||||
order_id: 4,
|
||||
sku_code: 'SKU-quantity',
|
||||
quantity: 2,
|
||||
item_snapshot_json: buildCloudSnapshot(74, '商品 A'),
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => [existingTask],
|
||||
getFulfillmentProfileByKey: async () => buildCloudProfile(),
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(302, input as CreatedTaskInput)
|
||||
@@ -321,20 +253,18 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断',
|
||||
item_snapshot_json: {
|
||||
isConfigured: true,
|
||||
fulfillmentRoute: {
|
||||
selectedExecutorKey: 'kuaishou_ct_assisted',
|
||||
reason: '规则指定云履约',
|
||||
selectedExecutorKey: 'affiliate_dash',
|
||||
reason: '规则指定 affiliate',
|
||||
},
|
||||
cloudtentacles: {
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: '商品 A',
|
||||
cloudSourceKeys: [],
|
||||
affiliateDash: {
|
||||
skuName: '商品 A',
|
||||
},
|
||||
} as JsonObject,
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => [],
|
||||
getFulfillmentProfileByKey: async () => buildCloudProfile(),
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTaskEvent: async () => null,
|
||||
logInfo: () => null,
|
||||
logWarn: (_scope, message, detail) => {
|
||||
@@ -354,13 +284,13 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断',
|
||||
assert.deepEqual(warnings[0]?.detail.missingUnitIndexes, [1])
|
||||
assert.deepEqual(warnings[0]?.detail.planningDiagnostic, {
|
||||
snapshotIsConfigured: true,
|
||||
selectedExecutorKey: 'kuaishou_ct_assisted',
|
||||
routeReason: '规则指定云履约',
|
||||
selectedExecutorKey: 'affiliate_dash',
|
||||
routeReason: '规则指定 affiliate',
|
||||
candidateAvailability: [],
|
||||
sourceItemTitle: '',
|
||||
sourceSkuNick: '',
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: '商品 A',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
cloudSourceKeys: [],
|
||||
feifeiProductCode: '',
|
||||
affiliateDashSku: '',
|
||||
@@ -372,7 +302,7 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
|
||||
orderId: 6,
|
||||
orderItemId: 50,
|
||||
unitIndex: 1,
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
executorKey: 'affiliate_dash',
|
||||
})
|
||||
let listCount = 0
|
||||
let eventCount = 0
|
||||
@@ -384,12 +314,12 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
|
||||
id: 50,
|
||||
order_id: 6,
|
||||
sku_code: 'SKU-concurrent',
|
||||
item_snapshot_json: buildCloudSnapshot(74, '商品 A'),
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]),
|
||||
getFulfillmentProfileByKey: async () => buildCloudProfile(),
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async () => {
|
||||
throw new Error('duplicate key value violates unique constraint')
|
||||
},
|
||||
@@ -411,25 +341,25 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
|
||||
assert.equal(eventCount, 0)
|
||||
})
|
||||
|
||||
function buildCloudSnapshot(cloudSkuId: number, cloudSkuName: string): JsonObject {
|
||||
function buildAffiliateSnapshot(sku: string, productName: string): JsonObject {
|
||||
return {
|
||||
isConfigured: true,
|
||||
fulfillmentRoute: {
|
||||
selectedExecutorKey: 'kuaishou_ct_assisted',
|
||||
selectedExecutorKey: 'affiliate_dash',
|
||||
},
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys: ['account-a'],
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
affiliateDash: {
|
||||
sku,
|
||||
skuName: productName,
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudProfile() {
|
||||
function buildAffiliateProfile() {
|
||||
return {
|
||||
id: 9,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: 'kuaishou-lewan 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
id: 11,
|
||||
profile_key: 'affiliate_dash',
|
||||
profile_name: 'affiliate-dash 履约',
|
||||
executor_key: 'affiliate_dash',
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,4 +1,5 @@
|
||||
export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
/** 商品名归一化:用于履约路由规则匹配等。 */
|
||||
export function normalizeProductMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】[\]()()]/g, ' ')
|
||||
@@ -7,3 +8,8 @@ export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** @deprecated 使用 normalizeProductMatchName */
|
||||
export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
return normalizeProductMatchName(value)
|
||||
}
|
||||
Reference in New Issue
Block a user