修复 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
@@ -3,7 +3,10 @@ import { updateTask } from '../../../repositories/task-repo.js'
import { buildClaimUrl } from '../../claim/claim-service.js'
import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js'
import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js'
import { backCloudtentaclesVirtualNumber } from '../../platforms/cloudtentacles/virtual-number-service.js'
import {
backCloudtentaclesVirtualNumber,
getCloudtentaclesBindUrl,
} from '../../platforms/cloudtentacles/virtual-number-service.js'
import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
@@ -27,6 +30,7 @@ import {
selectCloudtentaclesSourceForFulfillment,
} from './account-selector.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js'
import {
getTaskClaimExpiresAt,
normalizeActor,
@@ -40,6 +44,11 @@ import {
} from './role-state.js'
import type { TaskRow } from '../../../types/repository/rows.js'
/**
* 准备 / 重建绑定资源(不退款、不重建订单/claim)。
* - 默认:已 ready 且绑链未过期则直接复用
* - force=true:旧号 best-effort 退还(已被系统回收则忽略),再取新号+新绑链
*/
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
@@ -54,6 +63,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const claimLinkState = await ensureTaskClaimLink(task)
const source = String(options.source || 'system').trim() || 'system'
if (
!force &&
@@ -90,6 +100,18 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
}
}
// force 重建:旧号可能已被 CT 回收,退号失败直接忽略,下面重新取号
let previousVnRelease: 'none' | 'returned' | 'already_reclaimed' | 'skipped' = 'none'
if (force && flow.binding.vnId > 0 && flow.binding.vnKey) {
previousVnRelease = await tryReleasePreviousVirtualNumber({
task,
flow,
actor,
source,
now,
})
}
const selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow)
try {
const cloudContext = selectedCloud.context
@@ -203,6 +225,13 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
},
}
const lastErrorNote =
previousVnRelease === 'already_reclaimed' || previousVnRelease === 'skipped'
? '已重新取号并生成绑定链接(旧号可能已被 CT 回收,已忽略退号失败)'
: force
? '已重新取号并生成绑定链接'
: ''
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
@@ -210,7 +239,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
role_id: '',
role_name: '',
last_error: '',
last_error: lastErrorNote,
context_json: JSON.stringify(nextContext),
updated_at: now,
})
@@ -219,7 +248,9 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
task.id,
'kuaishou_cloud_binding_prepared',
{
source: String(options.source || 'system').trim() || 'system',
source,
force,
previousVnRelease,
skuId: flowWithResolvedBinding.binding.skuId,
skuName: flowWithResolvedBinding.binding.skuName,
vnKey: preparedBinding.vnKey,
@@ -249,12 +280,83 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
previousVnRelease,
}
} finally {
selectedCloud.release()
}
}
/** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */
async function tryReleasePreviousVirtualNumber({
task,
flow,
actor,
source,
now,
}: {
task: TaskRow
flow: ReturnType<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 = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
@@ -267,6 +369,9 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
const actor = normalizeActor(options.actor)
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const source = String(options.source || 'system').trim() || 'system'
const allowSkipBack = options.allowSkipBack !== false
const preferReuseVn = options.preferReuseVn !== false
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', {
@@ -275,32 +380,146 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
})
}
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
flow.binding.resolvedSourceKey,
...flow.binding.cloudSourceKeys,
])
let cloudContext: JsonObject
try {
cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
flow.binding.resolvedSourceKey,
...flow.binding.cloudSourceKeys,
])
} catch (error) {
throw wrapCloudtentaclesOperationError(error, {
action: 'resolve_source',
actionLabel: '解析取号账号',
sourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0],
vnKey: flow.binding.vnKey,
vnId: flow.binding.vnId,
vnPhone: flow.binding.vnPhone,
})
}
const oldVnKey = flow.binding.vnKey
const oldVnId = flow.binding.vnId
const oldVnPhone = flow.binding.vnPhone
const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task))
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: oldVnKey,
id: oldVnId,
})
// 路径 1:复用现有虚拟号,只重取 bindUrl(不退号、不占新号)
if (preferReuseVn) {
try {
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: oldVnKey,
id: oldVnId,
})
const bindUrl = String(bindUrlResult.bindUrl || '').trim()
if (bindUrl) {
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
bindUrl,
bindPreparedAt: now,
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
bindProbeAt: null as null,
bindProbeStatus: 'pending',
bindProbeMessage: '',
},
},
}
const updatedTask = await updateTask(task.id, {
task_status:
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
? task.task_status
: TASK_STATUS.WAITING_BINDING,
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_expired_bind_number_returned',
{
source: String(options.source || 'system').trim() || 'system',
await createTaskEvent(
task.id,
'kuaishou_cloud_bind_url_refreshed',
{
source,
mode: 'reuse_existing_vn',
vnKey: oldVnKey,
vnId: oldVnId,
vnPhoneMasked: maskPhone(oldVnPhone),
cloudSourceKey: cloudContext.resolvedSourceKey,
actor,
},
now,
)
return {
task: updatedTask,
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
mode: 'reuse_existing_vn',
}
}
} catch {
// 同号取链失败则走退号换号
}
}
// 路径 2:退旧号 + 取新号
let oldNumberBackStatus: 'success' | 'skipped' = 'success'
let oldNumberBackError = ''
try {
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: oldVnKey,
id: oldVnId,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_expired_bind_number_returned',
{
source,
vnKey: oldVnKey,
vnId: oldVnId,
vnPhoneMasked: maskPhone(oldVnPhone),
cloudSourceKey: cloudContext.resolvedSourceKey,
actor,
},
now,
)
} catch (error) {
oldNumberBackError = error instanceof Error ? error.message : String(error || '退号失败')
const wrapped = wrapCloudtentaclesOperationError(error, {
action: 'vn_back',
actionLabel: '退还过期虚拟号',
sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey,
vnKey: oldVnKey,
vnId: oldVnId,
vnPhoneMasked: maskPhone(oldVnPhone),
actor,
},
now,
)
vnPhone: oldVnPhone,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_expired_bind_number_back_failed',
{
source,
vnKey: oldVnKey,
vnId: oldVnId,
errorMessage: wrapped.message,
cloudSourceKey: cloudContext.resolvedSourceKey,
actor,
},
now,
)
if (!allowSkipBack) {
throw wrapped
}
oldNumberBackStatus = 'skipped'
}
let preparedBinding
try {
@@ -325,6 +544,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
now,
actor,
error,
oldNumberBackStatus,
oldNumberBackError,
})
return {
@@ -343,6 +564,10 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
preparedBinding,
now,
})
const skipNote =
oldNumberBackStatus === 'skipped'
? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链`
: ''
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
@@ -350,6 +575,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
binding: {
...flow.binding,
prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone,
@@ -375,7 +602,6 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
},
}
const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task))
const updatedTask = await updateTask(task.id, {
task_status:
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
@@ -387,7 +613,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '',
role_name:
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '',
last_error: '',
last_error: skipNote,
context_json: JSON.stringify(nextContext),
updated_at: now,
})
@@ -396,12 +622,16 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
task.id,
'kuaishou_cloud_bind_url_refreshed',
{
source: String(options.source || 'system').trim() || 'system',
source,
mode: 'replace_vn',
oldVnId,
oldVnPhoneMasked: maskPhone(oldVnPhone),
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
oldNumberBackStatus,
oldNumberBackError,
cloudSourceKey: cloudContext.resolvedSourceKey,
actor,
},
now,
@@ -412,16 +642,32 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
mode: 'replace_vn',
oldNumberBackStatus,
}
}
async function markKuaishouCloudBindUrlRefreshFailed(
task: TaskRow,
{ taskContext, flow, now, actor, error }: JsonObject = {},
{
taskContext,
flow,
now,
actor,
error,
oldNumberBackStatus = 'unknown',
oldNumberBackError = '',
}: JsonObject = {},
) {
const errorMessage =
error instanceof Error ? error.message : String(error || '新绑定链接准备失败')
const backNote =
oldNumberBackStatus === 'skipped'
? `旧号退还失败(${oldNumberBackError || '未知'})且`
: oldNumberBackStatus === 'success'
? '旧号码已退还,'
: ''
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
@@ -429,8 +675,8 @@ async function markKuaishouCloudBindUrlRefreshFailed(
binding: {
...flow.binding,
prepareStatus: 'pending',
vnId: 0,
vnPhone: '',
vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0,
vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '',
bindUrl: '',
bindPreparedAt: null,
bindExpiresAt: null,
@@ -445,7 +691,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
name: '',
rid: '',
refreshedAt: now,
errorMessage: '绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理',
errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`,
rawInfo: null,
},
},
@@ -455,7 +701,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
role_id: '',
role_name: '',
last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`,
last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`,
context_json: JSON.stringify(nextContext),
updated_at: now,
})