修复 lewan 换绑:旧号被 CT 回收时自动新取号出链
退号失败(含权限不足)不再卡住换绑,默认跳过并重新取号生成绑链; CT 错误返回补充账号/vn 诊断信息,避免误判为后台角色权限问题。
This commit is contained in:
@@ -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 重取 bindUrl(reuseExistingVn)
|
||||||
|
*/
|
||||||
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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user