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 4d46a43b..5370f30f 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -10,6 +10,10 @@ import { } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js"; +/** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */ +const APPOINT_QUOTA_COOLDOWN_MS = 60_000; +const appointQuotaCooldowns = new Map(); + export function resolveKuaishouCloudBindingResources( flow: JsonObject, { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {} @@ -77,11 +81,24 @@ export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) { export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) { const { cloudContext = {}, vnKeyCandidates = [] } = input; const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : []; + const sourceKey = String(cloudContext.resolvedSourceKey || "").trim(); let lastError = null; for (const vnKey of candidates) { let vnId = 0; let vnPhone = ""; + const cooldownKey = `${sourceKey}|${vnKey}`; + + const cooldownUntil = appointQuotaCooldowns.get(cooldownKey) || 0; + if (cooldownUntil > Date.now()) { + throw createHttpError( + "账号虚拟号配额已满,请先退回已占用号码或稍后重试", + { + statusCode: 409, + errorCode: "cloudtentacles_vn_quota_cooldown", + } + ); + } try { const appointed = await appointCloudtentaclesVirtualNumber({ @@ -132,6 +149,10 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb } catch (error) { lastError = error; + if (isAppointQuotaExhaustedError(error)) { + appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS); + } + if (vnId > 0) { try { await backCloudtentaclesVirtualNumber({ @@ -229,6 +250,15 @@ function isCloudSkuLikeItem(item: unknown): item is JsonObject { return Number(current.id || 0) > 0; } +function isAppointQuotaExhaustedError(error: unknown) { + const current = error && typeof error === "object" ? error as JsonObject : {}; + return ( + String(current.errorCode || current.code || "").trim() === + "cloudtentacles_vn_appoint_failed" && + String(current.message || "").trim().includes("最多同时占用") + ); +} + function isRecoverableKuaishouCloudVnKeyError(error: unknown) { const current = error && typeof error === "object" ? error as JsonObject : {}; const errorCode = String(current.errorCode || current.code || "").trim(); 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 318c7340..b87779aa 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -381,7 +381,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const source = String(options.source || 'system').trim() || 'system' - const allowSkipBack = options.allowSkipBack !== false + // 默认退号失败即中止:避免旧号没退掉又占新号,导致账号号码配额被净泄漏 + const allowSkipBack = options.allowSkipBack === true const preferReuseVn = options.preferReuseVn !== false if (isKuaishouCloudBindingMutationFrozen(task, flow)) { diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 3f6efbe8..0548a9af 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -13,6 +13,10 @@ import { notifyCloudtentaclesAssetLow, notifyCloudtentaclesAuthExpired, } from '../notification/domain-notifications.js' +import { + recycleCloudtentaclesStaleNumbers, + retryCloudtentaclesPendingReturns, +} from './cloudtentacles-number-recycle-service.js' type CloudtentaclesHealthAccountResult = { sourceKey: string @@ -31,9 +35,13 @@ type CloudtentaclesHealthAccountResult = { export async function runCloudtentaclesHealthJob(job: JsonObject) { const accounts = resolveCloudtentaclesHealthAccounts(job) const cooldownSeconds = Number(job.cooldownSeconds || 1800) - const results = await Promise.all( - accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), - ) + const [results, recycleResult, returnRetryResult] = await Promise.all([ + Promise.all( + accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), + ), + recycleCloudtentaclesStaleNumbers(), + retryCloudtentaclesPendingReturns(), + ]) const checkedResults = results.filter((item) => !item.skipped) const failedResults = checkedResults.filter((item) => item.ok === false) const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') @@ -60,6 +68,8 @@ 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/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts new file mode 100644 index 00000000..17407898 --- /dev/null +++ b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts @@ -0,0 +1,155 @@ +import { query as dbQuery } from '../../db/client.js' +import { TASK_STATUS } from '../../domain/task-status.js' +import { logInfo, logWarn } from '../../utils/logger.js' +import { parseJsonObject } from '../../utils/task-json.js' +import type { TaskRow } from '../../types/repository/rows.js' +import { + isKuaishouCloudBindingMutationFrozen, + isKuaishouCloudTask, + normalizeKuaishouCloudFlow, +} from '../fulfillment/kuaishou-cloud/domain.js' +import { refreshKuaishouCloudTaskBindUrl } from '../fulfillment/kuaishou-cloud/prepare-fulfillment.js' +import { returnKuaishouCloudFulfillmentTask } from '../fulfillment/kuaishou-cloud/return-fulfillment.js' + +/** bindUrl 过期超过该时长才回收(避免刚过期就触发上游操作) */ +const STALE_BIND_URL_GRACE_MS = 15 * 60 * 1000 +/** 单轮最多处理的任务数(health 每 2.5 分钟跑一次,防止一次扫太多) */ +const RECYCLE_SCAN_LIMIT = 50 + +type RecycleNumberResult = { + scanned: number + skippedFrozen: number + skippedMissingContext: number + refreshed: number + failed: number +} + +export async function recycleCloudtentaclesStaleNumbers(): Promise { + const staleBeforeIso = new Date(Date.now() - STALE_BIND_URL_GRACE_MS).toISOString() + const result: RecycleNumberResult = { + scanned: 0, + skippedFrozen: 0, + skippedMissingContext: 0, + refreshed: 0, + failed: 0, + } + + const rows = await dbQuery( + ` + SELECT * + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'kuaishou_ct_assisted' + AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,prepareStatus}', '') = 'ready' + AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}', '') <> '' + AND (ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}')::timestamptz < $1 + ORDER BY ft.id DESC + LIMIT $2 + `, + [staleBeforeIso, RECYCLE_SCAN_LIMIT] + ) + + for (const task of rows.rows) { + result.scanned += 1 + try { + if (!isKuaishouCloudTask(task)) { + result.skippedMissingContext += 1 + continue + } + + const flow = normalizeKuaishouCloudFlow( + parseJsonObject(task.context_json).kuaishouCloudFulfillment + ) + + // 已发货/兑换中:禁止换号覆盖,跳过 + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + result.skippedFrozen += 1 + continue + } + + if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.bindUrl) { + result.skippedMissingContext += 1 + continue + } + + await refreshKuaishouCloudTaskBindUrl(task, { + source: 'scheduler_stale_number_recycle', + actor: { source: 'system' }, + }) + result.refreshed += 1 + } catch (error) { + result.failed += 1 + logWarn('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收失败', { + taskId: Number(task.id || 0), + error: error instanceof Error ? error.message : String(error || ''), + }) + } + } + + if (result.scanned > 0) { + logInfo('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收完成', { + ...result, + taskIds: rows.rows.map((row) => Number(row.id || 0)), + }) + } + + return result +} + +/** 重试发货后未完成的退号(DISPATCHED_PENDING_RETURN 且未退号成功) */ +export async function retryCloudtentaclesPendingReturns(): Promise<{ + scanned: number + returned: number + failed: number +}> { + const result = { scanned: 0, returned: 0, failed: 0 } + const rows = await dbQuery( + ` + SELECT * + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'kuaishou_ct_assisted' + AND ft.task_status = $1 + AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,returnNumber,status}', '') <> 'success' + ORDER BY ft.id DESC + LIMIT $2 + `, + [TASK_STATUS.DISPATCHED_PENDING_RETURN, RECYCLE_SCAN_LIMIT] + ) + + for (const task of rows.rows) { + result.scanned += 1 + try { + if (!isKuaishouCloudTask(task)) { + result.failed += 1 + continue + } + const flow = normalizeKuaishouCloudFlow( + parseJsonObject(task.context_json).kuaishouCloudFulfillment + ) + if (!flow.binding.vnId || !flow.binding.vnKey) { + result.failed += 1 + continue + } + + await returnKuaishouCloudFulfillmentTask(task, { + source: 'scheduler_retry_return_number', + actor: { source: 'system' }, + }) + result.returned += 1 + } catch (error) { + result.failed += 1 + logWarn('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试失败', { + taskId: Number(task.id || 0), + error: error instanceof Error ? error.message : String(error || ''), + }) + } + } + + if (result.scanned > 0) { + logInfo('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试完成', { + ...result, + taskIds: rows.rows.map((row) => Number(row.id || 0)), + }) + } + + return result +}