feat: 增加多账号备选退避机制支持 cloudSourceKeyFallbacks

- kuaishou-cloud-fulfillment-config-service: 新增 cloudSourceKeyFallbacks 字段和 normalizeStringArray 辅助函数
- kuaishou-cloud-task-service: 新增 resolvePersistedCloudtentaclesContextWithFallback 函数实现备选账号自动切换
- 改造 5 个调用点(prepare/refresh/roleRefresh/dispatch/return)使用 fallback 机制
- binding 中新增 resolvedSourceKey 字段持久化首次选定的 sourceKey
- admin-write-inputs: 类型定义增加 cloudSourceKeyFallbacks 可选字段
- 向后兼容:旧数据无 fallbacks 字段时等同于旧行为
This commit is contained in:
yml
2026-05-20 01:04:04 +08:00
parent 1ef4db7f45
commit 6c29726e3f
3 changed files with 230 additions and 85 deletions
@@ -101,6 +101,10 @@ export function normalizeKuaishouCloudFlow(value) {
String(binding.prepareStatus || "pending").trim() || "pending", String(binding.prepareStatus || "pending").trim() || "pending",
cloudSourceKey: cloudSourceKey:
String(binding.cloudSourceKey || "default").trim() || "default", String(binding.cloudSourceKey || "default").trim() || "default",
cloudSourceKeyFallbacks: normalizeStringArray(
binding.cloudSourceKeyFallbacks
),
resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(),
skuId: Number(binding.skuId || 0) || 0, skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || "").trim(), skuName: String(binding.skuName || "").trim(),
vnKey: vnKey:
@@ -219,6 +223,66 @@ export function resolvePersistedCloudtentaclesContext(sourceKey = "default") {
}; };
} }
/**
* 按优先级依次尝试 cloudSourceKey 和 fallbacks 列表中的账号,
* 找到第一个有可用 token 的账号返回其 context。
* 将实际使用的 resolvedSourceKey 也返回,确保后续操作(退号、发货等)
* 使用同一个 sourceKey,防止跨账号操作导致数据不一致。
*/
export function resolvePersistedCloudtentaclesContextWithFallback(
primarySourceKey = "default",
fallbacks = []
) {
const candidates = [
String(primarySourceKey || "default").trim() || "default",
...normalizeStringArray(fallbacks),
];
// 去重但保持顺序
const uniqueCandidates = [...new Set(candidates)];
let lastError = null;
for (const sourceKey of uniqueCandidates) {
const source = getCloudtentaclesSourceByKey(sourceKey);
const session = getCloudtentaclesSessionStateByKey(sourceKey);
// 该账号不存在配置 → 跳过
if (!source) continue;
// 该账号没有 token → 标记跳过但不报错
const token = String(session?.token || "").trim();
if (!token) {
lastError = createHttpError(
`cloudtentacles 账号 ${sourceKey} 没有可用 token`,
{ statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" }
);
continue;
}
// 找到可用账号
return {
baseUrl:
String(session.baseUrl || source.baseUrl || "").trim() ||
"https://123.207.217.176",
token,
deviceId:
String(session.deviceId || source.deviceId || "-").trim() || "-",
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
resolvedSourceKey: sourceKey,
};
}
// 所有备选都不可用
throw (
lastError ||
createHttpError(
"所有 cloudtentacles 备选账号均不可用,请先到平台配置完成登录校验",
{ statusCode: 409, errorCode: "kuaishou_cloud_all_source_keys_exhausted" }
)
);
}
export async function ensureTaskClaimLink(task) { export async function ensureTaskClaimLink(task) {
const tokenStatus = String(task?.primary_claim_token_status || "").trim(); const tokenStatus = String(task?.primary_claim_token_status || "").trim();
const token = String( const token = String(
@@ -292,8 +356,9 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
}; };
} }
const cloudContext = resolvePersistedCloudtentaclesContext( const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
flow.binding.cloudSourceKey || "default" flow.binding.cloudSourceKey || "default",
flow.binding.cloudSourceKeyFallbacks || []
); );
const [knapsack, skuList] = await Promise.all([ const [knapsack, skuList] = await Promise.all([
getCloudtentaclesKnapsack(cloudContext), getCloudtentaclesKnapsack(cloudContext),
@@ -395,6 +460,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
...flowWithResolvedBinding, ...flowWithResolvedBinding,
binding: { binding: {
...flowWithResolvedBinding.binding, ...flowWithResolvedBinding.binding,
resolvedSourceKey: cloudContext.resolvedSourceKey,
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
prepareStatus: "ready", prepareStatus: "ready",
vnId: preparedBinding.vnId, vnId: preparedBinding.vnId,
@@ -488,8 +554,11 @@ export async function refreshKuaishouCloudTaskBindUrl(task, options = {}) {
}); });
} }
const cloudContext = resolvePersistedCloudtentaclesContext( const effectiveSourceKey =
flow.binding.cloudSourceKey || "default" flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
effectiveSourceKey,
flow.binding.cloudSourceKeyFallbacks || []
); );
const oldVnKey = flow.binding.vnKey; const oldVnKey = flow.binding.vnKey;
const oldVnId = flow.binding.vnId; const oldVnId = flow.binding.vnId;
@@ -851,8 +920,11 @@ export async function refreshKuaishouCloudTaskRoleInfo(task, options = {}) {
} }
} }
const cloudContext = resolvePersistedCloudtentaclesContext( const effectiveSourceKey =
flow.binding.cloudSourceKey || "default" flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
effectiveSourceKey,
flow.binding.cloudSourceKeyFallbacks || []
); );
const bindInfoResult = await getCloudtentaclesBindInfo({ const bindInfoResult = await getCloudtentaclesBindInfo({
...cloudContext, ...cloudContext,
@@ -925,8 +997,11 @@ export async function dispatchKuaishouCloudFulfillmentTask(task, options = {}) {
const now = nowIso(); const now = nowIso();
const taskContext = parseTaskContext(task); const taskContext = parseTaskContext(task);
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
const cloudContext = resolvePersistedCloudtentaclesContext( const effectiveSourceKey =
flow.binding.cloudSourceKey || "default" flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
effectiveSourceKey,
flow.binding.cloudSourceKeyFallbacks || []
); );
if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) {
@@ -1054,8 +1129,11 @@ export async function returnKuaishouCloudFulfillmentTask(task, options = {}) {
const now = nowIso(); const now = nowIso();
const taskContext = parseTaskContext(task); const taskContext = parseTaskContext(task);
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
const cloudContext = resolvePersistedCloudtentaclesContext( const effectiveSourceKey =
flow.binding.cloudSourceKey || "default" flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
effectiveSourceKey,
flow.binding.cloudSourceKeyFallbacks || []
); );
if (!flow.binding.vnId || !flow.binding.vnKey) { if (!flow.binding.vnId || !flow.binding.vnKey) {
@@ -1561,3 +1639,16 @@ function isClaimExpired(expiredAt) {
const timestamp = new Date(expiredAt).getTime(); const timestamp = new Date(expiredAt).getTime();
return Number.isFinite(timestamp) && timestamp <= Date.now(); return Number.isFinite(timestamp) && timestamp <= Date.now();
} }
function normalizeStringArray(value) {
if (Array.isArray(value)) {
return value.map((v) => String(v || "").trim()).filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
return [];
}
@@ -1,174 +1,227 @@
import fs from 'node:fs' import fs from "node:fs";
import path from 'node:path' import path from "node:path";
import { PROJECT_ROOT } from '../../config/runtime.js' import { PROJECT_ROOT } from "../../config/runtime.js";
import { resolveKuaishouEticketShopConfig } from '../platforms/kuaishou-eticket/source-config-service.js' import { resolveKuaishouEticketShopConfig } from "../platforms/kuaishou-eticket/source-config-service.js";
const KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-cloud-fulfillment.json') const KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH = path.join(
PROJECT_ROOT,
"data",
"kuaishou-cloud-fulfillment.json"
);
export function getKuaishouCloudFulfillmentFilePath() { export function getKuaishouCloudFulfillmentFilePath() {
return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH;
} }
export function getKuaishouCloudFulfillmentConfig() { export function getKuaishouCloudFulfillmentConfig() {
return loadKuaishouCloudFulfillmentConfigFromFile() return loadKuaishouCloudFulfillmentConfigFromFile();
} }
export function saveKuaishouCloudFulfillmentConfig(rawValue) { export function saveKuaishouCloudFulfillmentConfig(rawValue) {
const normalized = normalizeKuaishouCloudFulfillmentConfig(rawValue) const normalized = normalizeKuaishouCloudFulfillmentConfig(rawValue);
fs.mkdirSync(path.dirname(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH), { recursive: true }) fs.mkdirSync(path.dirname(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH), {
recursive: true,
});
fs.writeFileSync( fs.writeFileSync(
KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH,
`${JSON.stringify(normalized, null, 2)}\n`, `${JSON.stringify(normalized, null, 2)}\n`,
'utf8', "utf8"
) );
return normalized return normalized;
} }
export function mapKuaishouCloudFulfillmentItemsToBindings(config = {}) { export function mapKuaishouCloudFulfillmentItemsToBindings(config = {}) {
const items = Array.isArray(config.items) ? config.items : [] const items = Array.isArray(config.items) ? config.items : [];
return items return items
.filter((item) => item && item.enabled !== false) .filter((item) => item && item.enabled !== false)
.map((item) => ({ .map((item) => ({
provider: String(item.provider || '91kaquan').trim() || '91kaquan', provider: String(item.provider || "91kaquan").trim() || "91kaquan",
platform: String(item.platform || 'kuaishou').trim() || 'kuaishou', platform: String(item.platform || "kuaishou").trim() || "kuaishou",
shopId: String(item.shopId || '').trim(), shopId: String(item.shopId || "").trim(),
skuCode: String(item.internalSkuCode || '').trim(), skuCode: String(item.internalSkuCode || "").trim(),
skuName: String(item.internalSkuName || '').trim(), skuName: String(item.internalSkuName || "").trim(),
profileKey: 'kuaishou_ct_assisted', profileKey: "kuaishou_ct_assisted",
enabled: item.enabled !== false, enabled: item.enabled !== false,
priority: normalizePriority(item.priority), priority: normalizePriority(item.priority),
config: { config: {
flowType: 'kuaishou_cloud_fulfillment', flowType: "kuaishou_cloud_fulfillment",
configId: String(item.id || '').trim(), configId: String(item.id || "").trim(),
cloudtentacles: { cloudtentacles: {
cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default', cloudSourceKey:
String(item.cloudSourceKey || "default").trim() || "default",
cloudSourceKeyFallbacks: Array.isArray(item.cloudSourceKeyFallbacks)
? item.cloudSourceKeyFallbacks
: [],
skuId: normalizePositiveInteger(item.cloudSkuId), skuId: normalizePositiveInteger(item.cloudSkuId),
skuName: String(item.cloudSkuName || '').trim(), skuName: String(item.cloudSkuName || "").trim(),
vnKey: '1', vnKey: "1",
autoBuyEnabled: item.autoBuyEnabled !== false, autoBuyEnabled: item.autoBuyEnabled !== false,
minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0),
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, autoReturnNumberAfterDispatch:
item.autoReturnNumberAfterDispatch === true,
}, },
kuaishouConsume: { kuaishouConsume: {
shopId: String(item.kuaishouConsumeShopId || '').trim(), shopId: String(item.kuaishouConsumeShopId || "").trim(),
shopName: String(item.kuaishouConsumeShopName || '').trim(), shopName: String(item.kuaishouConsumeShopName || "").trim(),
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true,
}, },
notes: String(item.notes || '').trim(), notes: String(item.notes || "").trim(),
}, },
match: { match: {
externalSkuCode: String(item.externalSkuCode || '').trim(), externalSkuCode: String(item.externalSkuCode || "").trim(),
externalItemId: String(item.externalItemId || '').trim(), externalItemId: String(item.externalItemId || "").trim(),
externalSkuName: String(item.externalSkuName || '').trim(), externalSkuName: String(item.externalSkuName || "").trim(),
config: { config: {
resolvedSkuName: String(item.resolvedSkuName || item.internalSkuName || '').trim(), resolvedSkuName: String(
item.resolvedSkuName || item.internalSkuName || ""
).trim(),
}, },
}, },
})) }))
.filter((item) => item.skuCode && (item.match.externalSkuCode || item.match.externalItemId || item.match.externalSkuName)) .filter(
(item) =>
item.skuCode &&
(item.match.externalSkuCode ||
item.match.externalItemId ||
item.match.externalSkuName)
);
} }
function loadKuaishouCloudFulfillmentConfigFromFile() { function loadKuaishouCloudFulfillmentConfigFromFile() {
if (!fs.existsSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH)) { if (!fs.existsSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH)) {
return normalizeKuaishouCloudFulfillmentConfig({}) return normalizeKuaishouCloudFulfillmentConfig({});
} }
try { try {
const rawText = fs.readFileSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, 'utf8') const rawText = fs.readFileSync(
const parsed = JSON.parse(rawText) KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH,
return normalizeKuaishouCloudFulfillmentConfig(parsed) "utf8"
);
const parsed = JSON.parse(rawText);
return normalizeKuaishouCloudFulfillmentConfig(parsed);
} catch { } catch {
return normalizeKuaishouCloudFulfillmentConfig({}) return normalizeKuaishouCloudFulfillmentConfig({});
} }
} }
function normalizeKuaishouCloudFulfillmentConfig(rawValue) { function normalizeKuaishouCloudFulfillmentConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {} const source = isPlainObject(rawValue) ? rawValue : {};
return { return {
enabled: source.enabled !== false, enabled: source.enabled !== false,
items: Array.isArray(source.items) items: Array.isArray(source.items)
? source.items.map((item) => normalizeKuaishouCloudFulfillmentItem(item)).filter(Boolean) ? source.items
.map((item) => normalizeKuaishouCloudFulfillmentItem(item))
.filter(Boolean)
: [], : [],
} };
} }
function normalizeKuaishouCloudFulfillmentItem(rawValue) { function normalizeKuaishouCloudFulfillmentItem(rawValue) {
if (!isPlainObject(rawValue)) { if (!isPlainObject(rawValue)) {
return null return null;
} }
const internalSkuCode = String(rawValue.internalSkuCode || '').trim() const internalSkuCode = String(rawValue.internalSkuCode || "").trim();
const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId) const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId);
const externalSkuCode = String(rawValue.externalSkuCode || '').trim() const externalSkuCode = String(rawValue.externalSkuCode || "").trim();
const externalItemId = String(rawValue.externalItemId || '').trim() const externalItemId = String(rawValue.externalItemId || "").trim();
const externalSkuName = String(rawValue.externalSkuName || '').trim() const externalSkuName = String(rawValue.externalSkuName || "").trim();
const kuaishouConsumeShopId = String(rawValue.kuaishouConsumeShopId || '').trim() const kuaishouConsumeShopId = String(
const kuaishouConsumeShopName = String(rawValue.kuaishouConsumeShopName || '').trim() rawValue.kuaishouConsumeShopId || ""
).trim();
const kuaishouConsumeShopName = String(
rawValue.kuaishouConsumeShopName || ""
).trim();
const kuaishouShopConfig = resolveKuaishouEticketShopConfig({ const kuaishouShopConfig = resolveKuaishouEticketShopConfig({
shopId: kuaishouConsumeShopId, shopId: kuaishouConsumeShopId,
shopName: kuaishouConsumeShopName, shopName: kuaishouConsumeShopName,
}) });
if (!internalSkuCode) { if (!internalSkuCode) {
return null return null;
} }
if (!cloudSkuId) { if (!cloudSkuId) {
return null return null;
} }
if (!externalSkuCode && !externalItemId && !externalSkuName) { if (!externalSkuCode && !externalItemId && !externalSkuName) {
return null return null;
} }
return { return {
id: String(rawValue.id || internalSkuCode).trim() || internalSkuCode, id: String(rawValue.id || internalSkuCode).trim() || internalSkuCode,
enabled: rawValue.enabled !== false, enabled: rawValue.enabled !== false,
priority: normalizePriority(rawValue.priority), priority: normalizePriority(rawValue.priority),
provider: String(rawValue.provider || '91kaquan').trim() || '91kaquan', provider: String(rawValue.provider || "91kaquan").trim() || "91kaquan",
platform: String(rawValue.platform || 'kuaishou').trim() || 'kuaishou', platform: String(rawValue.platform || "kuaishou").trim() || "kuaishou",
shopId: String(rawValue.shopId || '').trim(), shopId: String(rawValue.shopId || "").trim(),
internalSkuCode, internalSkuCode,
internalSkuName: String(rawValue.internalSkuName || '').trim() || internalSkuCode, internalSkuName:
String(rawValue.internalSkuName || "").trim() || internalSkuCode,
externalSkuCode, externalSkuCode,
externalItemId, externalItemId,
externalSkuName, externalSkuName,
resolvedSkuName: String(rawValue.resolvedSkuName || rawValue.internalSkuName || '').trim(), resolvedSkuName: String(
cloudSourceKey: String(rawValue.cloudSourceKey || 'default').trim() || 'default', rawValue.resolvedSkuName || rawValue.internalSkuName || ""
).trim(),
cloudSourceKey:
String(rawValue.cloudSourceKey || "default").trim() || "default",
cloudSourceKeyFallbacks: normalizeStringArray(
rawValue.cloudSourceKeyFallbacks
),
cloudSkuId, cloudSkuId,
cloudSkuName: String(rawValue.cloudSkuName || '').trim(), cloudSkuName: String(rawValue.cloudSkuName || "").trim(),
vnKey: '1', vnKey: "1",
autoBuyEnabled: rawValue.autoBuyEnabled !== false, autoBuyEnabled: rawValue.autoBuyEnabled !== false,
minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0),
autoReturnNumberAfterDispatch: rawValue.autoReturnNumberAfterDispatch === true, autoReturnNumberAfterDispatch:
rawValue.autoReturnNumberAfterDispatch === true,
autoConsumeAfterDispatch: rawValue.autoConsumeAfterDispatch === true, autoConsumeAfterDispatch: rawValue.autoConsumeAfterDispatch === true,
kuaishouConsumeShopId: String(kuaishouShopConfig?.shopId || kuaishouConsumeShopId).trim(), kuaishouConsumeShopId: String(
kuaishouConsumeShopName: String(kuaishouShopConfig?.kshopName || kuaishouConsumeShopName).trim(), kuaishouShopConfig?.shopId || kuaishouConsumeShopId
notes: String(rawValue.notes || '').trim(), ).trim(),
} kuaishouConsumeShopName: String(
kuaishouShopConfig?.kshopName || kuaishouConsumeShopName
).trim(),
notes: String(rawValue.notes || "").trim(),
};
} }
function normalizePositiveInteger(value) { function normalizePositiveInteger(value) {
const parsed = Number(value) const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0 return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
} }
function normalizeNonNegativeInteger(value, fallback) { function normalizeNonNegativeInteger(value, fallback) {
const parsed = Number(value) const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
} }
function normalizePriority(value) { function normalizePriority(value) {
const parsed = Number(value) const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
return 100 return 100;
} }
return Math.max(1, Math.round(parsed)) return Math.max(1, Math.round(parsed));
} }
function isPlainObject(value) { function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]' return Object.prototype.toString.call(value) === "[object Object]";
}
function normalizeStringArray(value) {
if (Array.isArray(value)) {
return value.map((v) => String(v || "").trim()).filter(Boolean);
}
// 兼容旧格式:字符串用逗号分隔
if (typeof value === "string") {
return value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
return [];
} }
@@ -82,6 +82,7 @@ export {};
* externalSkuName?: string * externalSkuName?: string
* resolvedSkuName?: string * resolvedSkuName?: string
* cloudSourceKey?: string * cloudSourceKey?: string
* cloudSourceKeyFallbacks?: string[]
* cloudSkuId?: number | string * cloudSkuId?: number | string
* cloudSkuName?: string * cloudSkuName?: string
* vnKey?: string * vnKey?: string