彻底修复 lewan 自动发货问题-8-8-2
This commit is contained in:
@@ -24,6 +24,12 @@ const claimWriteRateLimit = createRateLimitMiddleware({
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
const claimRebindRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:rebind',
|
||||
windowMs: 60_000,
|
||||
max: 3,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:token',
|
||||
@@ -66,7 +72,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/rebind-role',
|
||||
claimWriteRateLimit,
|
||||
claimRebindRateLimit,
|
||||
createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
errorMessage: '换绑角色失败',
|
||||
|
||||
@@ -24,6 +24,7 @@ const listEmptyVirtualNumbers = async () => ({
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
key: '1',
|
||||
itemCount: 0,
|
||||
occupiedItemCount: 0,
|
||||
items: [],
|
||||
rawItems: [],
|
||||
})
|
||||
@@ -93,7 +94,8 @@ test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
key: '1',
|
||||
itemCount: count,
|
||||
items: Array.from({ length: count }, (_, id) => ({ id: id + 1 })),
|
||||
occupiedItemCount: count,
|
||||
items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })),
|
||||
rawItems: [],
|
||||
}
|
||||
},
|
||||
@@ -109,6 +111,67 @@ test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账
|
||||
}
|
||||
})
|
||||
|
||||
test('selectCloudtentaclesSourceForFulfillment 不把 status=0 的空闲号码计入占用', async () => {
|
||||
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
|
||||
{
|
||||
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
|
||||
listSourceLoadStats: async () => [],
|
||||
listVirtualNumbers: async (payload) => {
|
||||
const items = payload.sourceKey === 'account-a'
|
||||
? Array.from({ length: 20 }, (_, id) => ({ id: id + 1, status: 0 }))
|
||||
: Array.from({ length: 3 }, (_, id) => ({ id: id + 1, status: 1 }))
|
||||
return {
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
key: '1',
|
||||
itemCount: items.length,
|
||||
occupiedItemCount: items.filter((item) => item.status !== 0).length,
|
||||
items,
|
||||
rawItems: [],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
assert.equal(selection.sourceKey, 'account-a')
|
||||
assert.equal(selection.occupiedCount, 0)
|
||||
} finally {
|
||||
selection.release()
|
||||
}
|
||||
})
|
||||
|
||||
test('selectCloudtentaclesSourceForFulfillment 真实占用优先于数据库历史压力', async () => {
|
||||
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
|
||||
{
|
||||
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
|
||||
listSourceLoadStats: async () => [
|
||||
{ sourceKey: 'account-a', activeCount: 99, redeemingCount: 10 },
|
||||
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
|
||||
],
|
||||
listVirtualNumbers: async (payload) => {
|
||||
const count = payload.sourceKey === 'account-a' ? 5 : 6
|
||||
return {
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
key: '1',
|
||||
itemCount: count,
|
||||
occupiedItemCount: count,
|
||||
items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })),
|
||||
rawItems: [],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
assert.equal(selection.sourceKey, 'account-a')
|
||||
assert.equal(selection.occupiedCount, 5)
|
||||
} finally {
|
||||
selection.release()
|
||||
}
|
||||
})
|
||||
|
||||
test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => {
|
||||
const selection = await selectCloudtentaclesSourceForFulfillment(
|
||||
{
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
||||
sourceKey: context.resolvedSourceKey,
|
||||
accountLabel: context.accountLabel,
|
||||
})
|
||||
occupiedCount = Array.isArray(listed.items) ? listed.items.length : Number(listed.itemCount || 0) || 0
|
||||
occupiedCount = Number(listed.occupiedItemCount || 0) || 0
|
||||
} catch (error) {
|
||||
lastCapacityError = error
|
||||
continue
|
||||
@@ -120,9 +120,7 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
||||
}
|
||||
const activeCount = normalizeCount(stat.activeCount)
|
||||
const redeemingCount = normalizeCount(stat.redeemingCount)
|
||||
// Real upstream occupancy is the primary balancing signal. DB state is
|
||||
// retained as a secondary tie-breaker for tasks still being processed.
|
||||
const score = (occupiedCount + inProcessCount) * 100 + activeCount * 10 + redeemingCount * 20
|
||||
const score = occupiedCount + inProcessCount
|
||||
|
||||
ranked.push({
|
||||
context,
|
||||
@@ -141,8 +139,8 @@ export async function selectCloudtentaclesSourceForFulfillment(
|
||||
ranked.sort(
|
||||
(left, right) =>
|
||||
left.score - right.score ||
|
||||
left.activeCount - right.activeCount ||
|
||||
left.redeemingCount - right.redeemingCount ||
|
||||
left.activeCount - right.activeCount ||
|
||||
left.index - right.index ||
|
||||
left.sourceKey.localeCompare(right.sourceKey),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
verifyCloudtentaclesLoginCode,
|
||||
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
|
||||
import { logInfo } from "../../../utils/logger.js";
|
||||
|
||||
/** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */
|
||||
const APPOINT_QUOTA_COOLDOWN_MS = 60_000;
|
||||
@@ -108,6 +109,14 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb
|
||||
vnId = Number(appointed.item?.id || 0);
|
||||
vnPhone = String(appointed.item?.phone || "").trim();
|
||||
|
||||
logInfo('[kuaishou-cloud/binding]', '虚拟号申请成功', {
|
||||
sourceKey,
|
||||
accountLabel: cloudContext.accountLabel,
|
||||
purpose: String(input.purpose || 'prepare_binding'),
|
||||
vnKey,
|
||||
vnId,
|
||||
});
|
||||
|
||||
if (!vnId || !vnPhone) {
|
||||
throw createHttpError("申请虚拟号成功但返回数据不完整", {
|
||||
statusCode: 502,
|
||||
|
||||
@@ -200,6 +200,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
||||
const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
vnKeyCandidates,
|
||||
purpose: 'new_fulfillment',
|
||||
})
|
||||
const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({
|
||||
cloudContext,
|
||||
@@ -615,6 +616,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
|
||||
try {
|
||||
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
purpose: 'expired_binding_replacement',
|
||||
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
|
||||
flow,
|
||||
binding: flow.binding,
|
||||
|
||||
@@ -231,6 +231,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
|
||||
try {
|
||||
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
purpose: 'claim_rebind',
|
||||
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
|
||||
flow,
|
||||
binding: flow.binding,
|
||||
|
||||
@@ -58,11 +58,13 @@ export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {})
|
||||
})
|
||||
|
||||
const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapVirtualNumberItem) : []
|
||||
const occupiedItemCount = items.filter((item: ReturnType<typeof mapVirtualNumberItem>) => item.status !== 0).length
|
||||
|
||||
return {
|
||||
baseUrl: config.baseUrl,
|
||||
key,
|
||||
itemCount: items.length,
|
||||
occupiedItemCount,
|
||||
items,
|
||||
rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [],
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
notifyCloudtentaclesAuthExpired,
|
||||
} from '../notification/domain-notifications.js'
|
||||
import {
|
||||
recycleCloudtentaclesStaleNumbers,
|
||||
retryCloudtentaclesPendingReturns,
|
||||
} from './cloudtentacles-number-recycle-service.js'
|
||||
|
||||
@@ -35,11 +34,10 @@ type CloudtentaclesHealthAccountResult = {
|
||||
export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
||||
const accounts = resolveCloudtentaclesHealthAccounts(job)
|
||||
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
|
||||
const [results, recycleResult, returnRetryResult] = await Promise.all([
|
||||
const [results, returnRetryResult] = await Promise.all([
|
||||
Promise.all(
|
||||
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
|
||||
),
|
||||
recycleCloudtentaclesStaleNumbers(),
|
||||
retryCloudtentaclesPendingReturns(),
|
||||
])
|
||||
const checkedResults = results.filter((item) => !item.skipped)
|
||||
@@ -51,12 +49,11 @@ export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
const summary = buildCloudtentaclesHealthSummary(results)
|
||||
const status = resolveCloudtentaclesHealthStatus(results)
|
||||
const orphanTaskCount =
|
||||
Number(recycleResult?.orphanCount || 0) + Number(returnRetryResult?.orphanCount || 0)
|
||||
const orphanTaskCount = Number(returnRetryResult?.orphanCount || 0)
|
||||
const recycleNote = orphanTaskCount > 0
|
||||
? `,另有 ${orphanTaskCount} 个孤儿号码待人工/上游清理`
|
||||
: recycleResult?.refreshed || returnRetryResult?.returned
|
||||
? ',过期号码回收正常'
|
||||
: returnRetryResult?.returned
|
||||
? ',发货后退号重试正常'
|
||||
: ''
|
||||
const finalMessage = `${summary}${recycleNote}`
|
||||
|
||||
@@ -76,7 +73,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user