彻底修复 lewan 自动发货问题-8-8

This commit is contained in:
yml2213
2026-08-08 23:08:05 +08:00
parent 7196752d44
commit 6e41d7e0db
10 changed files with 260 additions and 37 deletions
@@ -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,10 +306,42 @@ 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 释放:已被回收/权限不足时不算失败 */
async function tryReleasePreviousVirtualNumber({ async function tryReleasePreviousVirtualNumber({
@@ -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