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