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