修复 lewan 换绑:旧号被 CT 回收时自动新取号出链

退号失败(含权限不足)不再卡住换绑,默认跳过并重新取号生成绑链;
CT 错误返回补充账号/vn 诊断信息,避免误判为后台角色权限问题。
This commit is contained in:
yml2213
2026-07-11 12:13:07 +08:00
parent 792680bd19
commit b0c77e5842
10 changed files with 750 additions and 61 deletions
+2 -1
View File
@@ -92,7 +92,8 @@ router.post(
createJsonHandler( createJsonHandler(
(req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null), (req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null),
{ {
successMessage: '角色换绑资源已准备完成', // 旧号已回收时会自动跳过退号并新取号出链
successMessage: '换绑完成:已生成新的绑定链接(原领取链接可继续使用)',
errorMessage: '换绑角色失败', errorMessage: '换绑角色失败',
scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]', scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]',
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data), audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data),
@@ -64,6 +64,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
const result = await prepareFulfillmentBinding(task, { const result = await prepareFulfillmentBinding(task, {
source: 'admin_task_prepare', source: 'admin_task_prepare',
actor: buildAdminActionActor(session), actor: buildAdminActionActor(session),
force: true,
}) })
if (!result?.task) { if (!result?.task) {
throw createHttpError('当前任务不支持准备绑定资源', { throw createHttpError('当前任务不支持准备绑定资源', {
@@ -94,16 +94,21 @@ async function prepareBinding(
task: TaskRow, task: TaskRow,
options: FulfillmentActionOptions = {}, options: FulfillmentActionOptions = {},
): Promise<FulfillmentActionResult> { ): Promise<FulfillmentActionResult> {
// 后台/显式 force:旧号可被 CT 回收,忽略退号失败并取新号+新绑链
const force =
options.force === true ||
String(options.source || '').includes('admin_')
const result = await prepareKuaishouCloudFulfillmentTask(task, { const result = await prepareKuaishouCloudFulfillmentTask(task, {
source: options.source || 'executor_prepare_binding', source: options.source || 'executor_prepare_binding',
actor: options.actor, actor: options.actor,
force: options.force === true, force,
}) })
return { return {
task: result.task || task, task: result.task || task,
claimUrl: result.claimUrl, claimUrl: result.claimUrl,
token: result.token, token: result.token,
flow: result.flow, flow: result.flow,
previousVnRelease: (result as { previousVnRelease?: string }).previousVnRelease,
} }
} }
@@ -63,7 +63,10 @@ export function resolvePersistedCloudtentaclesContextBySourceKeys(
deviceType: normalizeCloudtentaclesDeviceType( deviceType: normalizeCloudtentaclesDeviceType(
session.deviceType ?? source.deviceType session.deviceType ?? source.deviceType
), ),
// 透传给 http-client / 错误包装,便于定位跨账号问题
sourceKey,
resolvedSourceKey: sourceKey, resolvedSourceKey: sourceKey,
accountLabel: String(source.label || sourceKey).trim() || sourceKey,
}; };
} }
@@ -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)
})
@@ -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
}
@@ -3,7 +3,10 @@ import { updateTask } from '../../../repositories/task-repo.js'
import { buildClaimUrl } from '../../claim/claim-service.js' import { buildClaimUrl } from '../../claim/claim-service.js'
import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js' import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js'
import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-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 { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js'
import { createHttpError } from '../../../utils/http.js' import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js' import { nowIso } from '../../../utils/time.js'
@@ -27,6 +30,7 @@ import {
selectCloudtentaclesSourceForFulfillment, selectCloudtentaclesSourceForFulfillment,
} from './account-selector.js' } from './account-selector.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js'
import { import {
getTaskClaimExpiresAt, getTaskClaimExpiresAt,
normalizeActor, normalizeActor,
@@ -40,6 +44,11 @@ import {
} from './role-state.js' } from './role-state.js'
import type { TaskRow } from '../../../types/repository/rows.js' import type { TaskRow } from '../../../types/repository/rows.js'
/**
* 准备 / 重建绑定资源(不退款、不重建订单/claim)。
* - 默认:已 ready 且绑链未过期则直接复用
* - force=true:旧号 best-effort 退还(已被系统回收则忽略),再取新号+新绑链
*/
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) { export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) { if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
@@ -54,6 +63,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
const taskContext = parseTaskContext(task) const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const claimLinkState = await ensureTaskClaimLink(task) const claimLinkState = await ensureTaskClaimLink(task)
const source = String(options.source || 'system').trim() || 'system'
if ( if (
!force && !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) const selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow)
try { try {
const cloudContext = selectedCloud.context 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, { const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.WAITING_BINDING, task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim', user_action_status: 'pending_claim',
@@ -210,7 +239,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
role_id: '', role_id: '',
role_name: '', role_name: '',
last_error: '', last_error: lastErrorNote,
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
updated_at: now, updated_at: now,
}) })
@@ -219,7 +248,9 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
task.id, task.id,
'kuaishou_cloud_binding_prepared', 'kuaishou_cloud_binding_prepared',
{ {
source: String(options.source || 'system').trim() || 'system', source,
force,
previousVnRelease,
skuId: flowWithResolvedBinding.binding.skuId, skuId: flowWithResolvedBinding.binding.skuId,
skuName: flowWithResolvedBinding.binding.skuName, skuName: flowWithResolvedBinding.binding.skuName,
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
@@ -249,12 +280,83 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
claimUrl: claimLinkState.claimUrl, claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token, token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
previousVnRelease,
} }
} finally { } finally {
selectedCloud.release() selectedCloud.release()
} }
} }
/** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */
async function tryReleasePreviousVirtualNumber({
task,
flow,
actor,
source,
now,
}: {
task: TaskRow
flow: ReturnType<typeof normalizeKuaishouCloudFlow>
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 = {}) { export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) { if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
@@ -267,6 +369,9 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
const actor = normalizeActor(options.actor) const actor = normalizeActor(options.actor)
const taskContext = parseTaskContext(task) const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) 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) { if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', {
@@ -275,32 +380,146 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
}) })
} }
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ let cloudContext: JsonObject
flow.binding.resolvedSourceKey, try {
...flow.binding.cloudSourceKeys, 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 oldVnKey = flow.binding.vnKey
const oldVnId = flow.binding.vnId const oldVnId = flow.binding.vnId
const oldVnPhone = flow.binding.vnPhone const oldVnPhone = flow.binding.vnPhone
const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task))
await backCloudtentaclesVirtualNumber({ // 路径 1:复用现有虚拟号,只重取 bindUrl(不退号、不占新号)
...cloudContext, if (preferReuseVn) {
key: oldVnKey, try {
id: oldVnId, 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( await createTaskEvent(
task.id, task.id,
'kuaishou_cloud_expired_bind_number_returned', 'kuaishou_cloud_bind_url_refreshed',
{ {
source: String(options.source || 'system').trim() || 'system', 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, vnKey: oldVnKey,
vnId: oldVnId, vnId: oldVnId,
vnPhoneMasked: maskPhone(oldVnPhone), vnPhone: oldVnPhone,
actor, })
}, await createTaskEvent(
now, 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 let preparedBinding
try { try {
@@ -325,6 +544,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
now, now,
actor, actor,
error, error,
oldNumberBackStatus,
oldNumberBackError,
}) })
return { return {
@@ -343,6 +564,10 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
preparedBinding, preparedBinding,
now, now,
}) })
const skipNote =
oldNumberBackStatus === 'skipped'
? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链`
: ''
const nextContext = { const nextContext = {
...taskContext, ...taskContext,
kuaishouCloudFulfillment: { kuaishouCloudFulfillment: {
@@ -350,6 +575,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
binding: { binding: {
...flow.binding, ...flow.binding,
prepareStatus: 'ready', prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId, vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone, 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, { const updatedTask = await updateTask(task.id, {
task_status: task_status:
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED 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 : '', normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '',
role_name: role_name:
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '', normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '',
last_error: '', last_error: skipNote,
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
updated_at: now, updated_at: now,
}) })
@@ -396,12 +622,16 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
task.id, task.id,
'kuaishou_cloud_bind_url_refreshed', 'kuaishou_cloud_bind_url_refreshed',
{ {
source: String(options.source || 'system').trim() || 'system', source,
mode: 'replace_vn',
oldVnId, oldVnId,
oldVnPhoneMasked: maskPhone(oldVnPhone), oldVnPhoneMasked: maskPhone(oldVnPhone),
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId, vnId: preparedBinding.vnId,
vnPhoneMasked: maskPhone(preparedBinding.vnPhone), vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
oldNumberBackStatus,
oldNumberBackError,
cloudSourceKey: cloudContext.resolvedSourceKey,
actor, actor,
}, },
now, now,
@@ -412,16 +642,32 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
claimUrl: claimLinkState.claimUrl, claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token, token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
mode: 'replace_vn',
oldNumberBackStatus,
} }
} }
async function markKuaishouCloudBindUrlRefreshFailed( async function markKuaishouCloudBindUrlRefreshFailed(
task: TaskRow, task: TaskRow,
{ taskContext, flow, now, actor, error }: JsonObject = {}, {
taskContext,
flow,
now,
actor,
error,
oldNumberBackStatus = 'unknown',
oldNumberBackError = '',
}: JsonObject = {},
) { ) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error || '新绑定链接准备失败') error instanceof Error ? error.message : String(error || '新绑定链接准备失败')
const backNote =
oldNumberBackStatus === 'skipped'
? `旧号退还失败(${oldNumberBackError || '未知'})且`
: oldNumberBackStatus === 'success'
? '旧号码已退还,'
: ''
const nextContext = { const nextContext = {
...taskContext, ...taskContext,
kuaishouCloudFulfillment: { kuaishouCloudFulfillment: {
@@ -429,8 +675,8 @@ async function markKuaishouCloudBindUrlRefreshFailed(
binding: { binding: {
...flow.binding, ...flow.binding,
prepareStatus: 'pending', prepareStatus: 'pending',
vnId: 0, vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0,
vnPhone: '', vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '',
bindUrl: '', bindUrl: '',
bindPreparedAt: null, bindPreparedAt: null,
bindExpiresAt: null, bindExpiresAt: null,
@@ -445,7 +691,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
name: '', name: '',
rid: '', rid: '',
refreshedAt: now, refreshedAt: now,
errorMessage: '绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理', errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`,
rawInfo: null, rawInfo: null,
}, },
}, },
@@ -455,7 +701,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
task_status: TASK_STATUS.PENDING_BINDING_PREPARE, task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
role_id: '', role_id: '',
role_name: '', role_name: '',
last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`, last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`,
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
updated_at: now, updated_at: now,
}) })
@@ -1,7 +1,10 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js' import { updateTask } from '../../../repositories/task-repo.js'
import { buildClaimUrl } from '../../claim/claim-service.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 { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js' import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js'
@@ -17,6 +20,10 @@ import {
resolveKuaishouCloudVnKeyCandidates, resolveKuaishouCloudVnKeyCandidates,
} from './binding-resources.js' } from './binding-resources.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
import {
isCloudtentaclesPermissionLikeError,
wrapCloudtentaclesOperationError,
} from './cloudtentacles-errors.js'
import { import {
getTaskClaimExpiresAt, getTaskClaimExpiresAt,
normalizeActor, normalizeActor,
@@ -29,6 +36,12 @@ import {
} from './role-state.js' } from './role-state.js'
import type { TaskRow } from '../../../types/repository/rows.js' import type { TaskRow } from '../../../types/repository/rows.js'
/**
* 换绑角色:尽量退旧号后取新号+新绑链。
* - 不涉及订单退款;claimUrl 复用
* - 旧号退还失败时默认软跳过继续取新号(避免卡死),并在事件/文案中标明需人工清理旧号
* - 也可先尝试同 vn 重取 bindUrlreuseExistingVn
*/
export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) { export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) { if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { 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 source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role'
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ // 优先 resolvedSourceKey,避免静默落到别的账号导致「权限不足」
const preferredSourceKeys = [
flow.binding.resolvedSourceKey, flow.binding.resolvedSourceKey,
...flow.binding.cloudSourceKeys, ...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 = { const oldBinding = {
vnKey: flow.binding.vnKey, vnKey: flow.binding.vnKey,
vnId: flow.binding.vnId, vnId: flow.binding.vnId,
@@ -87,6 +115,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
bindExpiresAt: flow.binding.bindExpiresAt, bindExpiresAt: flow.binding.bindExpiresAt,
roleName: flow.binding.roleName || flow.role.name || task.role_name || '', roleName: flow.binding.roleName || flow.role.name || task.role_name || '',
roleId: flow.binding.roleId || flow.role.rid || task.role_id || '', roleId: flow.binding.roleId || flow.role.rid || task.role_id || '',
resolvedSourceKey: flow.binding.resolvedSourceKey || String(cloudContext.resolvedSourceKey || ''),
} }
const previousRebind: JsonObject = const previousRebind: JsonObject =
flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as 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, requestedAt: now,
requestedBy: actor, requestedBy: actor,
oldBinding, oldBinding,
cloudSourceKey: String(cloudContext.resolvedSourceKey || ''),
} }
await createTaskEvent( await createTaskEvent(
@@ -110,30 +140,92 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), oldVnPhoneMasked: maskPhone(oldBinding.vnPhone),
oldRoleName: oldBinding.roleName, oldRoleName: oldBinding.roleName,
oldRoleId: oldBinding.roleId, oldRoleId: oldBinding.roleId,
cloudSourceKey: baseHistoryItem.cloudSourceKey,
actor, actor,
}, },
now, now,
) )
await backCloudtentaclesVirtualNumber({ // 路径 A:仅重取同 vn 绑链(不退号、不换号)——适合链接过期但角色还要重绑同一会话时
...cloudContext, if (options.reuseExistingVn === true) {
key: oldBinding.vnKey, return rebindByRefreshingExistingVnBindUrl({
id: oldBinding.vnId, task,
}) taskContext,
flow,
await createTaskEvent( cloudContext,
task.id, oldBinding,
'kuaishou_cloud_rebind_old_number_returned', previousRebind,
{ history,
source, baseHistoryItem,
attempt, 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, vnKey: oldBinding.vnKey,
vnId: oldBinding.vnId, vnId: oldBinding.vnId,
vnPhoneMasked: maskPhone(oldBinding.vnPhone), vnPhone: oldBinding.vnPhone,
actor, })
},
now, 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 let preparedBinding
try { try {
@@ -153,8 +245,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
binding: { binding: {
...flow.binding, ...flow.binding,
prepareStatus: 'pending', prepareStatus: 'pending',
vnId: 0, vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding.vnId,
vnPhone: '', vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding.vnPhone,
bindUrl: '', bindUrl: '',
bindPreparedAt: null, bindPreparedAt: null,
bindExpiresAt: null, bindExpiresAt: null,
@@ -169,7 +261,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
name: '', name: '',
rid: '', rid: '',
refreshedAt: now, refreshedAt: now,
errorMessage: `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, errorMessage:
oldNumberBackStatus === 'success'
? `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`
: `旧号退还未成功且新号准备失败:${errorMessage}`,
rawInfo: null, rawInfo: null,
}, },
rebind: { rebind: {
@@ -180,6 +275,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
{ {
...baseHistoryItem, ...baseHistoryItem,
status: 'failed', status: 'failed',
oldNumberBackStatus,
oldNumberBackError,
errorMessage, errorMessage,
}, },
], ],
@@ -191,7 +288,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
role_id: '', role_id: '',
role_name: '', role_name: '',
role_confirmed_at: null, role_confirmed_at: null,
last_error: `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, last_error:
oldNumberBackStatus === 'success'
? `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`
: `换绑失败:旧号退还失败(${oldNumberBackError}),且新号准备失败:${errorMessage}`,
context_json: JSON.stringify(failedContext), context_json: JSON.stringify(failedContext),
updated_at: now, updated_at: now,
}) })
@@ -203,6 +303,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
source, source,
attempt, attempt,
errorMessage, errorMessage,
oldNumberBackStatus,
oldNumberBackError,
actor, actor,
}, },
now, now,
@@ -225,6 +327,10 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
preparedBinding, preparedBinding,
now, now,
}) })
const skipNote =
oldNumberBackStatus === 'skipped'
? `(旧号 vnId=${oldBinding.vnId} 退还失败已跳过,账号=${baseHistoryItem.cloudSourceKey},请稍后在 CT 人工清理)`
: ''
const nextContext = { const nextContext = {
...taskContext, ...taskContext,
kuaishouCloudFulfillment: { kuaishouCloudFulfillment: {
@@ -232,6 +338,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
binding: { binding: {
...flow.binding, ...flow.binding,
prepareStatus: 'ready', prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId, vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone, vnPhone: preparedBinding.vnPhone,
@@ -261,6 +369,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
bindExpiresAt: nextBindExpiresAt, bindExpiresAt: nextBindExpiresAt,
}, },
status: 'success', status: 'success',
oldNumberBackStatus,
oldNumberBackError,
errorMessage: '', errorMessage: '',
}, },
], ],
@@ -276,7 +386,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
role_id: '', role_id: '',
role_name: '', role_name: '',
role_confirmed_at: null, role_confirmed_at: null,
last_error: '', last_error: skipNote,
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
updated_at: now, updated_at: now,
}) })
@@ -293,6 +403,9 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
vnId: preparedBinding.vnId, vnId: preparedBinding.vnId,
vnPhoneMasked: maskPhone(preparedBinding.vnPhone), vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
bindUrl: preparedBinding.bindUrl, bindUrl: preparedBinding.bindUrl,
oldNumberBackStatus,
oldNumberBackError,
cloudSourceKey: baseHistoryItem.cloudSourceKey,
defaultRoleName: defaultRoleSnapshot.defaultName, defaultRoleName: defaultRoleSnapshot.defaultName,
defaultRoleId: defaultRoleSnapshot.defaultRid, defaultRoleId: defaultRoleSnapshot.defaultRid,
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
@@ -306,6 +419,151 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
claimUrl: claimLinkState.claimUrl, claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token, token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
oldNumberBackStatus,
} }
} }
async function rebindByRefreshingExistingVnBindUrl(input: {
task: TaskRow
taskContext: JsonObject
flow: ReturnType<typeof normalizeKuaishouCloudFlow>
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',
}
}
@@ -181,8 +181,16 @@ export async function getCloudtentaclesBindUrl(payload: JsonObject = {}) {
method: 'POST', method: 'POST',
token, token,
body: { id, key }, body: { id, key },
businessErrorStatusCode: 401, businessErrorStatusCode: 409,
businessErrorCode: 'cloudtentacles_vn_bind_url_failed', 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 { return {
@@ -340,8 +348,17 @@ export async function backCloudtentaclesVirtualNumber(payload: JsonObject = {})
method: 'POST', method: 'POST',
token, token,
body: { key, id }, body: { key, id },
businessErrorStatusCode: 401, // 409:业务失败,避免前端/客服误判为后台登录或角色权限问题
businessErrorStatusCode: 409,
businessErrorCode: 'cloudtentacles_vn_back_failed', 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 { return {
@@ -282,8 +282,8 @@ export default function AdminTaskDetailPage() {
runTaskAction( runTaskAction(
'rebind-role', 'rebind-role',
() => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId), () => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId),
'角色换绑资源已准备完成', '换绑完成:已生成新的绑定链接',
`确认为任务 ${resolvedDetail.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码`, `确认为任务 ${resolvedDetail.task.taskNo} 换绑吗?会退还当前虚拟号并生成新绑链;若旧号已被系统回收会自动跳过退号并直接新取号。原领取链接可继续使用`,
) )
} }
onPrepareKuaishouCloud={() => onPrepareKuaishouCloud={() =>
@@ -583,7 +583,7 @@ function TaskActionPanel({
loading={actionLoadingKey === 'rebind-role'} loading={actionLoadingKey === 'rebind-role'}
onClick={onRebindRole} onClick={onRebindRole}
> >
</Button> </Button>
) : null} ) : null}
{canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? ( {canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (