彻底修复 lewan 自动发货问题-8-8-2
This commit is contained in:
@@ -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>[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user