优化多账号选择
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user