彻底修复 lewan 自动发货问题-8-8
This commit is contained in:
@@ -20,12 +20,21 @@ const contexts = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listEmptyVirtualNumbers = async () => ({
|
||||||
|
baseUrl: 'https://cloud.example.com',
|
||||||
|
key: '1',
|
||||||
|
itemCount: 0,
|
||||||
|
items: [],
|
||||||
|
rawItems: [],
|
||||||
|
})
|
||||||
|
|
||||||
test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切换', async () => {
|
test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切换', async () => {
|
||||||
const selection = await selectCloudtentaclesSourceForFulfillment(
|
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||||
{
|
{
|
||||||
binding: {
|
binding: {
|
||||||
resolvedSourceKey: 'account-a',
|
resolvedSourceKey: 'account-a',
|
||||||
cloudSourceKeys: ['account-a', 'account-b'],
|
cloudSourceKeys: ['account-a', 'account-b'],
|
||||||
|
vnId: 123,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,6 +46,7 @@ test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切
|
|||||||
{ sourceKey: 'account-a', activeCount: 99, redeemingCount: 99 },
|
{ sourceKey: 'account-a', activeCount: 99, redeemingCount: 99 },
|
||||||
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
|
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
|
||||||
],
|
],
|
||||||
|
listVirtualNumbers: listEmptyVirtualNumbers,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +54,61 @@ test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切
|
|||||||
assert.equal(selection.fixed, true)
|
assert.equal(selection.fixed, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('selectCloudtentaclesSourceForFulfillment 无号码时忽略残留固定账号', async () => {
|
||||||
|
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||||
|
{
|
||||||
|
binding: {
|
||||||
|
resolvedSourceKey: 'account-a',
|
||||||
|
cloudSourceKeys: ['account-a', 'account-b'],
|
||||||
|
vnId: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
|
||||||
|
listSourceLoadStats: async () => [
|
||||||
|
{ sourceKey: 'account-a', activeCount: 20, redeemingCount: 0 },
|
||||||
|
{ sourceKey: 'account-b', activeCount: 1, redeemingCount: 0 },
|
||||||
|
],
|
||||||
|
listVirtualNumbers: listEmptyVirtualNumbers,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(selection.sourceKey, 'account-b')
|
||||||
|
assert.equal(selection.fixed, false)
|
||||||
|
} finally {
|
||||||
|
selection.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账号', async () => {
|
||||||
|
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||||
|
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
|
||||||
|
{
|
||||||
|
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
|
||||||
|
listSourceLoadStats: async () => [],
|
||||||
|
listVirtualNumbers: async (payload) => {
|
||||||
|
const count = payload.sourceKey === 'account-a' ? 20 : 3
|
||||||
|
return {
|
||||||
|
baseUrl: 'https://cloud.example.com',
|
||||||
|
key: '1',
|
||||||
|
itemCount: count,
|
||||||
|
items: Array.from({ length: count }, (_, id) => ({ id: id + 1 })),
|
||||||
|
rawItems: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(selection.sourceKey, 'account-b')
|
||||||
|
assert.equal(selection.occupiedCount, 3)
|
||||||
|
assert.equal(selection.availableCount, 17)
|
||||||
|
} finally {
|
||||||
|
selection.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => {
|
test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => {
|
||||||
const selection = await selectCloudtentaclesSourceForFulfillment(
|
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||||
{
|
{
|
||||||
@@ -60,6 +125,7 @@ test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账
|
|||||||
{ sourceKey: 'account-a', activeCount: 3, redeemingCount: 1 },
|
{ sourceKey: 'account-a', activeCount: 3, redeemingCount: 1 },
|
||||||
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
|
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
|
||||||
],
|
],
|
||||||
|
listVirtualNumbers: listEmptyVirtualNumbers,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,6 +153,7 @@ test('selectCloudtentaclesSourceForFulfillment 会跳过不可用账号', async
|
|||||||
return contexts[key as keyof typeof contexts]
|
return contexts[key as keyof typeof contexts]
|
||||||
},
|
},
|
||||||
listSourceLoadStats: async () => [],
|
listSourceLoadStats: async () => [],
|
||||||
|
listVirtualNumbers: listEmptyVirtualNumbers,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,6 +172,7 @@ test('selectCloudtentaclesSourceForFulfillment 用进程内占位分散并发准
|
|||||||
return contexts[key as keyof typeof contexts]
|
return contexts[key as keyof typeof contexts]
|
||||||
},
|
},
|
||||||
listSourceLoadStats: async () => [],
|
listSourceLoadStats: async () => [],
|
||||||
|
listVirtualNumbers: listEmptyVirtualNumbers,
|
||||||
}
|
}
|
||||||
const flow = {
|
const flow = {
|
||||||
binding: {
|
binding: {
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { listKuaishouCloudSourceLoadStats } from '../../../repositories/kuaishou-cloud-task-state-repo.js'
|
import { listKuaishouCloudSourceLoadStats } from '../../../repositories/kuaishou-cloud-task-state-repo.js'
|
||||||
import { normalizeKuaishouCloudFlow, normalizeStringArray, type JsonObject } from './domain.js'
|
import {
|
||||||
|
KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||||
|
normalizeKuaishouCloudFlow,
|
||||||
|
normalizeStringArray,
|
||||||
|
type JsonObject,
|
||||||
|
} from './domain.js'
|
||||||
import { resolvePersistedCloudtentaclesContextWithFallback } from './cloudtentacles-context.js'
|
import { resolvePersistedCloudtentaclesContextWithFallback } from './cloudtentacles-context.js'
|
||||||
|
import { listCloudtentaclesVirtualNumbers } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||||
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
|
||||||
type CloudtentaclesContext = ReturnType<typeof resolvePersistedCloudtentaclesContextWithFallback>
|
type CloudtentaclesContext = ReturnType<typeof resolvePersistedCloudtentaclesContextWithFallback>
|
||||||
|
|
||||||
type CloudtentaclesSourceSelectionDeps = {
|
type CloudtentaclesSourceSelectionDeps = {
|
||||||
resolveContextBySourceKeys?: typeof resolvePersistedCloudtentaclesContextWithFallback
|
resolveContextBySourceKeys?: typeof resolvePersistedCloudtentaclesContextWithFallback
|
||||||
listSourceLoadStats?: typeof listKuaishouCloudSourceLoadStats
|
listSourceLoadStats?: typeof listKuaishouCloudSourceLoadStats
|
||||||
|
listVirtualNumbers?: typeof listCloudtentaclesVirtualNumbers
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloudtentaclesSourceSelection = {
|
export type CloudtentaclesSourceSelection = {
|
||||||
@@ -17,6 +25,9 @@ export type CloudtentaclesSourceSelection = {
|
|||||||
redeemingCount: number
|
redeemingCount: number
|
||||||
inProcessCount: number
|
inProcessCount: number
|
||||||
score: number
|
score: number
|
||||||
|
occupiedCount: number
|
||||||
|
capacity: number
|
||||||
|
availableCount: number
|
||||||
fixed: boolean
|
fixed: boolean
|
||||||
release: () => void
|
release: () => void
|
||||||
}
|
}
|
||||||
@@ -32,12 +43,15 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
const resolveContext =
|
const resolveContext =
|
||||||
deps.resolveContextBySourceKeys || resolvePersistedCloudtentaclesContextWithFallback
|
deps.resolveContextBySourceKeys || resolvePersistedCloudtentaclesContextWithFallback
|
||||||
const listSourceLoadStats = deps.listSourceLoadStats || listKuaishouCloudSourceLoadStats
|
const listSourceLoadStats = deps.listSourceLoadStats || listKuaishouCloudSourceLoadStats
|
||||||
const fixedSourceKey = String(flow.binding.resolvedSourceKey || '').trim()
|
const listVirtualNumbers = deps.listVirtualNumbers || listCloudtentaclesVirtualNumbers
|
||||||
|
const persistedSourceKey = String(flow.binding.resolvedSourceKey || '').trim()
|
||||||
|
const fixedSourceKey = flow.binding.vnId > 0 ? persistedSourceKey : ''
|
||||||
|
const excludedSourceKeys = new Set(normalizeStringArray((flow as JsonObject).excludedSourceKeys))
|
||||||
const candidates = normalizeCloudtentaclesSourceCandidates(
|
const candidates = normalizeCloudtentaclesSourceCandidates(
|
||||||
fixedSourceKey
|
fixedSourceKey
|
||||||
? [fixedSourceKey, ...flow.binding.cloudSourceKeys]
|
? [fixedSourceKey, ...flow.binding.cloudSourceKeys]
|
||||||
: flow.binding.cloudSourceKeys,
|
: [...flow.binding.cloudSourceKeys, persistedSourceKey],
|
||||||
)
|
).filter((key) => !excludedSourceKeys.has(key))
|
||||||
|
|
||||||
if (fixedSourceKey) {
|
if (fixedSourceKey) {
|
||||||
const context = resolveContext([fixedSourceKey, ...flow.binding.cloudSourceKeys])
|
const context = resolveContext([fixedSourceKey, ...flow.binding.cloudSourceKeys])
|
||||||
@@ -49,6 +63,9 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
redeemingCount: 0,
|
redeemingCount: 0,
|
||||||
inProcessCount: 0,
|
inProcessCount: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
occupiedCount: 0,
|
||||||
|
capacity: 20,
|
||||||
|
availableCount: 20,
|
||||||
fixed: true,
|
fixed: true,
|
||||||
release: () => {},
|
release: () => {},
|
||||||
}
|
}
|
||||||
@@ -76,8 +93,26 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
|
|
||||||
const stats = await listSourceLoadStats(contexts.map((context) => context.resolvedSourceKey))
|
const stats = await listSourceLoadStats(contexts.map((context) => context.resolvedSourceKey))
|
||||||
const statsBySourceKey = new Map(stats.map((stat) => [stat.sourceKey, stat]))
|
const statsBySourceKey = new Map(stats.map((stat) => [stat.sourceKey, stat]))
|
||||||
const ranked = contexts
|
const ranked = []
|
||||||
.map((context, index) => {
|
let lastCapacityError: unknown = null
|
||||||
|
for (const [index, context] of contexts.entries()) {
|
||||||
|
let occupiedCount = 0
|
||||||
|
try {
|
||||||
|
// CT's VN list is the authoritative count; DB counters can lag after crashes/restarts.
|
||||||
|
const listed = await listVirtualNumbers({
|
||||||
|
...context,
|
||||||
|
key: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||||
|
sourceKey: context.resolvedSourceKey,
|
||||||
|
accountLabel: context.accountLabel,
|
||||||
|
})
|
||||||
|
occupiedCount = Array.isArray(listed.items) ? listed.items.length : Number(listed.itemCount || 0) || 0
|
||||||
|
} catch (error) {
|
||||||
|
lastCapacityError = error
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const capacity = 20
|
||||||
|
const inProcessCount = normalizeCount(inProcessSelections.get(context.resolvedSourceKey))
|
||||||
|
if (occupiedCount + inProcessCount >= capacity) continue
|
||||||
const stat = statsBySourceKey.get(context.resolvedSourceKey) || {
|
const stat = statsBySourceKey.get(context.resolvedSourceKey) || {
|
||||||
sourceKey: context.resolvedSourceKey,
|
sourceKey: context.resolvedSourceKey,
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
@@ -85,10 +120,11 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
}
|
}
|
||||||
const activeCount = normalizeCount(stat.activeCount)
|
const activeCount = normalizeCount(stat.activeCount)
|
||||||
const redeemingCount = normalizeCount(stat.redeemingCount)
|
const redeemingCount = normalizeCount(stat.redeemingCount)
|
||||||
const inProcessCount = normalizeCount(inProcessSelections.get(context.resolvedSourceKey))
|
// Real upstream occupancy is the primary balancing signal. DB state is
|
||||||
const score = (activeCount + inProcessCount) * 10 + redeemingCount * 20
|
// retained as a secondary tie-breaker for tasks still being processed.
|
||||||
|
const score = (occupiedCount + inProcessCount) * 100 + activeCount * 10 + redeemingCount * 20
|
||||||
|
|
||||||
return {
|
ranked.push({
|
||||||
context,
|
context,
|
||||||
sourceKey: context.resolvedSourceKey,
|
sourceKey: context.resolvedSourceKey,
|
||||||
index,
|
index,
|
||||||
@@ -96,9 +132,13 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
redeemingCount,
|
redeemingCount,
|
||||||
inProcessCount,
|
inProcessCount,
|
||||||
score,
|
score,
|
||||||
}
|
occupiedCount,
|
||||||
|
capacity,
|
||||||
|
availableCount: Math.max(0, capacity - occupiedCount - inProcessCount),
|
||||||
})
|
})
|
||||||
.sort(
|
}
|
||||||
|
if (ranked.length === 0 && lastCapacityError && contexts.length > 0) throw lastCapacityError
|
||||||
|
ranked.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
left.score - right.score ||
|
left.score - right.score ||
|
||||||
left.activeCount - right.activeCount ||
|
left.activeCount - right.activeCount ||
|
||||||
@@ -109,7 +149,10 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
|
|
||||||
const selected = ranked[0]
|
const selected = ranked[0]
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
throw new Error('所有 cloudtentacles 账号均不可用')
|
throw createHttpError('所有 cloudtentacles 账号虚拟号配额均已满', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'cloudtentacles_all_sources_at_capacity',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reserveCloudtentaclesSource(selected.sourceKey)
|
reserveCloudtentaclesSource(selected.sourceKey)
|
||||||
@@ -122,6 +165,9 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
|||||||
redeemingCount: selected.redeemingCount,
|
redeemingCount: selected.redeemingCount,
|
||||||
inProcessCount: selected.inProcessCount,
|
inProcessCount: selected.inProcessCount,
|
||||||
score: selected.score,
|
score: selected.score,
|
||||||
|
occupiedCount: selected.occupiedCount,
|
||||||
|
capacity: selected.capacity,
|
||||||
|
availableCount: selected.availableCount,
|
||||||
fixed: false,
|
fixed: false,
|
||||||
release: () => releaseCloudtentaclesSource(selected.sourceKey),
|
release: () => releaseCloudtentaclesSource(selected.sourceKey),
|
||||||
}
|
}
|
||||||
@@ -138,6 +184,9 @@ export function buildCloudtentaclesSourceSelectionEventDetail(
|
|||||||
redeemingCount: selection.redeemingCount,
|
redeemingCount: selection.redeemingCount,
|
||||||
inProcessCount: selection.inProcessCount,
|
inProcessCount: selection.inProcessCount,
|
||||||
score: selection.score,
|
score: selection.score,
|
||||||
|
occupiedCount: selection.occupiedCount,
|
||||||
|
capacity: selection.capacity,
|
||||||
|
availableCount: selection.availableCount,
|
||||||
fixed: selection.fixed,
|
fixed: selection.fixed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
isKuaishouCloudTask,
|
isKuaishouCloudTask,
|
||||||
maskPhone,
|
maskPhone,
|
||||||
normalizeKuaishouCloudFlow,
|
normalizeKuaishouCloudFlow,
|
||||||
|
normalizeStringArray,
|
||||||
resolveKuaishouCloudBindUrlExpiresAt,
|
resolveKuaishouCloudBindUrlExpiresAt,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
} from './domain.js'
|
} from './domain.js'
|
||||||
@@ -123,7 +124,16 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow)
|
const excludedSourceKeys = normalizeStringArray(options.excludedSourceKeys)
|
||||||
|
const sourceSelectionAttempts = normalizeStringArray(options.sourceSelectionAttempts)
|
||||||
|
const sourceCandidateCount = new Set(
|
||||||
|
normalizeStringArray([...flow.binding.cloudSourceKeys, flow.binding.resolvedSourceKey]),
|
||||||
|
).size
|
||||||
|
const selectedCloud = await selectCloudtentaclesSourceForFulfillment({
|
||||||
|
...flow,
|
||||||
|
excludedSourceKeys,
|
||||||
|
})
|
||||||
|
let retriedOnAnotherSource = false
|
||||||
try {
|
try {
|
||||||
const cloudContext = selectedCloud.context
|
const cloudContext = selectedCloud.context
|
||||||
const [knapsack, skuList] = await Promise.all([
|
const [knapsack, skuList] = await Promise.all([
|
||||||
@@ -277,7 +287,10 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
|||||||
purchasedCount: 0,
|
purchasedCount: 0,
|
||||||
})),
|
})),
|
||||||
resolvedByName: resolvedBinding.resolvedByName,
|
resolvedByName: resolvedBinding.resolvedByName,
|
||||||
accountSelection: buildCloudtentaclesSourceSelectionEventDetail(selectedCloud),
|
accountSelection: {
|
||||||
|
...buildCloudtentaclesSourceSelectionEventDetail(selectedCloud),
|
||||||
|
attemptedSourceKeys: [...sourceSelectionAttempts, selectedCloud.sourceKey],
|
||||||
|
},
|
||||||
defaultRoleName: defaultRoleSnapshot.defaultName,
|
defaultRoleName: defaultRoleSnapshot.defaultName,
|
||||||
defaultRoleId: defaultRoleSnapshot.defaultRid,
|
defaultRoleId: defaultRoleSnapshot.defaultRid,
|
||||||
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
|
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
|
||||||
@@ -293,9 +306,41 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
|||||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||||
previousVnRelease,
|
previousVnRelease,
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Before a VN exists, account-scoped failures are safe to retry on another
|
||||||
|
// candidate. Once a VN is acquired, switching accounts would orphan it.
|
||||||
|
if (
|
||||||
|
flow.binding.vnId <= 0 &&
|
||||||
|
isRetryableCloudtentaclesSourceError(error) &&
|
||||||
|
excludedSourceKeys.length + 1 < sourceCandidateCount
|
||||||
|
) {
|
||||||
|
retriedOnAnotherSource = true
|
||||||
|
selectedCloud.release()
|
||||||
|
return prepareKuaishouCloudFulfillmentTask(task, {
|
||||||
|
...options,
|
||||||
|
excludedSourceKeys: [...excludedSourceKeys, selectedCloud.sourceKey],
|
||||||
|
sourceSelectionAttempts: [...sourceSelectionAttempts, selectedCloud.sourceKey],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!retriedOnAnotherSource) {
|
||||||
selectedCloud.release()
|
selectedCloud.release()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableCloudtentaclesSourceError(error: unknown) {
|
||||||
|
const current = error && typeof error === 'object' ? error as JsonObject : {}
|
||||||
|
const code = String(current.errorCode || current.code || '').trim()
|
||||||
|
return (
|
||||||
|
code === 'cloudtentacles_vn_quota_cooldown' ||
|
||||||
|
(code === 'cloudtentacles_vn_appoint_failed' && String(current.message || '').trim().includes('最多同时占用')) ||
|
||||||
|
code === 'cloudtentacles_vn_list_failed' ||
|
||||||
|
code === 'cloudtentacles_sku_list_failed' ||
|
||||||
|
code === 'cloudtentacles_knapsack_failed' ||
|
||||||
|
code === 'cloudtentacles_asset_failed'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */
|
/** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */
|
||||||
|
|||||||
@@ -58,6 +58,25 @@ test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sour
|
|||||||
assert.equal(result?.matchMode, 'cloudtentacles_name')
|
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 () => {
|
test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => {
|
||||||
const result = await resolveCloudtentaclesSkuByProductName('套装1', {
|
const result = await resolveCloudtentaclesSkuByProductName('套装1', {
|
||||||
listCloudtentaclesSources: () => ({
|
listCloudtentaclesSources: () => ({
|
||||||
@@ -132,6 +151,7 @@ test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple
|
|||||||
assert.equal(result?.matchMode, 'cloudtentacles_override')
|
assert.equal(result?.matchMode, 'cloudtentacles_override')
|
||||||
assert.equal(result?.cloudSkuId, 28)
|
assert.equal(result?.cloudSkuId, 28)
|
||||||
assert.equal(result?.cloudSkuName, '商品A')
|
assert.equal(result?.cloudSkuName, '商品A')
|
||||||
|
assert.equal(result?.resolvedSourceKey, '')
|
||||||
assert.deepEqual(result?.deliveryItems, [
|
assert.deepEqual(result?.deliveryItems, [
|
||||||
{
|
{
|
||||||
cloudSkuId: 28,
|
cloudSkuId: 28,
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ export async function resolveCloudtentaclesSkuByProductName(
|
|||||||
cloudSkuPrice: Number(matchedSku.price || 0) || 0,
|
cloudSkuPrice: Number(matchedSku.price || 0) || 0,
|
||||||
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
||||||
cloudSourceKeys,
|
cloudSourceKeys,
|
||||||
resolvedSourceKey: context.sourceKey,
|
// Matching only discovers compatible accounts. The fulfillment selector
|
||||||
|
// chooses the least-loaded account immediately before taking a number.
|
||||||
|
resolvedSourceKey: '',
|
||||||
deliveryItems: [
|
deliveryItems: [
|
||||||
{
|
{
|
||||||
cloudSkuId,
|
cloudSkuId,
|
||||||
@@ -168,6 +170,7 @@ function buildCloudtentaclesSourceContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
accountLabel: String(source.label || sourceKey).trim() || sourceKey,
|
||||||
baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(),
|
baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(),
|
||||||
token,
|
token,
|
||||||
deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId),
|
deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId),
|
||||||
@@ -226,7 +229,7 @@ async function resolveOverrideMatch(
|
|||||||
cloudSkuPrice: Number(firstSku.price || 0) || 0,
|
cloudSkuPrice: Number(firstSku.price || 0) || 0,
|
||||||
cloudSkuInventory: Number(firstSku.inventory || 0) || 0,
|
cloudSkuInventory: Number(firstSku.inventory || 0) || 0,
|
||||||
cloudSourceKeys,
|
cloudSourceKeys,
|
||||||
resolvedSourceKey: context.sourceKey,
|
resolvedSourceKey: rule.sourceKey ? context.sourceKey : '',
|
||||||
deliveryItems,
|
deliveryItems,
|
||||||
skuSnapshot: {
|
skuSnapshot: {
|
||||||
overrideRuleId: rule.id,
|
overrideRuleId: rule.id,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
token,
|
token,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'asset_get', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_asset_failed',
|
businessErrorCode: 'cloudtentacles_asset_failed',
|
||||||
})
|
})
|
||||||
@@ -32,6 +35,9 @@ export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
token,
|
token,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'categories_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_categories_failed',
|
businessErrorCode: 'cloudtentacles_categories_failed',
|
||||||
})
|
})
|
||||||
@@ -55,6 +61,9 @@ export async function listCloudtentaclesSku(payload: JsonObject = {}) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
token,
|
token,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'sku_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_sku_list_failed',
|
businessErrorCode: 'cloudtentacles_sku_list_failed',
|
||||||
})
|
})
|
||||||
@@ -85,6 +94,8 @@ export async function buyCloudtentaclesSku(payload: JsonObject = {}) {
|
|||||||
},
|
},
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_sku_buy_failed',
|
businessErrorCode: 'cloudtentacles_sku_buy_failed',
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
context: {
|
context: {
|
||||||
operation: 'sku_buy',
|
operation: 'sku_buy',
|
||||||
skuId,
|
skuId,
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
|||||||
pathname: normalizedPathname,
|
pathname: normalizedPathname,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
attempt: attempt + 1,
|
attempt: attempt + 1,
|
||||||
|
sourceKey: options.sourceKey,
|
||||||
|
accountLabel: options.accountLabel,
|
||||||
|
context: options.context,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export async function getCloudtentaclesKnapsack(payload: JsonObject = {}) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
token,
|
token,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'knapsack_get', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_knapsack_failed',
|
businessErrorCode: 'cloudtentacles_knapsack_failed',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { JsonObject } from '../../../types/json.js'
|
|||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { cloudtentaclesRequest } from './http-client.js'
|
import { cloudtentaclesRequest } from './http-client.js'
|
||||||
import { resolveCloudtentaclesConfig } from './helpers.js'
|
import { resolveCloudtentaclesConfig } from './helpers.js'
|
||||||
|
import { maskPhone } from '../../../utils/masking.js'
|
||||||
|
|
||||||
const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024
|
const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024
|
||||||
const AMS_SIGNATURE_EXPIRED_CODE = '99998'
|
const AMS_SIGNATURE_EXPIRED_CODE = '99998'
|
||||||
@@ -49,6 +50,9 @@ export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {})
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { key },
|
body: { key },
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_list', vnKey: key, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_vn_list_failed',
|
businessErrorCode: 'cloudtentacles_vn_list_failed',
|
||||||
})
|
})
|
||||||
@@ -74,6 +78,9 @@ export async function appointCloudtentaclesVirtualNumber(payload: JsonObject = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { key },
|
body: { key },
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_appoint', vnKey: key, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_vn_appoint_failed',
|
businessErrorCode: 'cloudtentacles_vn_appoint_failed',
|
||||||
})
|
})
|
||||||
@@ -97,6 +104,9 @@ export async function generateCloudtentaclesLoginCode(payload: JsonObject = {})
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { key, id },
|
body: { key, id },
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_generate_code', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_vn_generate_code_failed',
|
businessErrorCode: 'cloudtentacles_vn_generate_code_failed',
|
||||||
})
|
})
|
||||||
@@ -126,6 +136,9 @@ export async function fetchCloudtentaclesVirtualNumberCode(payload: JsonObject =
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { key, phone },
|
body: { key, phone },
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_fetch_code', vnKey: key, phoneMasked: maskPhone(phone), sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_vn_verif_code_failed',
|
businessErrorCode: 'cloudtentacles_vn_verif_code_failed',
|
||||||
})
|
})
|
||||||
@@ -157,6 +170,9 @@ export async function verifyCloudtentaclesLoginCode(payload: JsonObject = {}) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { key, id, code },
|
body: { key, id, code },
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_verify_code', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
businessErrorStatusCode: 401,
|
businessErrorStatusCode: 401,
|
||||||
businessErrorCode: 'cloudtentacles_vn_verify_code_failed',
|
businessErrorCode: 'cloudtentacles_vn_verify_code_failed',
|
||||||
})
|
})
|
||||||
@@ -305,6 +321,9 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) {
|
|||||||
rateLimitRetryDelayMs: BIND_INFO_RATE_LIMIT_RETRY_DELAY_MS,
|
rateLimitRetryDelayMs: BIND_INFO_RATE_LIMIT_RETRY_DELAY_MS,
|
||||||
rateLimitRetries: 2,
|
rateLimitRetries: 2,
|
||||||
rateLimitIsolatePath: true,
|
rateLimitIsolatePath: true,
|
||||||
|
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||||
|
accountLabel: payload.accountLabel,
|
||||||
|
context: { operation: 'vn_bind_info', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = Array.isArray(result.payload?.data) ? result.payload.data : []
|
const items = Array.isArray(result.payload?.data) ? result.payload.data : []
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise<RecycleNumber
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
WHERE ft.executor_key = 'kuaishou_ct_assisted'
|
WHERE ft.executor_key = 'kuaishou_ct_assisted'
|
||||||
|
AND ft.task_status NOT IN ('redeeming', 'dispatched_pending_return', 'completed', 'redeemed', 'expired', 'closed')
|
||||||
|
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,dispatch,status}', '') <> 'success'
|
||||||
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,prepareStatus}', '') = 'ready'
|
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,prepareStatus}', '') = 'ready'
|
||||||
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}', '') <> ''
|
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}', '') <> ''
|
||||||
AND (ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}')::timestamptz < $1
|
AND (ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}')::timestamptz < $1
|
||||||
|
|||||||
Reference in New Issue
Block a user