Files
order_site/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts
T

280 lines
8.4 KiB
TypeScript

import { createHttpError } from "../../../utils/http.js";
import { normalizeProductName } from "../product-resolution-service.js";
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
fetchCloudtentaclesVirtualNumberCode,
generateCloudtentaclesLoginCode,
getCloudtentaclesBindUrl,
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;
const appointQuotaCooldowns = new Map<string, number>();
export function resolveKuaishouCloudBindingResources(
flow: JsonObject,
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {}
) {
const normalizedSkuItems = Array.isArray(skuItems)
? skuItems.filter(isCloudSkuLikeItem)
: [];
const normalizedKnapsackItems = Array.isArray(knapsackItems)
? knapsackItems.filter(isCloudSkuLikeItem)
: [];
const currentSkuId = Number(flow?.binding?.skuId || 0) || 0;
const currentSkuName = String(flow?.binding?.skuName || "").trim();
const nameCandidates = collectKuaishouCloudNameCandidates(flow);
const skuItemById =
currentSkuId > 0
? normalizedSkuItems.find(
(item) => Number(item.id || 0) === currentSkuId
) || null
: null;
const knapsackItemById =
currentSkuId > 0
? normalizedKnapsackItems.find(
(item) => Number(item.id || 0) === currentSkuId
) || null
: null;
if (skuItemById || knapsackItemById) {
const matchedItem = skuItemById || knapsackItemById;
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: skuItemById,
knapsackItem: knapsackItemById,
resolvedByName: false,
};
}
const matchedSkuItem = findCloudItemByNames(
normalizedSkuItems,
nameCandidates
);
const matchedKnapsackItem = findCloudItemByNames(
normalizedKnapsackItems,
nameCandidates,
matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0
);
const matchedItem = matchedSkuItem || matchedKnapsackItem;
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: matchedSkuItem,
knapsackItem: matchedKnapsackItem,
resolvedByName: Boolean(matchedItem),
};
}
export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) {
return [KUAISHOU_CLOUD_FIXED_VN_KEY];
}
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
const { cloudContext = {}, vnKeyCandidates = [] } = input;
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [];
const sourceKey = String(cloudContext.resolvedSourceKey || "").trim();
let lastError = null;
for (const vnKey of candidates) {
let vnId = 0;
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 {
const appointed = await appointCloudtentaclesVirtualNumber({
...cloudContext,
key: vnKey,
});
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,
errorCode: "kuaishou_cloud_invalid_vn",
});
}
await generateCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
});
const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({
...cloudContext,
key: vnKey,
phone: vnPhone,
});
await verifyCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
code: fetchedCode.code,
});
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: vnKey,
id: vnId,
});
return {
vnKey,
vnId,
vnPhone,
bindUrl: String(bindUrlResult.bindUrl || "").trim(),
};
} catch (error) {
lastError = error;
if (isAppointQuotaExhaustedError(error)) {
appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS);
}
if (vnId > 0) {
try {
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: vnKey,
id: vnId,
});
} catch {
// 退号失败保留主错误
}
}
if (!isRecoverableKuaishouCloudVnKeyError(error)) {
throw error;
}
}
}
throw (
lastError ||
createHttpError("没有找到可用的 VN Key", {
statusCode: 409,
errorCode: "kuaishou_cloud_missing_binding_config",
})
);
}
function collectKuaishouCloudNameCandidates(flow: JsonObject) {
return Array.from(
new Set(
[
String(flow?.binding?.skuName || "").trim(),
String(flow?.internalSkuName || "").trim(),
String(flow?.internalSkuCode || "").trim(),
].filter(Boolean)
)
);
}
function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) {
const normalizedItems = Array.isArray(items) ? items : [];
const normalizedNames = nameCandidates
.map((item: string) => ({
raw: String(item || "").trim(),
normalized: normalizeProductName(item),
}))
.filter((item) => item.raw && item.normalized);
if (normalizedNames.length === 0 || normalizedItems.length === 0) {
return preferredId > 0
? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) ||
null
: null;
}
if (preferredId > 0) {
const preferred =
normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) ||
null;
if (preferred) {
return preferred;
}
}
const exactMatches = normalizedItems.filter((item: JsonObject) => {
const itemName = normalizeProductName(item.name);
return normalizedNames.some(
(candidate: { normalized: string }) => candidate.normalized === itemName
);
});
if (exactMatches.length > 0) {
return exactMatches[0];
}
const partialMatches = normalizedItems.filter((item: JsonObject) => {
const itemName = normalizeProductName(item.name);
return normalizedNames.some(
(candidate: { normalized: string }) =>
itemName.includes(candidate.normalized) ||
candidate.normalized.includes(itemName)
);
});
if (partialMatches.length > 0) {
return partialMatches.sort(
(left, right) =>
String(left.name || "").length - String(right.name || "").length
)[0];
}
return null;
}
function isCloudSkuLikeItem(item: unknown): item is JsonObject {
const current = item && typeof item === "object" ? item as JsonObject : {};
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) {
const current = error && typeof error === "object" ? error as JsonObject : {};
const errorCode = String(current.errorCode || current.code || "").trim();
const errorMessage = String(current.message || "").trim();
return (
errorCode === "cloudtentacles_vn_bind_url_failed" &&
errorMessage.includes("不支持的游戏类型")
);
}