优化多账号选择

This commit is contained in:
yml2213
2026-05-29 22:34:41 +08:00
parent e5ad6f4918
commit f408fe1804
6 changed files with 1006 additions and 517 deletions
@@ -1,4 +1,5 @@
import { query } from '../db/client.js' import { query } from '../db/client.js'
import { TASK_STATUS } from '../domain/task-status.js'
import { maskCode } from '../utils/masking.js' import { maskCode } from '../utils/masking.js'
import { parseTaskContext } from '../utils/task-json.js' import { parseTaskContext } from '../utils/task-json.js'
import { normalizeTimestampIso } from '../utils/time.js' import { normalizeTimestampIso } from '../utils/time.js'
@@ -6,6 +7,12 @@ import { normalizeTimestampIso } from '../utils/time.js'
type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
type JsonRecord = Record<string, any> type JsonRecord = Record<string, any>
export type KuaishouCloudSourceLoadStat = {
sourceKey: string
activeCount: number
redeemingCount: number
}
export type KuaishouCloudTaskStateSyncInput = { export type KuaishouCloudTaskStateSyncInput = {
taskId: number | string taskId: number | string
executorKey?: unknown executorKey?: unknown
@@ -86,6 +93,49 @@ export async function syncKuaishouCloudTaskStateForTask(
) )
} }
export async function listKuaishouCloudSourceLoadStats(
sourceKeys: unknown[] = [],
executor: QueryExecutor = query,
): Promise<KuaishouCloudSourceLoadStat[]> {
const candidates = normalizeStringArray(sourceKeys)
if (candidates.length === 0) {
return []
}
const activeStatuses = [
TASK_STATUS.WAITING_BINDING,
TASK_STATUS.ROLE_CONFIRMED,
TASK_STATUS.REDEEMING,
TASK_STATUS.DISPATCHED_PENDING_RETURN,
]
const result = await executor(
`
SELECT
kcts.source_key,
COUNT(*) FILTER (WHERE ft.task_status = ANY($2::text[])) AS active_count,
COUNT(*) FILTER (WHERE ft.task_status = $3) AS redeeming_count
FROM fulfillment_tasks ft
JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
WHERE ft.executor_key = 'kuaishou_ct_assisted'
AND kcts.source_key = ANY($1::text[])
AND ft.task_status = ANY($2::text[])
GROUP BY kcts.source_key
`,
[candidates, activeStatuses, TASK_STATUS.REDEEMING],
)
return result.rows
.map((row) => {
const source = asRecord(row)
return {
sourceKey: String(source.source_key || '').trim(),
activeCount: Number(source.active_count || 0) || 0,
redeemingCount: Number(source.redeeming_count || 0) || 0,
}
})
.filter((row) => row.sourceKey)
}
function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null { function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null {
const context = parseTaskContext({ context_json: contextJson }) const context = parseTaskContext({ context_json: contextJson })
const flow = asRecord(context.kuaishouCloudFulfillment) const flow = asRecord(context.kuaishouCloudFulfillment)
@@ -93,13 +143,32 @@ function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null {
} }
function asRecord(value: unknown): JsonRecord { function asRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {}
} }
function normalizeStatus(value: unknown): string { function normalizeStatus(value: unknown): string {
return String(value || 'pending').trim() || 'pending' return String(value || 'pending').trim() || 'pending'
} }
function normalizeStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
if (typeof value === 'string') {
return Array.from(
new Set(
value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
),
)
}
return []
}
function pickFirstNonEmpty(values: unknown[]): string { function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) { for (const value of values) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { selectCloudtentaclesSourceForFulfillment } from './account-selector.js'
const contexts = {
'account-a': {
baseUrl: 'https://cloud.example.com',
token: 'token-a',
deviceId: '-',
deviceType: 0,
resolvedSourceKey: 'account-a',
},
'account-b': {
baseUrl: 'https://cloud.example.com',
token: 'token-b',
deviceId: '-',
deviceType: 0,
resolvedSourceKey: 'account-b',
},
}
test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切换', async () => {
const selection = await selectCloudtentaclesSourceForFulfillment(
{
binding: {
resolvedSourceKey: 'account-a',
cloudSourceKeys: ['account-a', 'account-b'],
},
},
{
resolveContextBySourceKeys: (sourceKeys) => {
const key = String(sourceKeys[0] || '').trim()
return contexts[key as keyof typeof contexts]
},
listSourceLoadStats: async () => [
{ sourceKey: 'account-a', activeCount: 99, redeemingCount: 99 },
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
],
},
)
assert.equal(selection.sourceKey, 'account-a')
assert.equal(selection.fixed, true)
})
test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => {
const selection = await selectCloudtentaclesSourceForFulfillment(
{
binding: {
cloudSourceKeys: ['account-a', 'account-b'],
},
},
{
resolveContextBySourceKeys: (sourceKeys) => {
const key = String(sourceKeys[0] || '').trim()
return contexts[key as keyof typeof contexts]
},
listSourceLoadStats: async () => [
{ sourceKey: 'account-a', activeCount: 3, redeemingCount: 1 },
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
],
},
)
try {
assert.equal(selection.sourceKey, 'account-b')
assert.equal(selection.fixed, false)
} finally {
selection.release()
}
})
test('selectCloudtentaclesSourceForFulfillment 会跳过不可用账号', async () => {
const selection = await selectCloudtentaclesSourceForFulfillment(
{
binding: {
cloudSourceKeys: ['account-a', 'account-b'],
},
},
{
resolveContextBySourceKeys: (sourceKeys) => {
const key = String(sourceKeys[0] || '').trim()
if (key === 'account-a') {
throw new Error('账号已停用')
}
return contexts[key as keyof typeof contexts]
},
listSourceLoadStats: async () => [],
},
)
try {
assert.equal(selection.sourceKey, 'account-b')
assert.equal(selection.candidateCount, 1)
} finally {
selection.release()
}
})
test('selectCloudtentaclesSourceForFulfillment 用进程内占位分散并发准备', async () => {
const deps = {
resolveContextBySourceKeys: (sourceKeys: unknown[] = []) => {
const key = String(sourceKeys[0] || '').trim()
return contexts[key as keyof typeof contexts]
},
listSourceLoadStats: async () => [],
}
const flow = {
binding: {
cloudSourceKeys: ['account-a', 'account-b'],
},
}
const first = await selectCloudtentaclesSourceForFulfillment(flow, deps)
const second = await selectCloudtentaclesSourceForFulfillment(flow, deps)
try {
assert.equal(first.sourceKey, 'account-a')
assert.equal(second.sourceKey, 'account-b')
} finally {
first.release()
second.release()
}
})
@@ -0,0 +1,182 @@
import { listKuaishouCloudSourceLoadStats } from '../../../repositories/kuaishou-cloud-task-state-repo.js'
import { normalizeKuaishouCloudFlow, normalizeStringArray, type JsonObject } from './domain.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
type CloudtentaclesContext = ReturnType<typeof resolvePersistedCloudtentaclesContextBySourceKeys>
type CloudtentaclesSourceSelectionDeps = {
resolveContextBySourceKeys?: typeof resolvePersistedCloudtentaclesContextBySourceKeys
listSourceLoadStats?: typeof listKuaishouCloudSourceLoadStats
}
export type CloudtentaclesSourceSelection = {
context: CloudtentaclesContext
sourceKey: string
candidateCount: number
activeCount: number
redeemingCount: number
inProcessCount: number
score: number
fixed: boolean
release: () => void
}
const inProcessSelections = new Map<string, number>()
let selectionLockTail: Promise<void> = Promise.resolve()
export async function selectCloudtentaclesSourceForFulfillment(
flowLike: unknown,
deps: CloudtentaclesSourceSelectionDeps = {},
): Promise<CloudtentaclesSourceSelection> {
const flow = normalizeKuaishouCloudFlow(flowLike)
const resolveContext =
deps.resolveContextBySourceKeys || resolvePersistedCloudtentaclesContextBySourceKeys
const listSourceLoadStats = deps.listSourceLoadStats || listKuaishouCloudSourceLoadStats
const fixedSourceKey = String(flow.binding.resolvedSourceKey || '').trim()
const candidates = normalizeCloudtentaclesSourceCandidates(
fixedSourceKey
? [fixedSourceKey, ...flow.binding.cloudSourceKeys]
: flow.binding.cloudSourceKeys,
)
if (fixedSourceKey) {
const context = resolveContext([fixedSourceKey, ...flow.binding.cloudSourceKeys])
return {
context,
sourceKey: context.resolvedSourceKey,
candidateCount: candidates.length,
activeCount: 0,
redeemingCount: 0,
inProcessCount: 0,
score: 0,
fixed: true,
release: () => {},
}
}
return withCloudtentaclesSourceSelectionLock(async () => {
const contexts = []
let lastError: unknown = null
for (const sourceKey of candidates) {
try {
contexts.push(resolveContext([sourceKey]))
} catch (error) {
lastError = error
}
}
if (contexts.length === 0) {
if (lastError) {
throw lastError
}
resolveContext(candidates)
throw new Error('所有 cloudtentacles 账号均不可用')
}
const stats = await listSourceLoadStats(contexts.map((context) => context.resolvedSourceKey))
const statsBySourceKey = new Map(stats.map((stat) => [stat.sourceKey, stat]))
const ranked = contexts
.map((context, index) => {
const stat = statsBySourceKey.get(context.resolvedSourceKey) || {
sourceKey: context.resolvedSourceKey,
activeCount: 0,
redeemingCount: 0,
}
const activeCount = normalizeCount(stat.activeCount)
const redeemingCount = normalizeCount(stat.redeemingCount)
const inProcessCount = normalizeCount(inProcessSelections.get(context.resolvedSourceKey))
const score = (activeCount + inProcessCount) * 10 + redeemingCount * 20
return {
context,
sourceKey: context.resolvedSourceKey,
index,
activeCount,
redeemingCount,
inProcessCount,
score,
}
})
.sort(
(left, right) =>
left.score - right.score ||
left.activeCount - right.activeCount ||
left.redeemingCount - right.redeemingCount ||
left.index - right.index ||
left.sourceKey.localeCompare(right.sourceKey),
)
const selected = ranked[0]
if (!selected) {
throw new Error('所有 cloudtentacles 账号均不可用')
}
reserveCloudtentaclesSource(selected.sourceKey)
return {
context: selected.context,
sourceKey: selected.sourceKey,
candidateCount: contexts.length,
activeCount: selected.activeCount,
redeemingCount: selected.redeemingCount,
inProcessCount: selected.inProcessCount,
score: selected.score,
fixed: false,
release: () => releaseCloudtentaclesSource(selected.sourceKey),
}
})
}
export function buildCloudtentaclesSourceSelectionEventDetail(
selection: CloudtentaclesSourceSelection,
): JsonObject {
return {
sourceKey: selection.sourceKey,
candidateCount: selection.candidateCount,
activeCount: selection.activeCount,
redeemingCount: selection.redeemingCount,
inProcessCount: selection.inProcessCount,
score: selection.score,
fixed: selection.fixed,
}
}
function normalizeCloudtentaclesSourceCandidates(sourceKeys: unknown[] = []) {
return Array.from(new Set(normalizeStringArray(sourceKeys)))
}
async function withCloudtentaclesSourceSelectionLock<T>(fn: () => Promise<T>): Promise<T> {
let releaseLock: () => void = () => {}
const waitForTurn = selectionLockTail
selectionLockTail = new Promise<void>((resolve) => {
releaseLock = resolve
})
await waitForTurn
try {
return await fn()
} finally {
releaseLock()
}
}
function reserveCloudtentaclesSource(sourceKey: string) {
inProcessSelections.set(sourceKey, normalizeCount(inProcessSelections.get(sourceKey)) + 1)
}
function releaseCloudtentaclesSource(sourceKey: string) {
const nextCount = normalizeCount(inProcessSelections.get(sourceKey)) - 1
if (nextCount > 0) {
inProcessSelections.set(sourceKey, nextCount)
return
}
inProcessSelections.delete(sourceKey)
}
function normalizeCount(value: unknown) {
const count = Number(value || 0) || 0
return Number.isFinite(count) && count > 0 ? count : 0
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { syncDeliveryTasksForOrderWithDeps } from './delivery-task-service.js'
test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles 账号', async () => {
const createdInputs: any[] = []
await syncDeliveryTasksForOrderWithDeps(
{
id: 1,
pay_status: 'pending_payment',
provider: 'open_91',
platform: 'kuaishou',
shop_id: 'shop-1',
shop_name: '测试店铺',
platform_order_id: '2614900602069169',
} as any,
[
{
id: 10,
order_id: 1,
sku_code: 'SKU-1',
sku_name: '荣耀勋章礼包(30个)',
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 any,
{
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => ({
id: 9,
profile_key: 'kuaishou_ct_assisted',
profile_name: '快手 cloud 履约',
executor_key: 'kuaishou_ct_assisted',
}),
createTask: async (input: any) => {
createdInputs.push(input)
return {
id: 100,
order_item_id: input.orderItemId,
context_json: input.contextJson,
task_status: input.taskStatus,
executor_key: input.executorKey,
} as any
},
nowIso: () => '2026-05-29T09:05:46.658Z',
randomId: () => 'DT-test',
},
)
const context = JSON.parse(createdInputs[0].contextJson)
assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, [
'account-a',
'account-b',
])
assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, '')
})
test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles 账号', async () => {
const createdInputs: any[] = []
await syncDeliveryTasksForOrderWithDeps(
{
id: 2,
pay_status: 'pending_payment',
provider: 'open_91',
platform: 'kuaishou',
shop_id: 'shop-1',
shop_name: '测试店铺',
platform_order_id: '2614900602069170',
} as any,
[
{
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 any,
{
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => ({
id: 9,
profile_key: 'kuaishou_ct_assisted',
profile_name: '快手 cloud 履约',
executor_key: 'kuaishou_ct_assisted',
}),
createTask: async (input: any) => {
createdInputs.push(input)
return {
id: 101,
order_item_id: input.orderItemId,
context_json: input.contextJson,
task_status: input.taskStatus,
executor_key: input.executorKey,
} as any
},
nowIso: () => '2026-05-29T09:05:46.658Z',
randomId: () => 'DT-test',
},
)
const context = JSON.parse(createdInputs[0].contextJson)
assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, ['account-a'])
assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, 'account-a')
})
@@ -53,13 +53,12 @@ type DeliveryTaskDeps = {
randomId?: (prefix?: string) => string randomId?: (prefix?: string) => string
} }
type RuntimeDeliveryTaskDeps = Required<Pick< type RuntimeDeliveryTaskDeps = Required<
DeliveryTaskDeps, Pick<
'updateTask' DeliveryTaskDeps,
| 'createTaskClaimToken' 'updateTask' | 'createTaskClaimToken' | 'notifyTaskAutoManualReview' | 'nowIso'
| 'notifyTaskAutoManualReview' >
| 'nowIso' >
>>
type TaskContext = { type TaskContext = {
[key: string]: unknown [key: string]: unknown
@@ -103,11 +102,18 @@ export async function syncDeliveryTasksForOrderWithDeps(
} }
const itemMap = new Map(orderItems.map((item) => [item.id, item])) const itemMap = new Map(orderItems.map((item) => [item.id, item]))
const preparedTasks = await Promise.all(existingTasks.map((task) => preparePaidTask({ const preparedTasks = await Promise.all(
...task, existingTasks.map((task) =>
skuCode: itemMap.get(task.order_item_id)?.sku_code || '', preparePaidTask(
skuName: itemMap.get(task.order_item_id)?.sku_name || '', {
}, runtimeDeps))) ...task,
skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
skuName: itemMap.get(task.order_item_id)?.sku_name || '',
},
runtimeDeps,
),
),
)
return preparedTasks.filter(isTaskRow) return preparedTasks.filter(isTaskRow)
} }
@@ -125,6 +131,11 @@ export async function syncDeliveryTasksForOrderWithDeps(
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
const resolvedCloudSourceKey =
cloudSourceKeys.length === 1
? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim()
: ''
const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig)
const primaryDeliveryItem = deliveryItems[0] || { const primaryDeliveryItem = deliveryItems[0] || {
cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0,
@@ -134,9 +145,8 @@ export async function syncDeliveryTasksForOrderWithDeps(
for (let index = 0; index < quantity; index += 1) { for (let index = 0; index < quantity; index += 1) {
const createdAt = getNowIso() const createdAt = getNowIso()
const initialStatus = order.pay_status === 'paid' const initialStatus =
? resolvePaidTaskStatus(profile) order.pay_status === 'paid' ? resolvePaidTaskStatus(profile) : TASK_STATUS.PENDING_PAYMENT
: TASK_STATUS.PENDING_PAYMENT
const task = await createDeliveryTask({ const task = await createDeliveryTask({
orderId: order.id, orderId: order.id,
@@ -168,78 +178,90 @@ export async function syncDeliveryTasksForOrderWithDeps(
skuName: item.sku_name, skuName: item.sku_name,
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key) kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
? { ? {
flowType: 'kuaishou_cloud_fulfillment', flowType: 'kuaishou_cloud_fulfillment',
configId: String(fulfillmentConfig.configId || '').trim(), configId: String(fulfillmentConfig.configId || '').trim(),
internalSkuCode: item.sku_code, internalSkuCode: item.sku_code,
internalSkuName: item.sku_name, internalSkuName: item.sku_name,
deliveryItems, deliveryItems,
ticket: { ticket: {
code: '', code: '',
status: 'pending', status: 'pending',
capturedAt: null, capturedAt: null,
capturedBy: null, capturedBy: null,
verifiedAt: null, verifiedAt: null,
oid: '', oid: '',
formToken: '', formToken: '',
leftCount: 0, leftCount: 0,
goodsTitle: '', goodsTitle: '',
}, },
binding: { binding: {
prepareStatus: 'pending', prepareStatus: 'pending',
cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys), cloudSourceKeys,
resolvedSourceKey: String(cloudtentaclesConfig.resolvedSourceKey || '').trim(), resolvedSourceKey: resolvedCloudSourceKey,
skuId: primaryDeliveryItem.cloudSkuId, skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName, skuName: primaryDeliveryItem.cloudSkuName,
vnKey: '1', vnKey: '1',
vnId: 0, vnId: 0,
vnPhone: '', vnPhone: '',
bindUrl: '', bindUrl: '',
bindPreparedAt: null, bindPreparedAt: null,
bindExpiresAt: null, bindExpiresAt: null,
bindProbeAt: null, bindProbeAt: null,
bindProbeStatus: '', bindProbeStatus: '',
bindProbeMessage: '', bindProbeMessage: '',
}, },
role: { role: {
status: 'pending', status: 'pending',
name: '', name: '',
rid: '', rid: '',
refreshedAt: null, refreshedAt: null,
errorMessage: '', errorMessage: '',
rawInfo: null, rawInfo: null,
}, },
purchase: { purchase: {
autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false,
minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0,
usedKnapsack: false, usedKnapsack: false,
purchaseTriggered: false, purchaseTriggered: false,
assetBefore: 0, assetBefore: 0,
assetAfter: 0, assetAfter: 0,
purchaseAt: null, purchaseAt: null,
}, },
dispatch: { dispatch: {
status: 'pending', status: 'pending',
dispatchAt: null, dispatchAt: null,
dispatchBy: null, dispatchBy: null,
sendType: 0, sendType: 0,
note: '', note: '',
}, },
returnNumber: { returnNumber: {
status: 'pending', status: 'pending',
returnedAt: null, returnedAt: null,
returnedBy: null, returnedBy: null,
autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true,
}, },
consume: { consume: {
status: 'pending', status: 'pending',
shopId: String(kuaishouConsumeConfig.shopId || kuaishouShopConfig.shopId || itemSnapshot.shopId || order.shop_id || '').trim(), shopId: String(
shopName: String(kuaishouConsumeConfig.shopName || kuaishouShopConfig.kshopName || itemSnapshot.shopName || order.shop_name || '').trim(), kuaishouConsumeConfig.shopId ||
autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, kuaishouShopConfig.shopId ||
consumedAt: null, itemSnapshot.shopId ||
errorMessage: '', order.shop_id ||
}, '',
notes: String(fulfillmentConfig.notes || '').trim(), ).trim(),
} shopName: String(
kuaishouConsumeConfig.shopName ||
kuaishouShopConfig.kshopName ||
itemSnapshot.shopName ||
order.shop_name ||
'',
).trim(),
autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true,
consumedAt: null,
errorMessage: '',
},
notes: String(fulfillmentConfig.notes || '').trim(),
}
: null, : null,
}), }),
createdAt, createdAt,
@@ -344,7 +366,9 @@ function parseTaskContext(task: { context_json?: unknown } | null | undefined):
try { try {
const parsed = JSON.parse(String(value || '{}')) const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as TaskContext : {} return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as TaskContext)
: {}
} catch { } catch {
return {} return {}
} }
@@ -361,7 +385,9 @@ function parseJsonObject(value: unknown): JsonObject {
try { try {
const parsed = JSON.parse(String(value || '{}')) const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as JsonObject : {} return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as JsonObject)
: {}
} catch { } catch {
return {} return {}
} }
@@ -385,7 +411,11 @@ async function resolveDynamicCloudtentaclesProfile(
quantity: 1, quantity: 1,
} }
if (!primaryDeliveryItem.cloudSkuId || !primaryDeliveryItem.cloudSkuName || cloudSourceKeys.length === 0) { if (
!primaryDeliveryItem.cloudSkuId ||
!primaryDeliveryItem.cloudSkuName ||
cloudSourceKeys.length === 0
) {
return null return null
} }
@@ -409,7 +439,10 @@ async function resolveDynamicCloudtentaclesProfile(
cloudSourceKeys, cloudSourceKeys,
skuId: primaryDeliveryItem.cloudSkuId, skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName, skuName: primaryDeliveryItem.cloudSkuName,
resolvedSourceKey: String(cloudtentacles.resolvedSourceKey || '').trim(), resolvedSourceKey:
cloudSourceKeys.length === 1
? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim()
: '',
deliveryItems, deliveryItems,
vnKey: '1', vnKey: '1',
autoBuyEnabled: true, autoBuyEnabled: true,
@@ -421,9 +454,10 @@ async function resolveDynamicCloudtentaclesProfile(
shopName: String(snapshot.shopName || '').trim(), shopName: String(snapshot.shopName || '').trim(),
autoConsumeAfterDispatch: false, autoConsumeAfterDispatch: false,
}, },
notes: cloudtentacles.matchMode === 'cloudtentacles_override' notes:
? '91卡券商品名命中 cloudtentacles 覆盖规则' cloudtentacles.matchMode === 'cloudtentacles_override'
: '91卡券商品名自动匹配 cloudtentacles 商品', ? '91卡券商品名命中 cloudtentacles 覆盖规则'
: '91卡券商品名自动匹配 cloudtentacles 商品',
}, },
} }
} }
@@ -433,9 +467,7 @@ function normalizeStringArray(value: unknown): string[] {
return [] return []
} }
return Array.from( return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
new Set(value.map((item) => String(item || '').trim()).filter(Boolean)),
)
} }
function normalizeCloudDeliveryItems(value: JsonObject): Array<{ function normalizeCloudDeliveryItems(value: JsonObject): Array<{
@@ -446,7 +478,9 @@ function normalizeCloudDeliveryItems(value: JsonObject): Array<{
const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : []
const items = rawItems const items = rawItems
.map((item) => normalizeCloudDeliveryItem(item)) .map((item) => normalizeCloudDeliveryItem(item))
.filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => Boolean(item)) .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } =>
Boolean(item),
)
if (items.length > 0) { if (items.length > 0) {
return mergeCloudDeliveryItems(items) return mergeCloudDeliveryItems(items)
@@ -457,17 +491,18 @@ function normalizeCloudDeliveryItems(value: JsonObject): Array<{
return [] return []
} }
return [{ return [
cloudSkuId, {
cloudSkuName: String(value.skuName || '').trim(), cloudSkuId,
quantity: 1, cloudSkuName: String(value.skuName || '').trim(),
}] quantity: 1,
},
]
} }
function normalizeCloudDeliveryItem(value: unknown) { function normalizeCloudDeliveryItem(value: unknown) {
const source = value && typeof value === 'object' && !Array.isArray(value) const source =
? value as JsonObject value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
: {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) {
return null return null