926 lines
29 KiB
TypeScript
926 lines
29 KiB
TypeScript
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||
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,
|
||
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'
|
||
import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js'
|
||
import {
|
||
KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||
isKuaishouCloudBindUrlFresh,
|
||
isKuaishouCloudBindingMutationFrozen,
|
||
isKuaishouCloudTask,
|
||
maskPhone,
|
||
normalizeKuaishouCloudFlow,
|
||
normalizeStringArray,
|
||
resolveKuaishouCloudBindUrlExpiresAt,
|
||
type JsonObject,
|
||
} from './domain.js'
|
||
import {
|
||
prepareKuaishouCloudBindResourceWithFallback,
|
||
resolveKuaishouCloudBindingResources,
|
||
resolveKuaishouCloudVnKeyCandidates,
|
||
} from './binding-resources.js'
|
||
import {
|
||
buildCloudtentaclesSourceSelectionEventDetail,
|
||
selectCloudtentaclesSourceForFulfillment,
|
||
} from './account-selector.js'
|
||
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
|
||
import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js'
|
||
import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js'
|
||
import { resolveKuaishouCloudDeliveryPlan } from './delivery-plan.js'
|
||
import { ensureTaskClaimLink } from './ensure-claim-link.js'
|
||
import {
|
||
buildPendingRoleWithDefaultSnapshot,
|
||
captureKuaishouCloudDefaultRoleSnapshot,
|
||
} 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 履约任务', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_task_invalid',
|
||
})
|
||
}
|
||
|
||
const now = nowIso()
|
||
const actor = normalizeActor(options.actor)
|
||
const force = options.force === true
|
||
const taskContext = parseTaskContext(task)
|
||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||
const claimLinkState = await ensureTaskClaimLink(task)
|
||
const source = String(options.source || 'system').trim() || 'system'
|
||
|
||
// 已发货:禁止 prepare / 过期换号把状态打回 waiting_binding
|
||
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
|
||
return {
|
||
task,
|
||
claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')),
|
||
token: String(task.primary_claim_token || task.claim_token || ''),
|
||
flow,
|
||
}
|
||
}
|
||
|
||
if (
|
||
!force &&
|
||
flow.binding.prepareStatus === 'ready' &&
|
||
flow.binding.vnId > 0 &&
|
||
flow.binding.vnPhone &&
|
||
flow.binding.bindUrl
|
||
) {
|
||
if (!isKuaishouCloudBindUrlFresh(flow)) {
|
||
return refreshKuaishouCloudTaskBindUrl(task, {
|
||
source: options.source || 'system_refresh_expired_bind_url',
|
||
actor,
|
||
claimLinkState,
|
||
})
|
||
}
|
||
|
||
let readyTask = task
|
||
|
||
if (normalizeTaskStatus(task.task_status) !== TASK_STATUS.WAITING_BINDING) {
|
||
readyTask =
|
||
(await updateTask(task.id, {
|
||
task_status: TASK_STATUS.WAITING_BINDING,
|
||
claim_token: claimLinkState.token || task.claim_token || '',
|
||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||
updated_at: now,
|
||
})) || task
|
||
}
|
||
|
||
return {
|
||
task: readyTask,
|
||
claimUrl: claimLinkState.claimUrl,
|
||
token: claimLinkState.token,
|
||
flow,
|
||
}
|
||
}
|
||
|
||
// 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 excludedSourceKeys = normalizeStringArray(options.excludedSourceKeys)
|
||
const sourceSelectionAttempts = normalizeStringArray(options.sourceSelectionAttempts)
|
||
const sourceCandidateCount = new Set(
|
||
normalizeStringArray([...flow.binding.cloudSourceKeys, flow.binding.resolvedSourceKey]),
|
||
).size
|
||
const selectedCloud = await selectCloudtentaclesSourceForFulfillment({
|
||
...flow,
|
||
excludedSourceKeys,
|
||
})
|
||
let retriedOnAnotherSource = false
|
||
try {
|
||
const cloudContext = selectedCloud.context
|
||
const [knapsack, skuList] = await Promise.all([
|
||
getCloudtentaclesKnapsack(cloudContext),
|
||
listCloudtentaclesSku(cloudContext),
|
||
])
|
||
const resolvedBinding = resolveKuaishouCloudBindingResources(flow, {
|
||
skuItems: Array.isArray(skuList.items) ? skuList.items : [],
|
||
knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [],
|
||
})
|
||
const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({
|
||
flow,
|
||
binding: resolvedBinding,
|
||
})
|
||
|
||
if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) {
|
||
throw createHttpError('当前任务缺少可用 cloud 资源,无法自动准备绑定', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_missing_binding_config',
|
||
})
|
||
}
|
||
|
||
const flowWithResolvedBinding = {
|
||
...flow,
|
||
binding: {
|
||
...flow.binding,
|
||
skuId: resolvedBinding.skuId,
|
||
skuName: resolvedBinding.skuName,
|
||
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||
},
|
||
}
|
||
|
||
const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, {
|
||
skuItems: Array.isArray(skuList.items) ? skuList.items : [],
|
||
knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [],
|
||
})
|
||
if (deliveryPlan.items.length === 0) {
|
||
throw createHttpError('当前任务缺少 cloud 发货物品配置', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_missing_delivery_items',
|
||
})
|
||
}
|
||
|
||
const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0)
|
||
if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
||
throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_auto_buy_disabled',
|
||
})
|
||
}
|
||
|
||
const missingSku = deliveryPlan.items.find((item) => item.missingCount > 0 && !item.skuItem)
|
||
if (missingSku) {
|
||
throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, {
|
||
statusCode: 404,
|
||
errorCode: 'kuaishou_cloud_sku_not_found',
|
||
})
|
||
}
|
||
|
||
const purchaseTriggered = false
|
||
const assetBefore = 0
|
||
const assetAfter = 0
|
||
|
||
const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||
cloudContext,
|
||
vnKeyCandidates,
|
||
purpose: 'new_fulfillment',
|
||
})
|
||
const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({
|
||
cloudContext,
|
||
preparedBinding,
|
||
now,
|
||
})
|
||
|
||
const nextContext = {
|
||
...taskContext,
|
||
kuaishouCloudFulfillment: {
|
||
...flowWithResolvedBinding,
|
||
binding: {
|
||
...flowWithResolvedBinding.binding,
|
||
resolvedSourceKey: cloudContext.resolvedSourceKey,
|
||
vnKey: preparedBinding.vnKey,
|
||
prepareStatus: 'ready',
|
||
vnId: preparedBinding.vnId,
|
||
vnPhone: preparedBinding.vnPhone,
|
||
bindUrl: preparedBinding.bindUrl,
|
||
bindPreparedAt: now,
|
||
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
|
||
bindProbeAt: null as null,
|
||
bindProbeStatus: 'pending',
|
||
bindProbeMessage: '',
|
||
roleName: '',
|
||
roleId: '',
|
||
},
|
||
role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot),
|
||
purchase: {
|
||
...flowWithResolvedBinding.purchase,
|
||
usedKnapsack,
|
||
purchaseTriggered,
|
||
assetBefore,
|
||
assetAfter,
|
||
items: deliveryPlan.items.map((item) => ({
|
||
cloudSkuId: item.cloudSkuId,
|
||
cloudSkuName: item.cloudSkuName,
|
||
requiredCount: item.quantity,
|
||
knapsackCount: item.knapsackCount,
|
||
purchasedCount: 0,
|
||
})),
|
||
purchaseAt: flowWithResolvedBinding.purchase.purchaseAt,
|
||
},
|
||
},
|
||
}
|
||
|
||
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',
|
||
claim_token: claimLinkState.token || task.claim_token || '',
|
||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||
role_id: '',
|
||
role_name: '',
|
||
last_error: lastErrorNote,
|
||
context_json: JSON.stringify(nextContext),
|
||
updated_at: now,
|
||
})
|
||
|
||
await createTaskEvent(
|
||
task.id,
|
||
'kuaishou_cloud_binding_prepared',
|
||
{
|
||
source,
|
||
force,
|
||
previousVnRelease,
|
||
skuId: flowWithResolvedBinding.binding.skuId,
|
||
skuName: flowWithResolvedBinding.binding.skuName,
|
||
vnKey: preparedBinding.vnKey,
|
||
vnId: preparedBinding.vnId,
|
||
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
|
||
purchaseTriggered,
|
||
usedKnapsack,
|
||
deliveryItems: deliveryPlan.items.map((item) => ({
|
||
cloudSkuId: item.cloudSkuId,
|
||
cloudSkuName: item.cloudSkuName,
|
||
quantity: item.quantity,
|
||
knapsackCount: item.knapsackCount,
|
||
purchasedCount: 0,
|
||
})),
|
||
resolvedByName: resolvedBinding.resolvedByName,
|
||
accountSelection: {
|
||
...buildCloudtentaclesSourceSelectionEventDetail(selectedCloud),
|
||
attemptedSourceKeys: [...sourceSelectionAttempts, selectedCloud.sourceKey],
|
||
},
|
||
defaultRoleName: defaultRoleSnapshot.defaultName,
|
||
defaultRoleId: defaultRoleSnapshot.defaultRid,
|
||
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
|
||
actor,
|
||
},
|
||
now,
|
||
)
|
||
|
||
return {
|
||
task: updatedTask,
|
||
claimUrl: claimLinkState.claimUrl,
|
||
token: claimLinkState.token,
|
||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||
previousVnRelease,
|
||
}
|
||
} catch (error) {
|
||
// Before a VN exists, account-scoped failures are safe to retry on another
|
||
// candidate. Once a VN is acquired, switching accounts would orphan it.
|
||
if (
|
||
flow.binding.vnId <= 0 &&
|
||
isRetryableCloudtentaclesSourceError(error) &&
|
||
excludedSourceKeys.length + 1 < sourceCandidateCount
|
||
) {
|
||
retriedOnAnotherSource = true
|
||
selectedCloud.release()
|
||
return prepareKuaishouCloudFulfillmentTask(task, {
|
||
...options,
|
||
excludedSourceKeys: [...excludedSourceKeys, selectedCloud.sourceKey],
|
||
sourceSelectionAttempts: [...sourceSelectionAttempts, selectedCloud.sourceKey],
|
||
})
|
||
}
|
||
throw error
|
||
} finally {
|
||
if (!retriedOnAnotherSource) {
|
||
selectedCloud.release()
|
||
}
|
||
}
|
||
}
|
||
|
||
function isRetryableCloudtentaclesSourceError(error: unknown) {
|
||
const current = error && typeof error === 'object' ? (error as JsonObject) : {}
|
||
const code = String(current.errorCode || current.code || '').trim()
|
||
return (
|
||
code === 'cloudtentacles_vn_quota_cooldown' ||
|
||
(code === 'cloudtentacles_vn_appoint_failed' &&
|
||
String(current.message || '')
|
||
.trim()
|
||
.includes('最多同时占用')) ||
|
||
code === 'cloudtentacles_vn_list_failed' ||
|
||
code === 'cloudtentacles_sku_list_failed' ||
|
||
code === 'cloudtentacles_knapsack_failed' ||
|
||
code === 'cloudtentacles_asset_failed'
|
||
)
|
||
}
|
||
|
||
/** 旧虚拟号 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 履约任务', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_task_invalid',
|
||
})
|
||
}
|
||
|
||
const now = nowIso()
|
||
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 === true
|
||
const preferReuseVn = options.preferReuseVn !== false
|
||
|
||
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
|
||
throw createHttpError('当前任务已经发货,不能刷新或更换绑定链接', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_bind_url_refresh_after_dispatch_forbidden',
|
||
})
|
||
}
|
||
|
||
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
||
throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', {
|
||
statusCode: 409,
|
||
errorCode: 'kuaishou_cloud_missing_bind_url_context',
|
||
})
|
||
}
|
||
|
||
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))
|
||
|
||
// 路径 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_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 (error) {
|
||
// 号码已退/已回收/无权限:直接清空绑定,不再走退号换号(避免反复打上游)
|
||
if (isNumberAlreadyReleasedError(error)) {
|
||
return clearKuaishouCloudStaleBinding(task, {
|
||
taskContext,
|
||
flow,
|
||
now,
|
||
actor,
|
||
source,
|
||
error,
|
||
claimUrl: claimLinkState.claimUrl,
|
||
token: claimLinkState.token || task.claim_token || '',
|
||
})
|
||
}
|
||
// 其他错误走退号换号
|
||
}
|
||
}
|
||
|
||
// 路径 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) {
|
||
// 号码已退/已回收/无权限:直接清空绑定(不再尝试取新号),避免反复打上游
|
||
if (isNumberAlreadyReleasedError(error)) {
|
||
return clearKuaishouCloudStaleBinding(task, {
|
||
taskContext,
|
||
flow,
|
||
now,
|
||
actor,
|
||
source,
|
||
error,
|
||
claimUrl: claimLinkState.claimUrl,
|
||
token: claimLinkState.token || task.claim_token || '',
|
||
})
|
||
}
|
||
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,
|
||
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 {
|
||
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||
cloudContext,
|
||
purpose: 'expired_binding_replacement',
|
||
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
|
||
flow,
|
||
binding: flow.binding,
|
||
}),
|
||
})
|
||
|
||
if (!String(preparedBinding.bindUrl || '').trim()) {
|
||
throw createHttpError('cloudtentacles 未返回新的绑定链接', {
|
||
statusCode: 502,
|
||
errorCode: 'kuaishou_cloud_empty_bind_url',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, {
|
||
taskContext,
|
||
flow,
|
||
now,
|
||
actor,
|
||
error,
|
||
oldNumberBackStatus,
|
||
oldNumberBackError,
|
||
})
|
||
|
||
return {
|
||
task: failedTask,
|
||
claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')),
|
||
token: String(task.primary_claim_token || task.claim_token || ''),
|
||
flow: normalizeKuaishouCloudFlow(parseTaskContext(failedTask).kuaishouCloudFulfillment),
|
||
}
|
||
}
|
||
|
||
const defaultRoleSnapshot =
|
||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||
? null
|
||
: await captureKuaishouCloudDefaultRoleSnapshot({
|
||
cloudContext,
|
||
preparedBinding,
|
||
now,
|
||
})
|
||
const skipNote =
|
||
oldNumberBackStatus === 'skipped'
|
||
? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链`
|
||
: ''
|
||
const nextContext = {
|
||
...taskContext,
|
||
kuaishouCloudFulfillment: {
|
||
...flow,
|
||
binding: {
|
||
...flow.binding,
|
||
prepareStatus: 'ready',
|
||
resolvedSourceKey: String(
|
||
cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '',
|
||
).trim(),
|
||
vnKey: preparedBinding.vnKey,
|
||
vnId: preparedBinding.vnId,
|
||
vnPhone: preparedBinding.vnPhone,
|
||
bindUrl: preparedBinding.bindUrl,
|
||
bindPreparedAt: now,
|
||
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
|
||
bindProbeAt: null as null,
|
||
bindProbeStatus: 'pending',
|
||
bindProbeMessage: '',
|
||
roleName:
|
||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||
? flow.binding.roleName
|
||
: '',
|
||
roleId:
|
||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||
? flow.binding.roleId
|
||
: '',
|
||
},
|
||
role:
|
||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||
? flow.role
|
||
: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot!),
|
||
},
|
||
}
|
||
|
||
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),
|
||
role_id:
|
||
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: skipNote,
|
||
context_json: JSON.stringify(nextContext),
|
||
updated_at: now,
|
||
})
|
||
|
||
await createTaskEvent(
|
||
task.id,
|
||
'kuaishou_cloud_bind_url_refreshed',
|
||
{
|
||
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,
|
||
)
|
||
|
||
return {
|
||
task: updatedTask,
|
||
claimUrl: claimLinkState.claimUrl,
|
||
token: claimLinkState.token,
|
||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||
mode: 'replace_vn',
|
||
oldNumberBackStatus,
|
||
}
|
||
}
|
||
|
||
async function markKuaishouCloudBindUrlRefreshFailed(
|
||
task: TaskRow,
|
||
{
|
||
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: {
|
||
...flow,
|
||
binding: {
|
||
...flow.binding,
|
||
prepareStatus: 'pending',
|
||
vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0,
|
||
vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '',
|
||
bindUrl: '',
|
||
bindPreparedAt: null,
|
||
bindExpiresAt: null,
|
||
bindProbeAt: now,
|
||
bindProbeStatus: 'refresh_failed',
|
||
bindProbeMessage: errorMessage,
|
||
roleName: '',
|
||
roleId: '',
|
||
},
|
||
role: {
|
||
status: 'pending',
|
||
name: '',
|
||
rid: '',
|
||
refreshedAt: now,
|
||
errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`,
|
||
rawInfo: null,
|
||
},
|
||
},
|
||
}
|
||
|
||
const updatedTask = await updateTask(task.id, {
|
||
task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
|
||
role_id: '',
|
||
role_name: '',
|
||
last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`,
|
||
context_json: JSON.stringify(nextContext),
|
||
updated_at: now,
|
||
})
|
||
|
||
await createTaskEvent(
|
||
task.id,
|
||
'kuaishou_cloud_bind_url_refresh_failed',
|
||
{
|
||
source: 'system_refresh_expired_bind_url',
|
||
errorMessage,
|
||
actor,
|
||
},
|
||
now,
|
||
)
|
||
|
||
await notifyKuaishouCloudBindUrlRefreshFailed({
|
||
task: updatedTask,
|
||
errorMessage,
|
||
})
|
||
|
||
return updatedTask
|
||
}
|
||
|
||
/** 号码已被上游回收/手动退回/账号换 token 后无操作权限:重试无意义,应清空任务绑定 */
|
||
function isNumberAlreadyReleasedError(error: unknown) {
|
||
const message = error instanceof Error ? error.message : String(error || '')
|
||
return (
|
||
message.includes('权限不足') ||
|
||
message.includes('没有权限') ||
|
||
message.includes('不存在') ||
|
||
message.includes('已释放') ||
|
||
message.includes('已回收') ||
|
||
message.includes('已退还')
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 清空任务侧已失效的旧绑定(号码已退/已回收/无权限),任务下次领取时自动重新取号。
|
||
* 所有调用路径(claim 轮询 probe、open-91 交付、回收任务、后台刷新)统一收敛,
|
||
* 避免对失效号码反复打上游。
|
||
*/
|
||
async function clearKuaishouCloudStaleBinding(
|
||
task: TaskRow,
|
||
{
|
||
taskContext,
|
||
flow,
|
||
now,
|
||
actor,
|
||
source,
|
||
error,
|
||
claimUrl,
|
||
token,
|
||
}: {
|
||
taskContext: JsonObject
|
||
flow: ReturnType<typeof normalizeKuaishouCloudFlow>
|
||
now: string
|
||
actor: unknown
|
||
source: string
|
||
error: unknown
|
||
claimUrl: string
|
||
token: string
|
||
},
|
||
) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error || '绑定已失效')
|
||
const oldVnId = flow.binding.vnId
|
||
const oldVnPhone = flow.binding.vnPhone
|
||
const nextContext = {
|
||
...taskContext,
|
||
kuaishouCloudFulfillment: {
|
||
...flow,
|
||
binding: {
|
||
...flow.binding,
|
||
prepareStatus: 'pending',
|
||
vnId: 0,
|
||
vnPhone: '',
|
||
bindUrl: '',
|
||
bindPreparedAt: null,
|
||
bindExpiresAt: null,
|
||
bindProbeAt: now,
|
||
bindProbeStatus: 'stale_cleared',
|
||
bindProbeMessage: errorMessage,
|
||
roleName: '',
|
||
roleId: '',
|
||
},
|
||
role: {
|
||
status: 'pending',
|
||
name: '',
|
||
rid: '',
|
||
refreshedAt: now,
|
||
errorMessage: '旧绑定号码已失效,已清空绑定,将重新取号',
|
||
rawInfo: null,
|
||
},
|
||
},
|
||
}
|
||
|
||
const updatedTask = await updateTask(task.id, {
|
||
task_status:
|
||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||
? task.task_status
|
||
: TASK_STATUS.PENDING_BINDING_PREPARE,
|
||
role_id: '',
|
||
role_name: '',
|
||
last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`,
|
||
context_json: JSON.stringify(nextContext),
|
||
updated_at: now,
|
||
})
|
||
|
||
await createTaskEvent(
|
||
task.id,
|
||
'kuaishou_cloud_stale_binding_cleared',
|
||
{
|
||
source,
|
||
oldVnId,
|
||
oldVnPhoneMasked: maskPhone(oldVnPhone),
|
||
cloudSourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0] || '',
|
||
errorMessage,
|
||
actor,
|
||
},
|
||
now,
|
||
)
|
||
|
||
return {
|
||
task: updatedTask || task,
|
||
claimUrl,
|
||
token,
|
||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||
mode: 'stale_cleared',
|
||
}
|
||
}
|