优化多账号选择

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 { TASK_STATUS } from '../domain/task-status.js'
import { maskCode } from '../utils/masking.js'
import { parseTaskContext } from '../utils/task-json.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 JsonRecord = Record<string, any>
export type KuaishouCloudSourceLoadStat = {
sourceKey: string
activeCount: number
redeemingCount: number
}
export type KuaishouCloudTaskStateSyncInput = {
taskId: number | string
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 {
const context = parseTaskContext({ context_json: contextJson })
const flow = asRecord(context.kuaishouCloudFulfillment)
@@ -93,13 +143,32 @@ function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null {
}
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 {
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 {
for (const value of values) {
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
}
type RuntimeDeliveryTaskDeps = Required<Pick<
type RuntimeDeliveryTaskDeps = Required<
Pick<
DeliveryTaskDeps,
'updateTask'
| 'createTaskClaimToken'
| 'notifyTaskAutoManualReview'
| 'nowIso'
>>
'updateTask' | 'createTaskClaimToken' | 'notifyTaskAutoManualReview' | 'nowIso'
>
>
type TaskContext = {
[key: string]: unknown
@@ -103,11 +102,18 @@ export async function syncDeliveryTasksForOrderWithDeps(
}
const itemMap = new Map(orderItems.map((item) => [item.id, item]))
const preparedTasks = await Promise.all(existingTasks.map((task) => preparePaidTask({
const preparedTasks = await Promise.all(
existingTasks.map((task) =>
preparePaidTask(
{
...task,
skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
skuName: itemMap.get(task.order_item_id)?.sku_name || '',
}, runtimeDeps)))
},
runtimeDeps,
),
),
)
return preparedTasks.filter(isTaskRow)
}
@@ -125,6 +131,11 @@ export async function syncDeliveryTasksForOrderWithDeps(
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
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 primaryDeliveryItem = deliveryItems[0] || {
cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0,
@@ -134,9 +145,8 @@ export async function syncDeliveryTasksForOrderWithDeps(
for (let index = 0; index < quantity; index += 1) {
const createdAt = getNowIso()
const initialStatus = order.pay_status === 'paid'
? resolvePaidTaskStatus(profile)
: TASK_STATUS.PENDING_PAYMENT
const initialStatus =
order.pay_status === 'paid' ? resolvePaidTaskStatus(profile) : TASK_STATUS.PENDING_PAYMENT
const task = await createDeliveryTask({
orderId: order.id,
@@ -186,8 +196,8 @@ export async function syncDeliveryTasksForOrderWithDeps(
},
binding: {
prepareStatus: 'pending',
cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys),
resolvedSourceKey: String(cloudtentaclesConfig.resolvedSourceKey || '').trim(),
cloudSourceKeys,
resolvedSourceKey: resolvedCloudSourceKey,
skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName,
vnKey: '1',
@@ -232,8 +242,20 @@ export async function syncDeliveryTasksForOrderWithDeps(
},
consume: {
status: 'pending',
shopId: String(kuaishouConsumeConfig.shopId || kuaishouShopConfig.shopId || itemSnapshot.shopId || order.shop_id || '').trim(),
shopName: String(kuaishouConsumeConfig.shopName || kuaishouShopConfig.kshopName || itemSnapshot.shopName || order.shop_name || '').trim(),
shopId: String(
kuaishouConsumeConfig.shopId ||
kuaishouShopConfig.shopId ||
itemSnapshot.shopId ||
order.shop_id ||
'',
).trim(),
shopName: String(
kuaishouConsumeConfig.shopName ||
kuaishouShopConfig.kshopName ||
itemSnapshot.shopName ||
order.shop_name ||
'',
).trim(),
autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true,
consumedAt: null,
errorMessage: '',
@@ -344,7 +366,9 @@ function parseTaskContext(task: { context_json?: unknown } | null | undefined):
try {
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 {
return {}
}
@@ -361,7 +385,9 @@ function parseJsonObject(value: unknown): JsonObject {
try {
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 {
return {}
}
@@ -385,7 +411,11 @@ async function resolveDynamicCloudtentaclesProfile(
quantity: 1,
}
if (!primaryDeliveryItem.cloudSkuId || !primaryDeliveryItem.cloudSkuName || cloudSourceKeys.length === 0) {
if (
!primaryDeliveryItem.cloudSkuId ||
!primaryDeliveryItem.cloudSkuName ||
cloudSourceKeys.length === 0
) {
return null
}
@@ -409,7 +439,10 @@ async function resolveDynamicCloudtentaclesProfile(
cloudSourceKeys,
skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName,
resolvedSourceKey: String(cloudtentacles.resolvedSourceKey || '').trim(),
resolvedSourceKey:
cloudSourceKeys.length === 1
? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim()
: '',
deliveryItems,
vnKey: '1',
autoBuyEnabled: true,
@@ -421,7 +454,8 @@ async function resolveDynamicCloudtentaclesProfile(
shopName: String(snapshot.shopName || '').trim(),
autoConsumeAfterDispatch: false,
},
notes: cloudtentacles.matchMode === 'cloudtentacles_override'
notes:
cloudtentacles.matchMode === 'cloudtentacles_override'
? '91卡券商品名命中 cloudtentacles 覆盖规则'
: '91卡券商品名自动匹配 cloudtentacles 商品',
},
@@ -433,9 +467,7 @@ function normalizeStringArray(value: unknown): string[] {
return []
}
return Array.from(
new Set(value.map((item) => String(item || '').trim()).filter(Boolean)),
)
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
function normalizeCloudDeliveryItems(value: JsonObject): Array<{
@@ -446,7 +478,9 @@ function normalizeCloudDeliveryItems(value: JsonObject): Array<{
const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : []
const items = rawItems
.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) {
return mergeCloudDeliveryItems(items)
@@ -457,17 +491,18 @@ function normalizeCloudDeliveryItems(value: JsonObject): Array<{
return []
}
return [{
return [
{
cloudSkuId,
cloudSkuName: String(value.skuName || '').trim(),
quantity: 1,
}]
},
]
}
function normalizeCloudDeliveryItem(value: unknown) {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const source =
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) {
return null