修复 lewan 虚拟号配额被占满:过期绑定自动回收、退号失败不再追加取号、取号占满增加冷却
This commit is contained in:
@@ -10,6 +10,10 @@ import {
|
|||||||
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||||
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
|
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
|
||||||
|
|
||||||
|
/** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */
|
||||||
|
const APPOINT_QUOTA_COOLDOWN_MS = 60_000;
|
||||||
|
const appointQuotaCooldowns = new Map<string, number>();
|
||||||
|
|
||||||
export function resolveKuaishouCloudBindingResources(
|
export function resolveKuaishouCloudBindingResources(
|
||||||
flow: JsonObject,
|
flow: JsonObject,
|
||||||
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {}
|
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {}
|
||||||
@@ -77,11 +81,24 @@ export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) {
|
|||||||
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
|
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
|
||||||
const { cloudContext = {}, vnKeyCandidates = [] } = input;
|
const { cloudContext = {}, vnKeyCandidates = [] } = input;
|
||||||
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [];
|
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [];
|
||||||
|
const sourceKey = String(cloudContext.resolvedSourceKey || "").trim();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|
||||||
for (const vnKey of candidates) {
|
for (const vnKey of candidates) {
|
||||||
let vnId = 0;
|
let vnId = 0;
|
||||||
let vnPhone = "";
|
let vnPhone = "";
|
||||||
|
const cooldownKey = `${sourceKey}|${vnKey}`;
|
||||||
|
|
||||||
|
const cooldownUntil = appointQuotaCooldowns.get(cooldownKey) || 0;
|
||||||
|
if (cooldownUntil > Date.now()) {
|
||||||
|
throw createHttpError(
|
||||||
|
"账号虚拟号配额已满,请先退回已占用号码或稍后重试",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "cloudtentacles_vn_quota_cooldown",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appointed = await appointCloudtentaclesVirtualNumber({
|
const appointed = await appointCloudtentaclesVirtualNumber({
|
||||||
@@ -132,6 +149,10 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|
||||||
|
if (isAppointQuotaExhaustedError(error)) {
|
||||||
|
appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
if (vnId > 0) {
|
if (vnId > 0) {
|
||||||
try {
|
try {
|
||||||
await backCloudtentaclesVirtualNumber({
|
await backCloudtentaclesVirtualNumber({
|
||||||
@@ -229,6 +250,15 @@ function isCloudSkuLikeItem(item: unknown): item is JsonObject {
|
|||||||
return Number(current.id || 0) > 0;
|
return Number(current.id || 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAppointQuotaExhaustedError(error: unknown) {
|
||||||
|
const current = error && typeof error === "object" ? error as JsonObject : {};
|
||||||
|
return (
|
||||||
|
String(current.errorCode || current.code || "").trim() ===
|
||||||
|
"cloudtentacles_vn_appoint_failed" &&
|
||||||
|
String(current.message || "").trim().includes("最多同时占用")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecoverableKuaishouCloudVnKeyError(error: unknown) {
|
function isRecoverableKuaishouCloudVnKeyError(error: unknown) {
|
||||||
const current = error && typeof error === "object" ? error as JsonObject : {};
|
const current = error && typeof error === "object" ? error as JsonObject : {};
|
||||||
const errorCode = String(current.errorCode || current.code || "").trim();
|
const errorCode = String(current.errorCode || current.code || "").trim();
|
||||||
|
|||||||
@@ -381,7 +381,8 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
|
|||||||
const taskContext = parseTaskContext(task)
|
const taskContext = parseTaskContext(task)
|
||||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||||
const source = String(options.source || 'system').trim() || 'system'
|
const source = String(options.source || 'system').trim() || 'system'
|
||||||
const allowSkipBack = options.allowSkipBack !== false
|
// 默认退号失败即中止:避免旧号没退掉又占新号,导致账号号码配额被净泄漏
|
||||||
|
const allowSkipBack = options.allowSkipBack === true
|
||||||
const preferReuseVn = options.preferReuseVn !== false
|
const preferReuseVn = options.preferReuseVn !== false
|
||||||
|
|
||||||
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
|
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
notifyCloudtentaclesAssetLow,
|
notifyCloudtentaclesAssetLow,
|
||||||
notifyCloudtentaclesAuthExpired,
|
notifyCloudtentaclesAuthExpired,
|
||||||
} from '../notification/domain-notifications.js'
|
} from '../notification/domain-notifications.js'
|
||||||
|
import {
|
||||||
|
recycleCloudtentaclesStaleNumbers,
|
||||||
|
retryCloudtentaclesPendingReturns,
|
||||||
|
} from './cloudtentacles-number-recycle-service.js'
|
||||||
|
|
||||||
type CloudtentaclesHealthAccountResult = {
|
type CloudtentaclesHealthAccountResult = {
|
||||||
sourceKey: string
|
sourceKey: string
|
||||||
@@ -31,9 +35,13 @@ type CloudtentaclesHealthAccountResult = {
|
|||||||
export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
||||||
const accounts = resolveCloudtentaclesHealthAccounts(job)
|
const accounts = resolveCloudtentaclesHealthAccounts(job)
|
||||||
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
|
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
|
||||||
const results = await Promise.all(
|
const [results, recycleResult, returnRetryResult] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
|
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
|
||||||
)
|
),
|
||||||
|
recycleCloudtentaclesStaleNumbers(),
|
||||||
|
retryCloudtentaclesPendingReturns(),
|
||||||
|
])
|
||||||
const checkedResults = results.filter((item) => !item.skipped)
|
const checkedResults = results.filter((item) => !item.skipped)
|
||||||
const failedResults = checkedResults.filter((item) => item.ok === false)
|
const failedResults = checkedResults.filter((item) => item.ok === false)
|
||||||
const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low')
|
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)))
|
? Math.max(...checkedResults.map((item) => Number(item.threshold || 0)))
|
||||||
: normalizeNonNegativeInteger(job.config?.assetThreshold, 500),
|
: normalizeNonNegativeInteger(job.config?.assetThreshold, 500),
|
||||||
accounts: results,
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user