回收遇无权限号码直接清空任务绑定不再重试:已手动退号的旧绑定一次性收敛,发货收尾视号码已释放
This commit is contained in:
@@ -60,11 +60,31 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
return { task, flow }
|
||||
}
|
||||
|
||||
await backCloudtentaclesVirtualNumber({
|
||||
...cloudContext,
|
||||
key: flow.binding.vnKey,
|
||||
id: flow.binding.vnId,
|
||||
})
|
||||
try {
|
||||
await backCloudtentaclesVirtualNumber({
|
||||
...cloudContext,
|
||||
key: flow.binding.vnKey,
|
||||
id: flow.binding.vnId,
|
||||
})
|
||||
} catch (error) {
|
||||
// 号码已被上游回收/手动退回/账号换 token 无权限:视为已释放,继续完成核销收尾
|
||||
if (isNumberAlreadyReleasedError(error)) {
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_number_already_released',
|
||||
{
|
||||
source: String(options.source || 'system').trim() || 'system',
|
||||
vnId: flow.binding.vnId,
|
||||
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||
errorMessage: error instanceof Error ? error.message : String(error || ''),
|
||||
actor,
|
||||
},
|
||||
now
|
||||
)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const ticketCode = String(flow.ticket.code || '').trim()
|
||||
let consumeStatus = 'pending'
|
||||
@@ -236,8 +256,7 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
}
|
||||
}
|
||||
|
||||
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
|
||||
const voucher = isPlainObject(value.kuaishouIndustryVoucher)
|
||||
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { const voucher = isPlainObject(value.kuaishouIndustryVoucher)
|
||||
? value.kuaishouIndustryVoucher
|
||||
: {}
|
||||
const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim()
|
||||
@@ -249,3 +268,16 @@ function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** 号码已被上游回收/手动退回/账号换 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('已退还')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../repositories/task-repo.js'
|
||||
import { query as dbQuery } from '../../db/client.js'
|
||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||
import { logInfo, logWarn } from '../../utils/logger.js'
|
||||
import { parseJsonObject } from '../../utils/task-json.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
import {
|
||||
isKuaishouCloudBindingMutationFrozen,
|
||||
isKuaishouCloudTask,
|
||||
maskPhone,
|
||||
normalizeKuaishouCloudFlow,
|
||||
} from '../fulfillment/kuaishou-cloud/domain.js'
|
||||
import { refreshKuaishouCloudTaskBindUrl } from '../fulfillment/kuaishou-cloud/prepare-fulfillment.js'
|
||||
@@ -15,12 +19,6 @@ import { returnKuaishouCloudFulfillmentTask } from '../fulfillment/kuaishou-clou
|
||||
const STALE_BIND_URL_GRACE_MS = 15 * 60 * 1000
|
||||
/** 单轮最多处理的任务数(health 每 2.5 分钟跑一次,防止一次扫太多) */
|
||||
const RECYCLE_SCAN_LIMIT = 50
|
||||
/** "权限不足"类孤儿号码最多重试次数:上游判定号码不属于当前会话时重试无意义,3 次后停止打上游 */
|
||||
const ORPHAN_MAX_RETRIES = 3
|
||||
/** 孤儿计数超过该时长后自动重置,允许上游/账号恢复后重新尝试 */
|
||||
const ORPHAN_RESET_AFTER_MS = 60 * 60 * 1000
|
||||
/** 连续失败记录:taskId -> { count, lastAt } */
|
||||
const recycleFailRecords = new Map<number, { count: number; lastAt: number }>()
|
||||
|
||||
type RecycleNumberResult = {
|
||||
scanned: number
|
||||
@@ -28,47 +26,81 @@ type RecycleNumberResult = {
|
||||
skippedMissingContext: number
|
||||
refreshed: number
|
||||
failed: number
|
||||
/** 上游判定无权操作(账号重新登录换 token 后的孤儿号码),已停止重试的任务数 */
|
||||
/** 上游判定无权操作(号码已被手动退回/回收/账号换 token 后的孤儿号码),任务绑定已清空 */
|
||||
orphanCount: number
|
||||
orphanTaskIds: number[]
|
||||
}
|
||||
|
||||
/** 上游"权限不足/没有权限"类错误:号码归属当前账号会话,重试无法恢复 */
|
||||
/** 上游"权限不足/没有权限/号码不存在"类错误:号码已不属于当前会话,重试无法恢复,应清空任务绑定 */
|
||||
function isOrphanNumberError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
return message.includes('权限不足') || message.includes('没有权限')
|
||||
return (
|
||||
message.includes('权限不足') ||
|
||||
message.includes('没有权限') ||
|
||||
message.includes('不存在') ||
|
||||
message.includes('已释放') ||
|
||||
message.includes('已回收')
|
||||
)
|
||||
}
|
||||
|
||||
function getRecycleFailCount(taskId: number) {
|
||||
const record = recycleFailRecords.get(taskId)
|
||||
if (!record) {
|
||||
return 0
|
||||
/** 清空任务侧已失效的旧绑定(号码已退/已回收),任务下次领取时重新取号 */
|
||||
async function clearStaleKuaishouCloudBinding(task: TaskRow, errorMessage: string) {
|
||||
const now = nowIso()
|
||||
const taskContext = parseJsonObject(task.context_json)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (Date.now() - record.lastAt > ORPHAN_RESET_AFTER_MS) {
|
||||
recycleFailRecords.delete(taskId)
|
||||
return 0
|
||||
}
|
||||
return record.count
|
||||
}
|
||||
|
||||
function isRecycleOrphanQuotaReached(taskId: number) {
|
||||
return getRecycleFailCount(taskId) >= ORPHAN_MAX_RETRIES
|
||||
}
|
||||
await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
role_id: '',
|
||||
role_name: '',
|
||||
last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
function recordRecycleFailure(taskId: number, isOrphan: boolean) {
|
||||
if (isOrphan) {
|
||||
const record = recycleFailRecords.get(taskId)
|
||||
recycleFailRecords.set(taskId, {
|
||||
count: (record?.count || 0) + 1,
|
||||
lastAt: Date.now(),
|
||||
})
|
||||
} else {
|
||||
recycleFailRecords.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function recordRecycleSuccess(taskId: number) {
|
||||
recycleFailRecords.delete(taskId)
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_stale_binding_cleared',
|
||||
{
|
||||
source: 'scheduler_stale_number_recycle',
|
||||
oldVnId,
|
||||
oldVnPhoneMasked: maskPhone(oldVnPhone),
|
||||
errorMessage,
|
||||
actor: { source: 'system' },
|
||||
},
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
export async function recycleCloudtentaclesStaleNumbers(): Promise<RecycleNumberResult> {
|
||||
@@ -101,13 +133,6 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise<RecycleNumber
|
||||
result.scanned += 1
|
||||
const taskId = Number(task.id || 0)
|
||||
|
||||
// 已确认孤儿号码:停止无效重试,避免每轮继续打上游
|
||||
if (isRecycleOrphanQuotaReached(taskId)) {
|
||||
result.orphanCount += 1
|
||||
result.orphanTaskIds.push(taskId)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
result.skippedMissingContext += 1
|
||||
@@ -134,27 +159,31 @@ export async function recycleCloudtentaclesStaleNumbers(): Promise<RecycleNumber
|
||||
actor: { source: 'system' },
|
||||
})
|
||||
result.refreshed += 1
|
||||
recordRecycleSuccess(taskId)
|
||||
} catch (error) {
|
||||
const isOrphan = isOrphanNumberError(error)
|
||||
recordRecycleFailure(taskId, isOrphan)
|
||||
if (isOrphan && isRecycleOrphanQuotaReached(taskId)) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
if (isOrphanNumberError(error)) {
|
||||
result.orphanCount += 1
|
||||
result.orphanTaskIds.push(taskId)
|
||||
logWarn(
|
||||
'[scheduler/cloudtentacles-number-recycle]',
|
||||
'孤儿号码达到重试上限,已停止自动重试(需人工或上游处理)',
|
||||
{
|
||||
try {
|
||||
await clearStaleKuaishouCloudBinding(task, message)
|
||||
logWarn(
|
||||
'[scheduler/cloudtentacles-number-recycle]',
|
||||
'旧绑定号码无操作权限(号码已退/已回收),已清空任务绑定,等待重新取号',
|
||||
{ taskId, error: message }
|
||||
)
|
||||
} catch (clearError) {
|
||||
result.failed += 1
|
||||
logWarn('[scheduler/cloudtentacles-number-recycle]', '清空旧绑定失败', {
|
||||
taskId,
|
||||
error: error instanceof Error ? error.message : String(error || ''),
|
||||
}
|
||||
)
|
||||
error: clearError instanceof Error ? clearError.message : String(clearError || ''),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
result.failed += 1
|
||||
logWarn('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收失败', {
|
||||
taskId,
|
||||
error: error instanceof Error ? error.message : String(error || ''),
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -201,12 +230,6 @@ export async function retryCloudtentaclesPendingReturns(): Promise<{
|
||||
result.scanned += 1
|
||||
const taskId = Number(task.id || 0)
|
||||
|
||||
if (isRecycleOrphanQuotaReached(taskId)) {
|
||||
result.orphanCount += 1
|
||||
result.orphanTaskIds.push(taskId)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
result.failed += 1
|
||||
@@ -225,27 +248,22 @@ export async function retryCloudtentaclesPendingReturns(): Promise<{
|
||||
actor: { source: 'system' },
|
||||
})
|
||||
result.returned += 1
|
||||
recordRecycleSuccess(taskId)
|
||||
} catch (error) {
|
||||
const isOrphan = isOrphanNumberError(error)
|
||||
recordRecycleFailure(taskId, isOrphan)
|
||||
if (isOrphan && isRecycleOrphanQuotaReached(taskId)) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
if (isOrphanNumberError(error)) {
|
||||
result.orphanCount += 1
|
||||
result.orphanTaskIds.push(taskId)
|
||||
logWarn(
|
||||
'[scheduler/cloudtentacles-number-recycle]',
|
||||
'发货后退号的孤儿号码达到重试上限,已停止自动重试(需人工或上游处理)',
|
||||
{
|
||||
taskId,
|
||||
error: error instanceof Error ? error.message : String(error || ''),
|
||||
}
|
||||
'发货收尾退号无操作权限(号码已退/已回收),请人工确认任务收尾',
|
||||
{ taskId, error: message }
|
||||
)
|
||||
continue
|
||||
}
|
||||
result.failed += 1
|
||||
logWarn('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试失败', {
|
||||
taskId,
|
||||
error: error instanceof Error ? error.message : String(error || ''),
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user