diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts index c0dafc61..8982bb82 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts @@ -60,11 +60,31 @@ export async function returnKuaishouCloudFulfillmentTask( return { task, flow } } - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }) + try { + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: flow.binding.vnKey, + id: flow.binding.vnId, + }) + } catch (error) { + // 号码已被上游回收/手动退回/账号换 token 无权限:视为已释放,继续完成核销收尾 + if (isNumberAlreadyReleasedError(error)) { + await createTaskEvent( + task.id, + 'kuaishou_cloud_number_already_released', + { + source: String(options.source || 'system').trim() || 'system', + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + errorMessage: error instanceof Error ? error.message : String(error || ''), + actor, + }, + now + ) + } else { + throw error + } + } const ticketCode = String(flow.ticket.code || '').trim() let consumeStatus = 'pending' @@ -236,8 +256,7 @@ export async function returnKuaishouCloudFulfillmentTask( } } -function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { - const voucher = isPlainObject(value.kuaishouIndustryVoucher) +function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { const voucher = isPlainObject(value.kuaishouIndustryVoucher) ? value.kuaishouIndustryVoucher : {} const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim() @@ -249,3 +268,16 @@ function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { function isPlainObject(value: unknown): value is JsonObject { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } + +/** 号码已被上游回收/手动退回/账号换 token 后无操作权限:退号视为已释放 */ +function isNumberAlreadyReleasedError(error: unknown) { + const message = error instanceof Error ? error.message : String(error || '') + return ( + message.includes('权限不足') || + message.includes('没有权限') || + message.includes('不存在') || + message.includes('已释放') || + message.includes('已回收') || + message.includes('已退还') + ) +} 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 151cd292..2f9d9237 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts @@ -1,11 +1,15 @@ +import { createTaskEvent } from '../../repositories/task-event-repo.js' +import { updateTask } from '../../repositories/task-repo.js' 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 { nowIso } from '../../utils/time.js' import type { TaskRow } from '../../types/repository/rows.js' import { isKuaishouCloudBindingMutationFrozen, isKuaishouCloudTask, + maskPhone, normalizeKuaishouCloudFlow, } from '../fulfillment/kuaishou-cloud/domain.js' import { refreshKuaishouCloudTaskBindUrl } from '../fulfillment/kuaishou-cloud/prepare-fulfillment.js' @@ -15,12 +19,6 @@ 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 @@ -28,47 +26,81 @@ type RecycleNumberResult = { skippedMissingContext: number refreshed: number failed: number - /** 上游判定无权操作(账号重新登录换 token 后的孤儿号码),已停止重试的任务数 */ + /** 上游判定无权操作(号码已被手动退回/回收/账号换 token 后的孤儿号码),任务绑定已清空 */ orphanCount: number orphanTaskIds: number[] } -/** 上游"权限不足/没有权限"类错误:号码归属当前账号会话,重试无法恢复 */ +/** 上游"权限不足/没有权限/号码不存在"类错误:号码已不属于当前会话,重试无法恢复,应清空任务绑定 */ function isOrphanNumberError(error: unknown) { const message = error instanceof Error ? error.message : String(error || '') - return message.includes('权限不足') || message.includes('没有权限') + return ( + message.includes('权限不足') || + message.includes('没有权限') || + message.includes('不存在') || + message.includes('已释放') || + message.includes('已回收') + ) } -function getRecycleFailCount(taskId: number) { - const record = recycleFailRecords.get(taskId) - if (!record) { - return 0 +/** 清空任务侧已失效的旧绑定(号码已退/已回收),任务下次领取时重新取号 */ +async function clearStaleKuaishouCloudBinding(task: TaskRow, errorMessage: string) { + const now = nowIso() + const taskContext = parseJsonObject(task.context_json) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const oldVnId = flow.binding.vnId + const oldVnPhone = flow.binding.vnPhone + + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'pending', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: now, + bindProbeStatus: 'stale_cleared', + bindProbeMessage: errorMessage, + roleName: '', + roleId: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: now, + errorMessage: '旧绑定号码已失效,已清空绑定,将重新取号', + rawInfo: null, + }, + }, } - 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 -} + await updateTask(task.id, { + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, + role_id: '', + role_name: '', + last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`, + context_json: JSON.stringify(nextContext), + updated_at: now, + }) -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) + await createTaskEvent( + task.id, + 'kuaishou_cloud_stale_binding_cleared', + { + source: 'scheduler_stale_number_recycle', + oldVnId, + oldVnPhoneMasked: maskPhone(oldVnPhone), + errorMessage, + actor: { source: 'system' }, + }, + now + ) } export async function recycleCloudtentaclesStaleNumbers(): Promise { @@ -101,13 +133,6 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise