diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index feaea394..bb1cec14 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -24,6 +24,12 @@ const claimWriteRateLimit = createRateLimitMiddleware({ max: 30, key: getParamRateLimitKey('token'), }) +const claimRebindRateLimit = createRateLimitMiddleware({ + scope: 'claim:rebind', + windowMs: 60_000, + max: 3, + key: getParamRateLimitKey('token'), +}) router.get( '/:token', @@ -66,7 +72,7 @@ router.post( router.post( '/:token/kuaishou-cloud/rebind-role', - claimWriteRateLimit, + claimRebindRateLimit, createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), { successMessage: '角色换绑资源已准备完成', errorMessage: '换绑角色失败', diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts index d403644c..ba62f268 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts @@ -24,6 +24,7 @@ const listEmptyVirtualNumbers = async () => ({ baseUrl: 'https://cloud.example.com', key: '1', itemCount: 0, + occupiedItemCount: 0, items: [], rawItems: [], }) @@ -93,7 +94,8 @@ test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账 baseUrl: 'https://cloud.example.com', key: '1', itemCount: count, - items: Array.from({ length: count }, (_, id) => ({ id: id + 1 })), + occupiedItemCount: count, + items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })), rawItems: [], } }, @@ -109,6 +111,67 @@ test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账 } }) +test('selectCloudtentaclesSourceForFulfillment 不把 status=0 的空闲号码计入占用', 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 items = payload.sourceKey === 'account-a' + ? Array.from({ length: 20 }, (_, id) => ({ id: id + 1, status: 0 })) + : Array.from({ length: 3 }, (_, id) => ({ id: id + 1, status: 1 })) + return { + baseUrl: 'https://cloud.example.com', + key: '1', + itemCount: items.length, + occupiedItemCount: items.filter((item) => item.status !== 0).length, + items, + rawItems: [], + } + }, + }, + ) + + try { + assert.equal(selection.sourceKey, 'account-a') + assert.equal(selection.occupiedCount, 0) + } finally { + selection.release() + } +}) + +test('selectCloudtentaclesSourceForFulfillment 真实占用优先于数据库历史压力', async () => { + const selection = await selectCloudtentaclesSourceForFulfillment( + { binding: { cloudSourceKeys: ['account-a', 'account-b'] } }, + { + resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts], + listSourceLoadStats: async () => [ + { sourceKey: 'account-a', activeCount: 99, redeemingCount: 10 }, + { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, + ], + listVirtualNumbers: async (payload) => { + const count = payload.sourceKey === 'account-a' ? 5 : 6 + return { + baseUrl: 'https://cloud.example.com', + key: '1', + itemCount: count, + occupiedItemCount: count, + items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })), + rawItems: [], + } + }, + }, + ) + + try { + assert.equal(selection.sourceKey, 'account-a') + assert.equal(selection.occupiedCount, 5) + } finally { + selection.release() + } +}) + test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => { const selection = await selectCloudtentaclesSourceForFulfillment( { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts index d80ea6a2..b945085e 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts @@ -105,7 +105,7 @@ export async function selectCloudtentaclesSourceForFulfillment( sourceKey: context.resolvedSourceKey, accountLabel: context.accountLabel, }) - occupiedCount = Array.isArray(listed.items) ? listed.items.length : Number(listed.itemCount || 0) || 0 + occupiedCount = Number(listed.occupiedItemCount || 0) || 0 } catch (error) { lastCapacityError = error continue @@ -120,9 +120,7 @@ export async function selectCloudtentaclesSourceForFulfillment( } const activeCount = normalizeCount(stat.activeCount) const redeemingCount = normalizeCount(stat.redeemingCount) - // Real upstream occupancy is the primary balancing signal. DB state is - // retained as a secondary tie-breaker for tasks still being processed. - const score = (occupiedCount + inProcessCount) * 100 + activeCount * 10 + redeemingCount * 20 + const score = occupiedCount + inProcessCount ranked.push({ context, @@ -141,8 +139,8 @@ export async function selectCloudtentaclesSourceForFulfillment( ranked.sort( (left, right) => left.score - right.score || - left.activeCount - right.activeCount || left.redeemingCount - right.redeemingCount || + left.activeCount - right.activeCount || left.index - right.index || left.sourceKey.localeCompare(right.sourceKey), ) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts index 5370f30f..d8fd8634 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -9,6 +9,7 @@ import { verifyCloudtentaclesLoginCode, } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js"; +import { logInfo } from "../../../utils/logger.js"; /** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */ const APPOINT_QUOTA_COOLDOWN_MS = 60_000; @@ -108,6 +109,14 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb vnId = Number(appointed.item?.id || 0); vnPhone = String(appointed.item?.phone || "").trim(); + logInfo('[kuaishou-cloud/binding]', '虚拟号申请成功', { + sourceKey, + accountLabel: cloudContext.accountLabel, + purpose: String(input.purpose || 'prepare_binding'), + vnKey, + vnId, + }); + if (!vnId || !vnPhone) { throw createHttpError("申请虚拟号成功但返回数据不完整", { statusCode: 502, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts index e54ca576..66dfb016 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -200,6 +200,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ cloudContext, vnKeyCandidates, + purpose: 'new_fulfillment', }) const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ cloudContext, @@ -615,6 +616,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js try { preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ cloudContext, + purpose: 'expired_binding_replacement', vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ flow, binding: flow.binding, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts index f620896c..6067ab8f 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts @@ -231,6 +231,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb try { preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ cloudContext, + purpose: 'claim_rebind', vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ flow, binding: flow.binding, diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index bd816cf5..179aad09 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -58,11 +58,13 @@ export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {}) }) const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapVirtualNumberItem) : [] + const occupiedItemCount = items.filter((item: ReturnType) => item.status !== 0).length return { baseUrl: config.baseUrl, key, itemCount: items.length, + occupiedItemCount, items, rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [], } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 460ab280..fd7e4a28 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -14,7 +14,6 @@ import { notifyCloudtentaclesAuthExpired, } from '../notification/domain-notifications.js' import { - recycleCloudtentaclesStaleNumbers, retryCloudtentaclesPendingReturns, } from './cloudtentacles-number-recycle-service.js' @@ -35,11 +34,10 @@ type CloudtentaclesHealthAccountResult = { export async function runCloudtentaclesHealthJob(job: JsonObject) { const accounts = resolveCloudtentaclesHealthAccounts(job) const cooldownSeconds = Number(job.cooldownSeconds || 1800) - const [results, recycleResult, returnRetryResult] = await Promise.all([ + const [results, returnRetryResult] = await Promise.all([ Promise.all( accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), ), - recycleCloudtentaclesStaleNumbers(), retryCloudtentaclesPendingReturns(), ]) const checkedResults = results.filter((item) => !item.skipped) @@ -51,12 +49,11 @@ export async function runCloudtentaclesHealthJob(job: JsonObject) { .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) const summary = buildCloudtentaclesHealthSummary(results) const status = resolveCloudtentaclesHealthStatus(results) - const orphanTaskCount = - Number(recycleResult?.orphanCount || 0) + Number(returnRetryResult?.orphanCount || 0) + const orphanTaskCount = Number(returnRetryResult?.orphanCount || 0) const recycleNote = orphanTaskCount > 0 ? `,另有 ${orphanTaskCount} 个孤儿号码待人工/上游清理` - : recycleResult?.refreshed || returnRetryResult?.returned - ? ',过期号码回收正常' + : returnRetryResult?.returned + ? ',发货后退号重试正常' : '' const finalMessage = `${summary}${recycleNote}` @@ -76,7 +73,6 @@ export async function runCloudtentaclesHealthJob(job: JsonObject) { ? Math.max(...checkedResults.map((item) => Number(item.threshold || 0))) : normalizeNonNegativeInteger(job.config?.assetThreshold, 500), accounts: results, - numberRecycle: recycleResult, pendingReturnRetry: returnRetryResult, } } diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts index f4752132..125e60b4 100644 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -117,6 +117,7 @@ export interface AdminCloudtentaclesSkuItem { export interface AdminCloudtentaclesSkuListResult { baseUrl: string itemCount: number + occupiedItemCount: number items: AdminCloudtentaclesSkuItem[] rawItems: Record[] }