diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 0548a9af..460ab280 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -51,11 +51,19 @@ 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 recycleNote = orphanTaskCount > 0 + ? `,另有 ${orphanTaskCount} 个孤儿号码待人工/上游清理` + : recycleResult?.refreshed || returnRetryResult?.returned + ? ',过期号码回收正常' + : '' + const finalMessage = `${summary}${recycleNote}` return { ok: failedResults.length === 0, status, - message: summary, + message: finalMessage, accountCount: results.length, checkedCount: checkedResults.length, okCount: okResults.length, diff --git a/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts index 17407898..151cd292 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts @@ -15,6 +15,12 @@ import { returnKuaishouCloudFulfillmentTask } from '../fulfillment/kuaishou-clou const STALE_BIND_URL_GRACE_MS = 15 * 60 * 1000 /** 单轮最多处理的任务数(health 每 2.5 分钟跑一次,防止一次扫太多) */ const RECYCLE_SCAN_LIMIT = 50 +/** "权限不足"类孤儿号码最多重试次数:上游判定号码不属于当前会话时重试无意义,3 次后停止打上游 */ +const ORPHAN_MAX_RETRIES = 3 +/** 孤儿计数超过该时长后自动重置,允许上游/账号恢复后重新尝试 */ +const ORPHAN_RESET_AFTER_MS = 60 * 60 * 1000 +/** 连续失败记录:taskId -> { count, lastAt } */ +const recycleFailRecords = new Map() type RecycleNumberResult = { scanned: number @@ -22,6 +28,47 @@ type RecycleNumberResult = { skippedMissingContext: number refreshed: number failed: number + /** 上游判定无权操作(账号重新登录换 token 后的孤儿号码),已停止重试的任务数 */ + orphanCount: number + orphanTaskIds: number[] +} + +/** 上游"权限不足/没有权限"类错误:号码归属当前账号会话,重试无法恢复 */ +function isOrphanNumberError(error: unknown) { + const message = error instanceof Error ? error.message : String(error || '') + return message.includes('权限不足') || message.includes('没有权限') +} + +function getRecycleFailCount(taskId: number) { + const record = recycleFailRecords.get(taskId) + if (!record) { + return 0 + } + if (Date.now() - record.lastAt > ORPHAN_RESET_AFTER_MS) { + recycleFailRecords.delete(taskId) + return 0 + } + return record.count +} + +function isRecycleOrphanQuotaReached(taskId: number) { + return getRecycleFailCount(taskId) >= ORPHAN_MAX_RETRIES +} + +function recordRecycleFailure(taskId: number, isOrphan: boolean) { + if (isOrphan) { + const record = recycleFailRecords.get(taskId) + recycleFailRecords.set(taskId, { + count: (record?.count || 0) + 1, + lastAt: Date.now(), + }) + } else { + recycleFailRecords.delete(taskId) + } +} + +function recordRecycleSuccess(taskId: number) { + recycleFailRecords.delete(taskId) } export async function recycleCloudtentaclesStaleNumbers(): Promise { @@ -32,6 +79,8 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise( @@ -50,6 +99,15 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise { - const result = { scanned: 0, returned: 0, failed: 0 } + const result = { + scanned: 0, + returned: 0, + failed: 0, + orphanCount: 0, + orphanTaskIds: [] as number[], + } const rows = await dbQuery( ` SELECT * @@ -117,6 +199,14 @@ export async function retryCloudtentaclesPendingReturns(): Promise<{ for (const task of rows.rows) { result.scanned += 1 + const taskId = Number(task.id || 0) + + if (isRecycleOrphanQuotaReached(taskId)) { + result.orphanCount += 1 + result.orphanTaskIds.push(taskId) + continue + } + try { if (!isKuaishouCloudTask(task)) { result.failed += 1 @@ -135,10 +225,26 @@ export async function retryCloudtentaclesPendingReturns(): Promise<{ actor: { source: 'system' }, }) result.returned += 1 + recordRecycleSuccess(taskId) } catch (error) { + const isOrphan = isOrphanNumberError(error) + recordRecycleFailure(taskId, isOrphan) + if (isOrphan && isRecycleOrphanQuotaReached(taskId)) { + result.orphanCount += 1 + result.orphanTaskIds.push(taskId) + logWarn( + '[scheduler/cloudtentacles-number-recycle]', + '发货后退号的孤儿号码达到重试上限,已停止自动重试(需人工或上游处理)', + { + taskId, + error: error instanceof Error ? error.message : String(error || ''), + } + ) + continue + } result.failed += 1 logWarn('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试失败', { - taskId: Number(task.id || 0), + taskId, error: error instanceof Error ? error.message : String(error || ''), }) }