修复 lewan 虚拟号配额被占满:过期绑定自动回收、退号失败不再追加取号、取号占满增加冷却

This commit is contained in:
yml2213
2026-08-08 02:15:13 +08:00
parent 84a4c6c627
commit 995de86322
4 changed files with 200 additions and 4 deletions
@@ -13,6 +13,10 @@ import {
notifyCloudtentaclesAssetLow,
notifyCloudtentaclesAuthExpired,
} from '../notification/domain-notifications.js'
import {
recycleCloudtentaclesStaleNumbers,
retryCloudtentaclesPendingReturns,
} from './cloudtentacles-number-recycle-service.js'
type CloudtentaclesHealthAccountResult = {
sourceKey: string
@@ -31,9 +35,13 @@ type CloudtentaclesHealthAccountResult = {
export async function runCloudtentaclesHealthJob(job: JsonObject) {
const accounts = resolveCloudtentaclesHealthAccounts(job)
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
const results = await Promise.all(
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
)
const [results, recycleResult, returnRetryResult] = await Promise.all([
Promise.all(
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
),
recycleCloudtentaclesStaleNumbers(),
retryCloudtentaclesPendingReturns(),
])
const checkedResults = results.filter((item) => !item.skipped)
const failedResults = checkedResults.filter((item) => item.ok === false)
const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low')
@@ -60,6 +68,8 @@ export async function runCloudtentaclesHealthJob(job: JsonObject) {
? Math.max(...checkedResults.map((item) => Number(item.threshold || 0)))
: normalizeNonNegativeInteger(job.config?.assetThreshold, 500),
accounts: results,
numberRecycle: recycleResult,
pendingReturnRetry: returnRetryResult,
}
}
@@ -0,0 +1,155 @@
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 type { TaskRow } from '../../types/repository/rows.js'
import {
isKuaishouCloudBindingMutationFrozen,
isKuaishouCloudTask,
normalizeKuaishouCloudFlow,
} from '../fulfillment/kuaishou-cloud/domain.js'
import { refreshKuaishouCloudTaskBindUrl } from '../fulfillment/kuaishou-cloud/prepare-fulfillment.js'
import { returnKuaishouCloudFulfillmentTask } from '../fulfillment/kuaishou-cloud/return-fulfillment.js'
/** bindUrl 过期超过该时长才回收(避免刚过期就触发上游操作) */
const STALE_BIND_URL_GRACE_MS = 15 * 60 * 1000
/** 单轮最多处理的任务数(health 每 2.5 分钟跑一次,防止一次扫太多) */
const RECYCLE_SCAN_LIMIT = 50
type RecycleNumberResult = {
scanned: number
skippedFrozen: number
skippedMissingContext: number
refreshed: number
failed: number
}
export async function recycleCloudtentaclesStaleNumbers(): Promise<RecycleNumberResult> {
const staleBeforeIso = new Date(Date.now() - STALE_BIND_URL_GRACE_MS).toISOString()
const result: RecycleNumberResult = {
scanned: 0,
skippedFrozen: 0,
skippedMissingContext: 0,
refreshed: 0,
failed: 0,
}
const rows = await dbQuery<TaskRow>(
`
SELECT *
FROM fulfillment_tasks ft
WHERE ft.executor_key = 'kuaishou_ct_assisted'
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,prepareStatus}', '') = 'ready'
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}', '') <> ''
AND (ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}')::timestamptz < $1
ORDER BY ft.id DESC
LIMIT $2
`,
[staleBeforeIso, RECYCLE_SCAN_LIMIT]
)
for (const task of rows.rows) {
result.scanned += 1
try {
if (!isKuaishouCloudTask(task)) {
result.skippedMissingContext += 1
continue
}
const flow = normalizeKuaishouCloudFlow(
parseJsonObject(task.context_json).kuaishouCloudFulfillment
)
// 已发货/兑换中:禁止换号覆盖,跳过
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
result.skippedFrozen += 1
continue
}
if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.bindUrl) {
result.skippedMissingContext += 1
continue
}
await refreshKuaishouCloudTaskBindUrl(task, {
source: 'scheduler_stale_number_recycle',
actor: { source: 'system' },
})
result.refreshed += 1
} catch (error) {
result.failed += 1
logWarn('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收失败', {
taskId: Number(task.id || 0),
error: error instanceof Error ? error.message : String(error || ''),
})
}
}
if (result.scanned > 0) {
logInfo('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收完成', {
...result,
taskIds: rows.rows.map((row) => Number(row.id || 0)),
})
}
return result
}
/** 重试发货后未完成的退号(DISPATCHED_PENDING_RETURN 且未退号成功) */
export async function retryCloudtentaclesPendingReturns(): Promise<{
scanned: number
returned: number
failed: number
}> {
const result = { scanned: 0, returned: 0, failed: 0 }
const rows = await dbQuery<TaskRow>(
`
SELECT *
FROM fulfillment_tasks ft
WHERE ft.executor_key = 'kuaishou_ct_assisted'
AND ft.task_status = $1
AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,returnNumber,status}', '') <> 'success'
ORDER BY ft.id DESC
LIMIT $2
`,
[TASK_STATUS.DISPATCHED_PENDING_RETURN, RECYCLE_SCAN_LIMIT]
)
for (const task of rows.rows) {
result.scanned += 1
try {
if (!isKuaishouCloudTask(task)) {
result.failed += 1
continue
}
const flow = normalizeKuaishouCloudFlow(
parseJsonObject(task.context_json).kuaishouCloudFulfillment
)
if (!flow.binding.vnId || !flow.binding.vnKey) {
result.failed += 1
continue
}
await returnKuaishouCloudFulfillmentTask(task, {
source: 'scheduler_retry_return_number',
actor: { source: 'system' },
})
result.returned += 1
} catch (error) {
result.failed += 1
logWarn('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试失败', {
taskId: Number(task.id || 0),
error: error instanceof Error ? error.message : String(error || ''),
})
}
}
if (result.scanned > 0) {
logInfo('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试完成', {
...result,
taskIds: rows.rows.map((row) => Number(row.id || 0)),
})
}
return result
}