彻底修复 lewan 自动发货问题-8-8-2

This commit is contained in:
yml2213
2026-08-08 23:43:29 +08:00
parent 6e41d7e0db
commit bcde47ef97
9 changed files with 93 additions and 15 deletions
+7 -1
View File
@@ -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,
}
}
@@ -117,6 +117,7 @@ export interface AdminCloudtentaclesSkuItem {
export interface AdminCloudtentaclesSkuListResult {
baseUrl: string
itemCount: number
occupiedItemCount: number
items: AdminCloudtentaclesSkuItem[]
rawItems: Record<string, unknown>[]
}