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 b87779aa..2d0f173c 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -484,8 +484,21 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js mode: 'reuse_existing_vn', } } - } catch { - // 同号取链失败则走退号换号 + } catch (error) { + // 号码已退/已回收/无权限:直接清空绑定,不再走退号换号(避免反复打上游) + if (isNumberAlreadyReleasedError(error)) { + return clearKuaishouCloudStaleBinding(task, { + taskContext, + flow, + now, + actor, + source, + error, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token || task.claim_token || '', + }) + } + // 其他错误走退号换号 } } @@ -512,6 +525,19 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js now, ) } catch (error) { + // 号码已退/已回收/无权限:直接清空绑定(不再尝试取新号),避免反复打上游 + if (isNumberAlreadyReleasedError(error)) { + return clearKuaishouCloudStaleBinding(task, { + taskContext, + flow, + now, + actor, + source, + error, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token || task.claim_token || '', + }) + } oldNumberBackError = error instanceof Error ? error.message : String(error || '退号失败') const wrapped = wrapCloudtentaclesOperationError(error, { action: 'vn_back', @@ -743,3 +769,110 @@ async function markKuaishouCloudBindUrlRefreshFailed( return updatedTask } + +/** 号码已被上游回收/手动退回/账号换 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('已退还') + ) +} + +/** + * 清空任务侧已失效的旧绑定(号码已退/已回收/无权限),任务下次领取时自动重新取号。 + * 所有调用路径(claim 轮询 probe、open-91 交付、回收任务、后台刷新)统一收敛, + * 避免对失效号码反复打上游。 + */ +async function clearKuaishouCloudStaleBinding( + task: TaskRow, + { + taskContext, + flow, + now, + actor, + source, + error, + claimUrl, + token, + }: { + taskContext: JsonObject + flow: ReturnType + now: string + actor: unknown + source: string + error: unknown + claimUrl: string + token: string + } +) { + const errorMessage = error instanceof Error ? error.message : String(error || '绑定已失效') + 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, + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? task.task_status + : TASK_STATUS.PENDING_BINDING_PREPARE, + role_id: '', + role_name: '', + last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`, + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_stale_binding_cleared', + { + source, + oldVnId, + oldVnPhoneMasked: maskPhone(oldVnPhone), + cloudSourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0] || '', + errorMessage, + actor, + }, + now + ) + + return { + task: updatedTask || task, + claimUrl, + token, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + mode: 'stale_cleared', + } +}