280 lines
8.4 KiB
TypeScript
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("不支持的游戏类型")
|
|
);
|
|
}
|