import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { buildClaimUrl } from '../../claim/claim-service.js' import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js' import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' import { backCloudtentaclesVirtualNumber, getCloudtentaclesBindUrl, } from '../../platforms/cloudtentacles/virtual-number-service.js' import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' import { KUAISHOU_CLOUD_FIXED_VN_KEY, isKuaishouCloudBindUrlFresh, isKuaishouCloudBindingMutationFrozen, isKuaishouCloudTask, maskPhone, normalizeKuaishouCloudFlow, normalizeStringArray, resolveKuaishouCloudBindUrlExpiresAt, type JsonObject, } from './domain.js' import { prepareKuaishouCloudBindResourceWithFallback, resolveKuaishouCloudBindingResources, resolveKuaishouCloudVnKeyCandidates, } from './binding-resources.js' import { buildCloudtentaclesSourceSelectionEventDetail, selectCloudtentaclesSourceForFulfillment, } from './account-selector.js' import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js' import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js' import { resolveKuaishouCloudDeliveryPlan } from './delivery-plan.js' import { ensureTaskClaimLink } from './ensure-claim-link.js' import { buildPendingRoleWithDefaultSnapshot, captureKuaishouCloudDefaultRoleSnapshot, } from './role-state.js' import type { TaskRow } from '../../../types/repository/rows.js' /** * 准备 / 重建绑定资源(不退款、不重建订单/claim)。 * - 默认:已 ready 且绑链未过期则直接复用 * - force=true:旧号 best-effort 退还(已被系统回收则忽略),再取新号+新绑链 */ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) { if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { statusCode: 409, errorCode: 'kuaishou_cloud_task_invalid', }) } const now = nowIso() const actor = normalizeActor(options.actor) const force = options.force === true const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const claimLinkState = await ensureTaskClaimLink(task) const source = String(options.source || 'system').trim() || 'system' // 已发货:禁止 prepare / 过期换号把状态打回 waiting_binding if (isKuaishouCloudBindingMutationFrozen(task, flow)) { return { task, claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), token: String(task.primary_claim_token || task.claim_token || ''), flow, } } if ( !force && flow.binding.prepareStatus === 'ready' && flow.binding.vnId > 0 && flow.binding.vnPhone && flow.binding.bindUrl ) { if (!isKuaishouCloudBindUrlFresh(flow)) { return refreshKuaishouCloudTaskBindUrl(task, { source: options.source || 'system_refresh_expired_bind_url', actor, claimLinkState, }) } let readyTask = task if (normalizeTaskStatus(task.task_status) !== TASK_STATUS.WAITING_BINDING) { readyTask = (await updateTask(task.id, { task_status: TASK_STATUS.WAITING_BINDING, claim_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), updated_at: now, })) || task } return { task: readyTask, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow, } } // force 重建:旧号可能已被 CT 回收,退号失败直接忽略,下面重新取号 let previousVnRelease: 'none' | 'returned' | 'already_reclaimed' | 'skipped' = 'none' if (force && flow.binding.vnId > 0 && flow.binding.vnKey) { previousVnRelease = await tryReleasePreviousVirtualNumber({ task, flow, actor, source, now, }) } const excludedSourceKeys = normalizeStringArray(options.excludedSourceKeys) const sourceSelectionAttempts = normalizeStringArray(options.sourceSelectionAttempts) const sourceCandidateCount = new Set( normalizeStringArray([...flow.binding.cloudSourceKeys, flow.binding.resolvedSourceKey]), ).size const selectedCloud = await selectCloudtentaclesSourceForFulfillment({ ...flow, excludedSourceKeys, }) let retriedOnAnotherSource = false try { const cloudContext = selectedCloud.context const [knapsack, skuList] = await Promise.all([ getCloudtentaclesKnapsack(cloudContext), listCloudtentaclesSku(cloudContext), ]) const resolvedBinding = resolveKuaishouCloudBindingResources(flow, { skuItems: Array.isArray(skuList.items) ? skuList.items : [], knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], }) const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({ flow, binding: resolvedBinding, }) if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) { throw createHttpError('当前任务缺少可用 cloud 资源,无法自动准备绑定', { statusCode: 409, errorCode: 'kuaishou_cloud_missing_binding_config', }) } const flowWithResolvedBinding = { ...flow, binding: { ...flow.binding, skuId: resolvedBinding.skuId, skuName: resolvedBinding.skuName, vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, }, } const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, { skuItems: Array.isArray(skuList.items) ? skuList.items : [], knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], }) if (deliveryPlan.items.length === 0) { throw createHttpError('当前任务缺少 cloud 发货物品配置', { statusCode: 409, errorCode: 'kuaishou_cloud_missing_delivery_items', }) } const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0) if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) { throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { statusCode: 409, errorCode: 'kuaishou_cloud_auto_buy_disabled', }) } const missingSku = deliveryPlan.items.find((item) => item.missingCount > 0 && !item.skuItem) if (missingSku) { throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { statusCode: 404, errorCode: 'kuaishou_cloud_sku_not_found', }) } const purchaseTriggered = false const assetBefore = 0 const assetAfter = 0 const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ cloudContext, vnKeyCandidates, purpose: 'new_fulfillment', }) const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ cloudContext, preparedBinding, now, }) const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flowWithResolvedBinding, binding: { ...flowWithResolvedBinding.binding, resolvedSourceKey: cloudContext.resolvedSourceKey, vnKey: preparedBinding.vnKey, prepareStatus: 'ready', vnId: preparedBinding.vnId, vnPhone: preparedBinding.vnPhone, bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', roleName: '', roleId: '', }, role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot), purchase: { ...flowWithResolvedBinding.purchase, usedKnapsack, purchaseTriggered, assetBefore, assetAfter, items: deliveryPlan.items.map((item) => ({ cloudSkuId: item.cloudSkuId, cloudSkuName: item.cloudSkuName, requiredCount: item.quantity, knapsackCount: item.knapsackCount, purchasedCount: 0, })), purchaseAt: flowWithResolvedBinding.purchase.purchaseAt, }, }, } const lastErrorNote = previousVnRelease === 'already_reclaimed' || previousVnRelease === 'skipped' ? '已重新取号并生成绑定链接(旧号可能已被 CT 回收,已忽略退号失败)' : force ? '已重新取号并生成绑定链接' : '' const updatedTask = await updateTask(task.id, { task_status: TASK_STATUS.WAITING_BINDING, user_action_status: 'pending_claim', claim_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), role_id: '', role_name: '', last_error: lastErrorNote, context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent( task.id, 'kuaishou_cloud_binding_prepared', { source, force, previousVnRelease, skuId: flowWithResolvedBinding.binding.skuId, skuName: flowWithResolvedBinding.binding.skuName, vnKey: preparedBinding.vnKey, vnId: preparedBinding.vnId, vnPhoneMasked: maskPhone(preparedBinding.vnPhone), purchaseTriggered, usedKnapsack, deliveryItems: deliveryPlan.items.map((item) => ({ cloudSkuId: item.cloudSkuId, cloudSkuName: item.cloudSkuName, quantity: item.quantity, knapsackCount: item.knapsackCount, purchasedCount: 0, })), resolvedByName: resolvedBinding.resolvedByName, accountSelection: { ...buildCloudtentaclesSourceSelectionEventDetail(selectedCloud), attemptedSourceKeys: [...sourceSelectionAttempts, selectedCloud.sourceKey], }, defaultRoleName: defaultRoleSnapshot.defaultName, defaultRoleId: defaultRoleSnapshot.defaultRid, defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, actor, }, now, ) return { task: updatedTask, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), previousVnRelease, } } catch (error) { // Before a VN exists, account-scoped failures are safe to retry on another // candidate. Once a VN is acquired, switching accounts would orphan it. if ( flow.binding.vnId <= 0 && isRetryableCloudtentaclesSourceError(error) && excludedSourceKeys.length + 1 < sourceCandidateCount ) { retriedOnAnotherSource = true selectedCloud.release() return prepareKuaishouCloudFulfillmentTask(task, { ...options, excludedSourceKeys: [...excludedSourceKeys, selectedCloud.sourceKey], sourceSelectionAttempts: [...sourceSelectionAttempts, selectedCloud.sourceKey], }) } throw error } finally { if (!retriedOnAnotherSource) { selectedCloud.release() } } } function isRetryableCloudtentaclesSourceError(error: unknown) { const current = error && typeof error === 'object' ? (error as JsonObject) : {} const code = String(current.errorCode || current.code || '').trim() return ( code === 'cloudtentacles_vn_quota_cooldown' || (code === 'cloudtentacles_vn_appoint_failed' && String(current.message || '') .trim() .includes('最多同时占用')) || code === 'cloudtentacles_vn_list_failed' || code === 'cloudtentacles_sku_list_failed' || code === 'cloudtentacles_knapsack_failed' || code === 'cloudtentacles_asset_failed' ) } /** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */ async function tryReleasePreviousVirtualNumber({ task, flow, actor, source, now, }: { task: TaskRow flow: ReturnType actor: unknown source: string now: string }): Promise<'returned' | 'already_reclaimed' | 'skipped'> { try { const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ flow.binding.resolvedSourceKey, ...flow.binding.cloudSourceKeys, ]) await backCloudtentaclesVirtualNumber({ ...cloudContext, key: flow.binding.vnKey, id: flow.binding.vnId, }) await createTaskEvent( task.id, 'kuaishou_cloud_previous_number_returned_before_reprepare', { source, vnId: flow.binding.vnId, vnKey: flow.binding.vnKey, vnPhoneMasked: maskPhone(flow.binding.vnPhone), cloudSourceKey: cloudContext.resolvedSourceKey, actor, }, now, ) return 'returned' } catch (error) { const message = error instanceof Error ? error.message : String(error || '') const reclaimed = message.includes('权限不足') || message.includes('不存在') || message.includes('已释放') || message.includes('已回收') await createTaskEvent( task.id, reclaimed ? 'kuaishou_cloud_previous_number_already_reclaimed' : 'kuaishou_cloud_previous_number_release_skipped', { source, vnId: flow.binding.vnId, vnKey: flow.binding.vnKey, vnPhoneMasked: maskPhone(flow.binding.vnPhone), errorMessage: message, actor, }, now, ) return reclaimed ? 'already_reclaimed' : 'skipped' } } /** * 刷新绑定链接(不退订单/不重建 claim): * 1. 优先同 vn 调 bind_url 重取链接(不退号) * 2. 失败再退号 + 取新号 * 3. 退号失败可软跳过继续取新号(allowSkipBack,默认 true) */ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) { if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { statusCode: 409, errorCode: 'kuaishou_cloud_task_invalid', }) } const now = nowIso() const actor = normalizeActor(options.actor) const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const source = String(options.source || 'system').trim() || 'system' // 默认退号失败即中止:避免旧号没退掉又占新号,导致账号号码配额被净泄漏 const allowSkipBack = options.allowSkipBack === true const preferReuseVn = options.preferReuseVn !== false if (isKuaishouCloudBindingMutationFrozen(task, flow)) { throw createHttpError('当前任务已经发货,不能刷新或更换绑定链接', { statusCode: 409, errorCode: 'kuaishou_cloud_bind_url_refresh_after_dispatch_forbidden', }) } if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { statusCode: 409, errorCode: 'kuaishou_cloud_missing_bind_url_context', }) } let cloudContext: JsonObject try { cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ flow.binding.resolvedSourceKey, ...flow.binding.cloudSourceKeys, ]) } catch (error) { throw wrapCloudtentaclesOperationError(error, { action: 'resolve_source', actionLabel: '解析取号账号', sourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0], vnKey: flow.binding.vnKey, vnId: flow.binding.vnId, vnPhone: flow.binding.vnPhone, }) } const oldVnKey = flow.binding.vnKey const oldVnId = flow.binding.vnId const oldVnPhone = flow.binding.vnPhone const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task)) // 路径 1:复用现有虚拟号,只重取 bindUrl(不退号、不占新号) if (preferReuseVn) { try { const bindUrlResult = await getCloudtentaclesBindUrl({ ...cloudContext, key: oldVnKey, id: oldVnId, }) const bindUrl = String(bindUrlResult.bindUrl || '').trim() if (bindUrl) { const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flow, binding: { ...flow.binding, prepareStatus: 'ready', resolvedSourceKey: String( cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '', ).trim(), bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', }, }, } const updatedTask = await updateTask(task.id, { task_status: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.task_status : TASK_STATUS.WAITING_BINDING, claim_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), last_error: '', context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent( task.id, 'kuaishou_cloud_bind_url_refreshed', { source, mode: 'reuse_existing_vn', vnKey: oldVnKey, vnId: oldVnId, vnPhoneMasked: maskPhone(oldVnPhone), cloudSourceKey: cloudContext.resolvedSourceKey, actor, }, now, ) return { task: updatedTask, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), mode: 'reuse_existing_vn', } } } catch (error) { // 号码已退/已回收/无权限:直接清空绑定,不再走退号换号(避免反复打上游) if (isNumberAlreadyReleasedError(error)) { return clearKuaishouCloudStaleBinding(task, { taskContext, flow, now, actor, source, error, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token || task.claim_token || '', }) } // 其他错误走退号换号 } } // 路径 2:退旧号 + 取新号 let oldNumberBackStatus: 'success' | 'skipped' = 'success' let oldNumberBackError = '' try { await backCloudtentaclesVirtualNumber({ ...cloudContext, key: oldVnKey, id: oldVnId, }) await createTaskEvent( task.id, 'kuaishou_cloud_expired_bind_number_returned', { source, vnKey: oldVnKey, vnId: oldVnId, vnPhoneMasked: maskPhone(oldVnPhone), cloudSourceKey: cloudContext.resolvedSourceKey, actor, }, 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', actionLabel: '退还过期虚拟号', sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, vnKey: oldVnKey, vnId: oldVnId, vnPhone: oldVnPhone, }) await createTaskEvent( task.id, 'kuaishou_cloud_expired_bind_number_back_failed', { source, vnKey: oldVnKey, vnId: oldVnId, errorMessage: wrapped.message, cloudSourceKey: cloudContext.resolvedSourceKey, actor, }, now, ) if (!allowSkipBack) { throw wrapped } oldNumberBackStatus = 'skipped' } let preparedBinding try { preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ cloudContext, purpose: 'expired_binding_replacement', vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ flow, binding: flow.binding, }), }) if (!String(preparedBinding.bindUrl || '').trim()) { throw createHttpError('cloudtentacles 未返回新的绑定链接', { statusCode: 502, errorCode: 'kuaishou_cloud_empty_bind_url', }) } } catch (error) { const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, { taskContext, flow, now, actor, error, oldNumberBackStatus, oldNumberBackError, }) return { task: failedTask, claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), token: String(task.primary_claim_token || task.claim_token || ''), flow: normalizeKuaishouCloudFlow(parseTaskContext(failedTask).kuaishouCloudFulfillment), } } const defaultRoleSnapshot = normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? null : await captureKuaishouCloudDefaultRoleSnapshot({ cloudContext, preparedBinding, now, }) const skipNote = oldNumberBackStatus === 'skipped' ? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链` : '' const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flow, binding: { ...flow.binding, prepareStatus: 'ready', resolvedSourceKey: String( cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '', ).trim(), vnKey: preparedBinding.vnKey, vnId: preparedBinding.vnId, vnPhone: preparedBinding.vnPhone, bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', roleName: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? flow.binding.roleName : '', roleId: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? flow.binding.roleId : '', }, role: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? flow.role : buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot!), }, } const updatedTask = await updateTask(task.id, { task_status: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.task_status : TASK_STATUS.WAITING_BINDING, claim_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), role_id: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '', role_name: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '', last_error: skipNote, context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent( task.id, 'kuaishou_cloud_bind_url_refreshed', { source, mode: 'replace_vn', oldVnId, oldVnPhoneMasked: maskPhone(oldVnPhone), vnKey: preparedBinding.vnKey, vnId: preparedBinding.vnId, vnPhoneMasked: maskPhone(preparedBinding.vnPhone), oldNumberBackStatus, oldNumberBackError, cloudSourceKey: cloudContext.resolvedSourceKey, actor, }, now, ) return { task: updatedTask, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), mode: 'replace_vn', oldNumberBackStatus, } } async function markKuaishouCloudBindUrlRefreshFailed( task: TaskRow, { taskContext, flow, now, actor, error, oldNumberBackStatus = 'unknown', oldNumberBackError = '', }: JsonObject = {}, ) { const errorMessage = error instanceof Error ? error.message : String(error || '新绑定链接准备失败') const backNote = oldNumberBackStatus === 'skipped' ? `旧号退还失败(${oldNumberBackError || '未知'})且` : oldNumberBackStatus === 'success' ? '旧号码已退还,' : '' const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flow, binding: { ...flow.binding, prepareStatus: 'pending', vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0, vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '', bindUrl: '', bindPreparedAt: null, bindExpiresAt: null, bindProbeAt: now, bindProbeStatus: 'refresh_failed', bindProbeMessage: errorMessage, roleName: '', roleId: '', }, role: { status: 'pending', name: '', rid: '', refreshedAt: now, errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`, rawInfo: null, }, }, } const updatedTask = await updateTask(task.id, { task_status: TASK_STATUS.PENDING_BINDING_PREPARE, role_id: '', role_name: '', last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`, context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent( task.id, 'kuaishou_cloud_bind_url_refresh_failed', { source: 'system_refresh_expired_bind_url', errorMessage, actor, }, now, ) await notifyKuaishouCloudBindUrlRefreshFailed({ task: updatedTask, errorMessage, }) 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', } }