From b0c77e5842f85a32d2a44131b35de9958946a9a4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 11 Jul 2026 12:13:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20lewan=20=E6=8D=A2=E7=BB=91?= =?UTF-8?q?=EF=BC=9A=E6=97=A7=E5=8F=B7=E8=A2=AB=20CT=20=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E6=96=B0=E5=8F=96=E5=8F=B7=E5=87=BA?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退号失败(含权限不足)不再卡住换绑,默认跳过并重新取号生成绑链; CT 错误返回补充账号/vn 诊断信息,避免误判为后台角色权限问题。 --- apps/backend/src/routes/admin/tasks.ts | 3 +- .../admin/write/kuaishou-cloud-actions.ts | 1 + .../executors/kuaishou-cloud-executor.ts | 7 +- .../kuaishou-cloud/cloudtentacles-context.ts | 3 + .../cloudtentacles-errors.test.ts | 41 +++ .../kuaishou-cloud/cloudtentacles-errors.ts | 117 +++++++ .../kuaishou-cloud/prepare-fulfillment.ts | 306 ++++++++++++++++-- .../fulfillment/kuaishou-cloud/rebind-role.ts | 306 ++++++++++++++++-- .../cloudtentacles/virtual-number-service.ts | 21 +- .../src/pages/admin/AdminTaskDetailPage.tsx | 6 +- 10 files changed, 750 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 044a4e0c..d1ca8a4d 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -92,7 +92,8 @@ router.post( createJsonHandler( (req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null), { - successMessage: '角色换绑资源已准备完成', + // 旧号已回收时会自动跳过退号并新取号出链 + successMessage: '换绑完成:已生成新的绑定链接(原领取链接可继续使用)', errorMessage: '换绑角色失败', scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]', audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data), diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 12775d88..2fc01ec4 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -64,6 +64,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( const result = await prepareFulfillmentBinding(task, { source: 'admin_task_prepare', actor: buildAdminActionActor(session), + force: true, }) if (!result?.task) { throw createHttpError('当前任务不支持准备绑定资源', { diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts index 9260f921..2bdede41 100644 --- a/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts @@ -94,16 +94,21 @@ async function prepareBinding( task: TaskRow, options: FulfillmentActionOptions = {}, ): Promise { + // 后台/显式 force:旧号可被 CT 回收,忽略退号失败并取新号+新绑链 + const force = + options.force === true || + String(options.source || '').includes('admin_') const result = await prepareKuaishouCloudFulfillmentTask(task, { source: options.source || 'executor_prepare_binding', actor: options.actor, - force: options.force === true, + force, }) return { task: result.task || task, claimUrl: result.claimUrl, token: result.token, flow: result.flow, + previousVnRelease: (result as { previousVnRelease?: string }).previousVnRelease, } } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts index 50d0e000..a803dd43 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts @@ -63,7 +63,10 @@ export function resolvePersistedCloudtentaclesContextBySourceKeys( deviceType: normalizeCloudtentaclesDeviceType( session.deviceType ?? source.deviceType ), + // 透传给 http-client / 错误包装,便于定位跨账号问题 + sourceKey, resolvedSourceKey: sourceKey, + accountLabel: String(source.label || sourceKey).trim() || sourceKey, }; } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts new file mode 100644 index 00000000..e3273894 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + isCloudtentaclesPermissionLikeError, + wrapCloudtentaclesOperationError, +} from './cloudtentacles-errors.js' + +test('wrapCloudtentaclesOperationError 把权限不足包装成可定位文案', () => { + const upstream = Object.assign(new Error('权限不足'), { + statusCode: 401, + errorCode: 'cloudtentacles_vn_back_failed', + context: { + upstreamCode: -1, + upstreamMessage: '权限不足', + upstreamPayload: { code: -1, message: '权限不足' }, + }, + }) + + const wrapped = wrapCloudtentaclesOperationError(upstream, { + action: 'vn_back', + actionLabel: '退还旧虚拟号', + sourceKey: 'account_day', + vnKey: '1', + vnId: 12345, + vnPhone: '13800000000', + }) as Error & { statusCode?: number; errorCode?: string } + + assert.equal(wrapped.statusCode, 409) + assert.match(wrapped.message, /cloudtentacles 退还旧虚拟号失败/) + assert.match(wrapped.message, /上游:权限不足/) + assert.match(wrapped.message, /账号=account_day/) + assert.match(wrapped.message, /vnId=12345/) + assert.match(wrapped.message, /平台配置/) + assert.equal(isCloudtentaclesPermissionLikeError(upstream), true) +}) + +test('isCloudtentaclesPermissionLikeError 识别权限类文案', () => { + assert.equal(isCloudtentaclesPermissionLikeError(new Error('权限不足')), true) + assert.equal(isCloudtentaclesPermissionLikeError(new Error('余额不足')), false) +}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts new file mode 100644 index 00000000..3d64ecd9 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts @@ -0,0 +1,117 @@ +import { createHttpError } from '../../../utils/http.js' +import type { JsonObject } from './domain.js' + +/** + * 将 cloudtentacles 上游错误包装成可运维定位的文案。 + * 避免只回「权限不足」让人误判成后台角色权限。 + */ +export function wrapCloudtentaclesOperationError( + error: unknown, + operation: { + action: string + actionLabel: string + sourceKey?: unknown + vnKey?: unknown + vnId?: unknown + vnPhone?: unknown + }, +) { + const upstreamMessage = resolveErrorMessage(error) + const upstreamCode = + resolveErrorField(error, 'errorCode') || + resolveUpstreamCode(error) || + 'cloudtentacles_operation_failed' + const sourceKey = String(operation.sourceKey || '').trim() || 'unknown' + const vnKey = String(operation.vnKey || '').trim() + const vnId = Number(operation.vnId || 0) || 0 + const vnPhone = String(operation.vnPhone || '').trim() + + const parts = [ + `cloudtentacles ${operation.actionLabel}失败`, + upstreamMessage ? `上游:${upstreamMessage}` : '', + `账号=${sourceKey}`, + vnId > 0 ? `vnId=${vnId}` : '', + vnKey ? `vnKey=${vnKey}` : '', + vnPhone ? `phone=${vnPhone}` : '', + '请到「平台配置-cloudtentacles」校验该账号登录态;若账号与取号时不一致会导致退号/取链权限不足', + ].filter(Boolean) + + const statusCode = resolveStatusCode(error) + // 业务失败不要用 401,避免前端当成后台未登录/无权限 + const normalizedStatus = + statusCode === 401 || statusCode === 403 ? 409 : statusCode >= 400 ? statusCode : 409 + + return createHttpError(parts.join(';'), { + statusCode: normalizedStatus, + errorCode: String(upstreamCode || `cloudtentacles_${operation.action}_failed`).trim(), + cause: error instanceof Error ? error : undefined, + context: { + cloudtentacles: { + action: operation.action, + actionLabel: operation.actionLabel, + sourceKey, + vnKey, + vnId, + vnPhone, + upstreamMessage, + upstreamCode: resolveUpstreamCode(error), + upstreamPayload: resolveUpstreamPayload(error), + }, + }, + }) +} + +export function isCloudtentaclesPermissionLikeError(error: unknown): boolean { + const message = resolveErrorMessage(error) + const code = resolveUpstreamCode(error) + return ( + message.includes('权限不足') || + message.includes('没有权限') || + message.includes('无权限') || + String(code) === '-1' + ) +} + +function resolveErrorMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message.trim() + } + return String(error || '未知错误').trim() +} + +function resolveErrorField(error: unknown, field: string) { + if (!error || typeof error !== 'object') { + return '' + } + return String((error as JsonObject)[field] || '').trim() +} + +function resolveStatusCode(error: unknown) { + if (!error || typeof error !== 'object') { + return 409 + } + const statusCode = Number((error as JsonObject).statusCode || 0) + return Number.isFinite(statusCode) && statusCode > 0 ? statusCode : 409 +} + +function resolveUpstreamCode(error: unknown) { + if (!error || typeof error !== 'object') { + return '' + } + const context = (error as { context?: unknown }).context + if (!context || typeof context !== 'object') { + return '' + } + return String((context as JsonObject).upstreamCode ?? '').trim() +} + +function resolveUpstreamPayload(error: unknown) { + if (!error || typeof error !== 'object') { + return null + } + const context = (error as { context?: unknown }).context + if (!context || typeof context !== 'object') { + return null + } + return (context as JsonObject).upstreamPayload ?? null +} 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 849fbe88..aa2218ff 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -3,7 +3,10 @@ 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 } from '../../platforms/cloudtentacles/virtual-number-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' @@ -27,6 +30,7 @@ import { selectCloudtentaclesSourceForFulfillment, } from './account-selector.js' import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' +import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js' import { getTaskClaimExpiresAt, normalizeActor, @@ -40,6 +44,11 @@ import { } 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 履约任务', { @@ -54,6 +63,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const claimLinkState = await ensureTaskClaimLink(task) + const source = String(options.source || 'system').trim() || 'system' if ( !force && @@ -90,6 +100,18 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options } } + // 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 selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow) try { const cloudContext = selectedCloud.context @@ -203,6 +225,13 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options }, } + 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', @@ -210,7 +239,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), role_id: '', role_name: '', - last_error: '', + last_error: lastErrorNote, context_json: JSON.stringify(nextContext), updated_at: now, }) @@ -219,7 +248,9 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options task.id, 'kuaishou_cloud_binding_prepared', { - source: String(options.source || 'system').trim() || 'system', + source, + force, + previousVnRelease, skuId: flowWithResolvedBinding.binding.skuId, skuName: flowWithResolvedBinding.binding.skuName, vnKey: preparedBinding.vnKey, @@ -249,12 +280,83 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + previousVnRelease, } } finally { selectedCloud.release() } } +/** 旧虚拟号 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 履约任务', { @@ -267,6 +369,9 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js 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 !== false + const preferReuseVn = options.preferReuseVn !== false if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { @@ -275,32 +380,146 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js }) } - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) + 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)) - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: oldVnKey, - id: oldVnId, - }) + // 路径 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_expired_bind_number_returned', - { - source: String(options.source || 'system').trim() || 'system', + 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 { + // 同号取链失败则走退号换号 + } + } + + // 路径 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) { + 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, - vnPhoneMasked: maskPhone(oldVnPhone), - actor, - }, - now, - ) + 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 { @@ -325,6 +544,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js now, actor, error, + oldNumberBackStatus, + oldNumberBackError, }) return { @@ -343,6 +564,10 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js preparedBinding, now, }) + const skipNote = + oldNumberBackStatus === 'skipped' + ? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链` + : '' const nextContext = { ...taskContext, kuaishouCloudFulfillment: { @@ -350,6 +575,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js binding: { ...flow.binding, prepareStatus: 'ready', + resolvedSourceKey: + String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(), vnKey: preparedBinding.vnKey, vnId: preparedBinding.vnId, vnPhone: preparedBinding.vnPhone, @@ -375,7 +602,6 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js }, } - const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task)) const updatedTask = await updateTask(task.id, { task_status: normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED @@ -387,7 +613,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js 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: '', + last_error: skipNote, context_json: JSON.stringify(nextContext), updated_at: now, }) @@ -396,12 +622,16 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js task.id, 'kuaishou_cloud_bind_url_refreshed', { - source: String(options.source || 'system').trim() || 'system', + 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, @@ -412,16 +642,32 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + mode: 'replace_vn', + oldNumberBackStatus, } } async function markKuaishouCloudBindUrlRefreshFailed( task: TaskRow, - { taskContext, flow, now, actor, error }: JsonObject = {}, + { + 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: { @@ -429,8 +675,8 @@ async function markKuaishouCloudBindUrlRefreshFailed( binding: { ...flow.binding, prepareStatus: 'pending', - vnId: 0, - vnPhone: '', + vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0, + vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '', bindUrl: '', bindPreparedAt: null, bindExpiresAt: null, @@ -445,7 +691,7 @@ async function markKuaishouCloudBindUrlRefreshFailed( name: '', rid: '', refreshedAt: now, - errorMessage: '绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理', + errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`, rawInfo: null, }, }, @@ -455,7 +701,7 @@ async function markKuaishouCloudBindUrlRefreshFailed( task_status: TASK_STATUS.PENDING_BINDING_PREPARE, role_id: '', role_name: '', - last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`, + last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`, context_json: JSON.stringify(nextContext), updated_at: now, }) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts index 618d382c..f620896c 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts @@ -1,7 +1,10 @@ import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { buildClaimUrl } from '../../claim/claim-service.js' -import { backCloudtentaclesVirtualNumber } from '../../platforms/cloudtentacles/virtual-number-service.js' +import { + backCloudtentaclesVirtualNumber, + getCloudtentaclesBindUrl, +} from '../../platforms/cloudtentacles/virtual-number-service.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' @@ -17,6 +20,10 @@ import { resolveKuaishouCloudVnKeyCandidates, } from './binding-resources.js' import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' +import { + isCloudtentaclesPermissionLikeError, + wrapCloudtentaclesOperationError, +} from './cloudtentacles-errors.js' import { getTaskClaimExpiresAt, normalizeActor, @@ -29,6 +36,12 @@ import { } from './role-state.js' import type { TaskRow } from '../../../types/repository/rows.js' +/** + * 换绑角色:尽量退旧号后取新号+新绑链。 + * - 不涉及订单退款;claimUrl 复用 + * - 旧号退还失败时默认软跳过继续取新号(避免卡死),并在事件/文案中标明需人工清理旧号 + * - 也可先尝试同 vn 重取 bindUrl(reuseExistingVn) + */ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) { if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { @@ -74,10 +87,25 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb } const source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role' - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + // 优先 resolvedSourceKey,避免静默落到别的账号导致「权限不足」 + const preferredSourceKeys = [ flow.binding.resolvedSourceKey, ...flow.binding.cloudSourceKeys, - ]) + ].filter(Boolean) + let cloudContext: JsonObject + try { + cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys(preferredSourceKeys) + } 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 oldBinding = { vnKey: flow.binding.vnKey, vnId: flow.binding.vnId, @@ -87,6 +115,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb bindExpiresAt: flow.binding.bindExpiresAt, roleName: flow.binding.roleName || flow.role.name || task.role_name || '', roleId: flow.binding.roleId || flow.role.rid || task.role_id || '', + resolvedSourceKey: flow.binding.resolvedSourceKey || String(cloudContext.resolvedSourceKey || ''), } const previousRebind: JsonObject = flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {} @@ -98,6 +127,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb requestedAt: now, requestedBy: actor, oldBinding, + cloudSourceKey: String(cloudContext.resolvedSourceKey || ''), } await createTaskEvent( @@ -110,30 +140,92 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), oldRoleName: oldBinding.roleName, oldRoleId: oldBinding.roleId, + cloudSourceKey: baseHistoryItem.cloudSourceKey, actor, }, now, ) - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: oldBinding.vnKey, - id: oldBinding.vnId, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_old_number_returned', - { - source, + // 路径 A:仅重取同 vn 绑链(不退号、不换号)——适合链接过期但角色还要重绑同一会话时 + if (options.reuseExistingVn === true) { + return rebindByRefreshingExistingVnBindUrl({ + task, + taskContext, + flow, + cloudContext, + oldBinding, + previousRebind, + history, + baseHistoryItem, attempt, + actor, + source, + now, + }) + } + + // 标准换绑:先退旧号;旧号已被 CT 回收(权限不足等)→ 自动跳过并新取号出链 + // 唯一入口给客服用,不另开按钮 + const allowSkipBack = options.allowSkipBack !== false + let oldNumberBackStatus: 'success' | 'skipped' | 'failed' = 'success' + let oldNumberBackError = '' + + try { + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: oldBinding.vnKey, + id: oldBinding.vnId, + }) + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_old_number_returned', + { + source, + attempt, + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhoneMasked: maskPhone(oldBinding.vnPhone), + cloudSourceKey: baseHistoryItem.cloudSourceKey, + actor, + }, + now, + ) + } catch (error) { + oldNumberBackStatus = 'failed' + oldNumberBackError = error instanceof Error ? error.message : String(error || '退号失败') + const wrapped = wrapCloudtentaclesOperationError(error, { + action: 'vn_back', + actionLabel: '退还旧虚拟号', + sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, vnKey: oldBinding.vnKey, vnId: oldBinding.vnId, - vnPhoneMasked: maskPhone(oldBinding.vnPhone), - actor, - }, - now, - ) + vnPhone: oldBinding.vnPhone, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_old_number_back_failed', + { + source, + attempt, + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhoneMasked: maskPhone(oldBinding.vnPhone), + cloudSourceKey: baseHistoryItem.cloudSourceKey, + errorMessage: wrapped.message, + permissionLike: isCloudtentaclesPermissionLikeError(error), + actor, + }, + now, + ) + + // 默认继续新取号(旧号被系统回收时最常见);仅显式 allowSkipBack=false 才硬失败 + if (!allowSkipBack) { + throw wrapped + } + + oldNumberBackStatus = 'skipped' + } let preparedBinding try { @@ -153,8 +245,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb binding: { ...flow.binding, prepareStatus: 'pending', - vnId: 0, - vnPhone: '', + vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding.vnId, + vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding.vnPhone, bindUrl: '', bindPreparedAt: null, bindExpiresAt: null, @@ -169,7 +261,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb name: '', rid: '', refreshedAt: now, - errorMessage: `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + errorMessage: + oldNumberBackStatus === 'success' + ? `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}` + : `旧号退还未成功且新号准备失败:${errorMessage}`, rawInfo: null, }, rebind: { @@ -180,6 +275,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb { ...baseHistoryItem, status: 'failed', + oldNumberBackStatus, + oldNumberBackError, errorMessage, }, ], @@ -191,7 +288,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb role_id: '', role_name: '', role_confirmed_at: null, - last_error: `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + last_error: + oldNumberBackStatus === 'success' + ? `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}` + : `换绑失败:旧号退还失败(${oldNumberBackError}),且新号准备失败:${errorMessage}`, context_json: JSON.stringify(failedContext), updated_at: now, }) @@ -203,6 +303,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb source, attempt, errorMessage, + oldNumberBackStatus, + oldNumberBackError, actor, }, now, @@ -225,6 +327,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb preparedBinding, now, }) + const skipNote = + oldNumberBackStatus === 'skipped' + ? `(旧号 vnId=${oldBinding.vnId} 退还失败已跳过,账号=${baseHistoryItem.cloudSourceKey},请稍后在 CT 人工清理)` + : '' const nextContext = { ...taskContext, kuaishouCloudFulfillment: { @@ -232,6 +338,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb binding: { ...flow.binding, prepareStatus: 'ready', + resolvedSourceKey: + String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(), vnKey: preparedBinding.vnKey, vnId: preparedBinding.vnId, vnPhone: preparedBinding.vnPhone, @@ -261,6 +369,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb bindExpiresAt: nextBindExpiresAt, }, status: 'success', + oldNumberBackStatus, + oldNumberBackError, errorMessage: '', }, ], @@ -276,7 +386,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb role_id: '', role_name: '', role_confirmed_at: null, - last_error: '', + last_error: skipNote, context_json: JSON.stringify(nextContext), updated_at: now, }) @@ -293,6 +403,9 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb vnId: preparedBinding.vnId, vnPhoneMasked: maskPhone(preparedBinding.vnPhone), bindUrl: preparedBinding.bindUrl, + oldNumberBackStatus, + oldNumberBackError, + cloudSourceKey: baseHistoryItem.cloudSourceKey, defaultRoleName: defaultRoleSnapshot.defaultName, defaultRoleId: defaultRoleSnapshot.defaultRid, defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, @@ -306,6 +419,151 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + oldNumberBackStatus, } } +async function rebindByRefreshingExistingVnBindUrl(input: { + task: TaskRow + taskContext: JsonObject + flow: ReturnType + cloudContext: JsonObject + oldBinding: JsonObject + previousRebind: JsonObject + history: unknown[] + baseHistoryItem: JsonObject + attempt: number + actor: unknown + source: string + now: string +}) { + const { + task, + taskContext, + flow, + cloudContext, + oldBinding, + previousRebind, + history, + baseHistoryItem, + attempt, + actor, + source, + now, + } = input + + let bindUrl = '' + try { + const bindUrlResult = await getCloudtentaclesBindUrl({ + ...cloudContext, + key: oldBinding.vnKey, + id: oldBinding.vnId, + }) + bindUrl = String(bindUrlResult.bindUrl || '').trim() + if (!bindUrl) { + throw createHttpError('cloudtentacles 未返回绑定链接', { + statusCode: 502, + errorCode: 'kuaishou_cloud_empty_bind_url', + }) + } + } catch (error) { + throw wrapCloudtentaclesOperationError(error, { + action: 'vn_bind_url', + actionLabel: '复用原虚拟号重取绑定链接', + sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhone: oldBinding.vnPhone, + }) + } + + const claimLinkState = await ensureTaskClaimLink(task) + const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now) + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'ready', + bindUrl, + bindPreparedAt: now, + bindExpiresAt: nextBindExpiresAt, + bindProbeAt: null, + bindProbeStatus: 'pending', + bindProbeMessage: '', + // 清空角色,要求用户用正确 UID 重新绑定 + roleName: '', + roleId: '', + }, + role: { + ...flow.role, + status: 'pending', + name: '', + rid: '', + refreshedAt: now, + errorMessage: '', + rawInfo: null, + }, + rebind: { + ...previousRebind, + currentAttempt: attempt, + history: [ + ...history, + { + ...baseHistoryItem, + status: 'success', + mode: 'reuse_existing_vn', + newBinding: { + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhone: oldBinding.vnPhone, + bindUrl, + bindPreparedAt: now, + bindExpiresAt: nextBindExpiresAt, + }, + errorMessage: '', + }, + ], + }, + }, + } + + 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: '', + role_confirmed_at: null, + last_error: '已复用原虚拟号刷新绑定链接,请用户使用正确角色重新扫码绑定', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_prepared', + { + source, + attempt, + mode: 'reuse_existing_vn', + vnId: oldBinding.vnId, + vnPhoneMasked: maskPhone(oldBinding.vnPhone), + bindUrl, + cloudSourceKey: baseHistoryItem.cloudSourceKey, + actor, + }, + now, + ) + + return { + task: updatedTask, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + oldNumberBackStatus: 'skipped' as const, + mode: 'reuse_existing_vn', + } +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index 97072c84..63503fcb 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -181,8 +181,16 @@ export async function getCloudtentaclesBindUrl(payload: JsonObject = {}) { method: 'POST', token, body: { id, key }, - businessErrorStatusCode: 401, + businessErrorStatusCode: 409, businessErrorCode: 'cloudtentacles_vn_bind_url_failed', + sourceKey: payload.sourceKey || payload.resolvedSourceKey, + accountLabel: payload.accountLabel, + context: { + operation: 'vn_bind_url', + vnKey: key, + vnId: id, + sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', + }, }) return { @@ -340,8 +348,17 @@ export async function backCloudtentaclesVirtualNumber(payload: JsonObject = {}) method: 'POST', token, body: { key, id }, - businessErrorStatusCode: 401, + // 409:业务失败,避免前端/客服误判为后台登录或角色权限问题 + businessErrorStatusCode: 409, businessErrorCode: 'cloudtentacles_vn_back_failed', + sourceKey: payload.sourceKey || payload.resolvedSourceKey, + accountLabel: payload.accountLabel, + context: { + operation: 'vn_back', + vnKey: key, + vnId: id, + sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', + }, }) return { diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 0a5f8306..fc799d78 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -282,8 +282,8 @@ export default function AdminTaskDetailPage() { runTaskAction( 'rebind-role', () => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId), - '角色换绑资源已准备完成', - `确认为任务 ${resolvedDetail.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`, + '换绑完成:已生成新的绑定链接', + `确认为任务 ${resolvedDetail.task.taskNo} 换绑吗?会退还当前虚拟号并生成新绑链;若旧号已被系统回收会自动跳过退号并直接新取号。原领取链接可继续使用。`, ) } onPrepareKuaishouCloud={() => @@ -583,7 +583,7 @@ function TaskActionPanel({ loading={actionLoadingKey === 'rebind-role'} onClick={onRebindRole} > - 换绑角色 + 换绑 ) : null} {canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (