Files
order_site/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts
T

926 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
}
}