diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js index c2e4943c..f328f402 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js @@ -101,6 +101,10 @@ export function normalizeKuaishouCloudFlow(value) { String(binding.prepareStatus || "pending").trim() || "pending", cloudSourceKey: String(binding.cloudSourceKey || "default").trim() || "default", + cloudSourceKeyFallbacks: normalizeStringArray( + binding.cloudSourceKeyFallbacks + ), + resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(), skuId: Number(binding.skuId || 0) || 0, skuName: String(binding.skuName || "").trim(), 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) { const tokenStatus = String(task?.primary_claim_token_status || "").trim(); const token = String( @@ -292,8 +356,9 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) { }; } - const cloudContext = resolvePersistedCloudtentaclesContext( - flow.binding.cloudSourceKey || "default" + const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( + flow.binding.cloudSourceKey || "default", + flow.binding.cloudSourceKeyFallbacks || [] ); const [knapsack, skuList] = await Promise.all([ getCloudtentaclesKnapsack(cloudContext), @@ -395,6 +460,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) { ...flowWithResolvedBinding, binding: { ...flowWithResolvedBinding.binding, + resolvedSourceKey: cloudContext.resolvedSourceKey, vnKey: preparedBinding.vnKey, prepareStatus: "ready", vnId: preparedBinding.vnId, @@ -488,8 +554,11 @@ export async function refreshKuaishouCloudTaskBindUrl(task, options = {}) { }); } - const cloudContext = resolvePersistedCloudtentaclesContext( - flow.binding.cloudSourceKey || "default" + const effectiveSourceKey = + flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; + const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( + effectiveSourceKey, + flow.binding.cloudSourceKeyFallbacks || [] ); const oldVnKey = flow.binding.vnKey; const oldVnId = flow.binding.vnId; @@ -851,8 +920,11 @@ export async function refreshKuaishouCloudTaskRoleInfo(task, options = {}) { } } - const cloudContext = resolvePersistedCloudtentaclesContext( - flow.binding.cloudSourceKey || "default" + const effectiveSourceKey = + flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; + const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( + effectiveSourceKey, + flow.binding.cloudSourceKeyFallbacks || [] ); const bindInfoResult = await getCloudtentaclesBindInfo({ ...cloudContext, @@ -925,8 +997,11 @@ export async function dispatchKuaishouCloudFulfillmentTask(task, options = {}) { const now = nowIso(); const taskContext = parseTaskContext(task); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - const cloudContext = resolvePersistedCloudtentaclesContext( - flow.binding.cloudSourceKey || "default" + const effectiveSourceKey = + flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; + const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( + effectiveSourceKey, + flow.binding.cloudSourceKeyFallbacks || [] ); if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { @@ -1054,8 +1129,11 @@ export async function returnKuaishouCloudFulfillmentTask(task, options = {}) { const now = nowIso(); const taskContext = parseTaskContext(task); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - const cloudContext = resolvePersistedCloudtentaclesContext( - flow.binding.cloudSourceKey || "default" + const effectiveSourceKey = + flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; + const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( + effectiveSourceKey, + flow.binding.cloudSourceKeyFallbacks || [] ); if (!flow.binding.vnId || !flow.binding.vnKey) { @@ -1561,3 +1639,16 @@ function isClaimExpired(expiredAt) { const timestamp = new Date(expiredAt).getTime(); 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 []; +} diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js index 07bafb4b..06c61173 100644 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js @@ -1,174 +1,227 @@ -import fs from 'node:fs' -import path from 'node:path' +import fs from "node:fs"; +import path from "node:path"; -import { PROJECT_ROOT } from '../../config/runtime.js' -import { resolveKuaishouEticketShopConfig } from '../platforms/kuaishou-eticket/source-config-service.js' +import { PROJECT_ROOT } from "../../config/runtime.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() { - return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH + return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH; } export function getKuaishouCloudFulfillmentConfig() { - return loadKuaishouCloudFulfillmentConfigFromFile() + return loadKuaishouCloudFulfillmentConfigFromFile(); } export function saveKuaishouCloudFulfillmentConfig(rawValue) { - const normalized = normalizeKuaishouCloudFulfillmentConfig(rawValue) - fs.mkdirSync(path.dirname(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH), { recursive: true }) + const normalized = normalizeKuaishouCloudFulfillmentConfig(rawValue); + fs.mkdirSync(path.dirname(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH), { + recursive: true, + }); fs.writeFileSync( KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, - 'utf8', - ) - return normalized + "utf8" + ); + return normalized; } export function mapKuaishouCloudFulfillmentItemsToBindings(config = {}) { - const items = Array.isArray(config.items) ? config.items : [] + const items = Array.isArray(config.items) ? config.items : []; return items .filter((item) => item && item.enabled !== false) .map((item) => ({ - provider: String(item.provider || '91kaquan').trim() || '91kaquan', - platform: String(item.platform || 'kuaishou').trim() || 'kuaishou', - shopId: String(item.shopId || '').trim(), - skuCode: String(item.internalSkuCode || '').trim(), - skuName: String(item.internalSkuName || '').trim(), - profileKey: 'kuaishou_ct_assisted', + provider: String(item.provider || "91kaquan").trim() || "91kaquan", + platform: String(item.platform || "kuaishou").trim() || "kuaishou", + shopId: String(item.shopId || "").trim(), + skuCode: String(item.internalSkuCode || "").trim(), + skuName: String(item.internalSkuName || "").trim(), + profileKey: "kuaishou_ct_assisted", enabled: item.enabled !== false, priority: normalizePriority(item.priority), config: { - flowType: 'kuaishou_cloud_fulfillment', - configId: String(item.id || '').trim(), + flowType: "kuaishou_cloud_fulfillment", + configId: String(item.id || "").trim(), 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), - skuName: String(item.cloudSkuName || '').trim(), - vnKey: '1', + skuName: String(item.cloudSkuName || "").trim(), + vnKey: "1", autoBuyEnabled: item.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), - autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, + autoReturnNumberAfterDispatch: + item.autoReturnNumberAfterDispatch === true, }, kuaishouConsume: { - shopId: String(item.kuaishouConsumeShopId || '').trim(), - shopName: String(item.kuaishouConsumeShopName || '').trim(), + shopId: String(item.kuaishouConsumeShopId || "").trim(), + shopName: String(item.kuaishouConsumeShopName || "").trim(), autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, }, - notes: String(item.notes || '').trim(), + notes: String(item.notes || "").trim(), }, match: { - externalSkuCode: String(item.externalSkuCode || '').trim(), - externalItemId: String(item.externalItemId || '').trim(), - externalSkuName: String(item.externalSkuName || '').trim(), + externalSkuCode: String(item.externalSkuCode || "").trim(), + externalItemId: String(item.externalItemId || "").trim(), + externalSkuName: String(item.externalSkuName || "").trim(), 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() { if (!fs.existsSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH)) { - return normalizeKuaishouCloudFulfillmentConfig({}) + return normalizeKuaishouCloudFulfillmentConfig({}); } try { - const rawText = fs.readFileSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, 'utf8') - const parsed = JSON.parse(rawText) - return normalizeKuaishouCloudFulfillmentConfig(parsed) + const rawText = fs.readFileSync( + KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, + "utf8" + ); + const parsed = JSON.parse(rawText); + return normalizeKuaishouCloudFulfillmentConfig(parsed); } catch { - return normalizeKuaishouCloudFulfillmentConfig({}) + return normalizeKuaishouCloudFulfillmentConfig({}); } } function normalizeKuaishouCloudFulfillmentConfig(rawValue) { - const source = isPlainObject(rawValue) ? rawValue : {} + const source = isPlainObject(rawValue) ? rawValue : {}; return { enabled: source.enabled !== false, items: Array.isArray(source.items) - ? source.items.map((item) => normalizeKuaishouCloudFulfillmentItem(item)).filter(Boolean) + ? source.items + .map((item) => normalizeKuaishouCloudFulfillmentItem(item)) + .filter(Boolean) : [], - } + }; } function normalizeKuaishouCloudFulfillmentItem(rawValue) { if (!isPlainObject(rawValue)) { - return null + return null; } - const internalSkuCode = String(rawValue.internalSkuCode || '').trim() - const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId) - const externalSkuCode = String(rawValue.externalSkuCode || '').trim() - const externalItemId = String(rawValue.externalItemId || '').trim() - const externalSkuName = String(rawValue.externalSkuName || '').trim() - const kuaishouConsumeShopId = String(rawValue.kuaishouConsumeShopId || '').trim() - const kuaishouConsumeShopName = String(rawValue.kuaishouConsumeShopName || '').trim() + const internalSkuCode = String(rawValue.internalSkuCode || "").trim(); + const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId); + const externalSkuCode = String(rawValue.externalSkuCode || "").trim(); + const externalItemId = String(rawValue.externalItemId || "").trim(); + const externalSkuName = String(rawValue.externalSkuName || "").trim(); + const kuaishouConsumeShopId = String( + rawValue.kuaishouConsumeShopId || "" + ).trim(); + const kuaishouConsumeShopName = String( + rawValue.kuaishouConsumeShopName || "" + ).trim(); const kuaishouShopConfig = resolveKuaishouEticketShopConfig({ shopId: kuaishouConsumeShopId, shopName: kuaishouConsumeShopName, - }) + }); if (!internalSkuCode) { - return null + return null; } if (!cloudSkuId) { - return null + return null; } if (!externalSkuCode && !externalItemId && !externalSkuName) { - return null + return null; } return { id: String(rawValue.id || internalSkuCode).trim() || internalSkuCode, enabled: rawValue.enabled !== false, priority: normalizePriority(rawValue.priority), - provider: String(rawValue.provider || '91kaquan').trim() || '91kaquan', - platform: String(rawValue.platform || 'kuaishou').trim() || 'kuaishou', - shopId: String(rawValue.shopId || '').trim(), + provider: String(rawValue.provider || "91kaquan").trim() || "91kaquan", + platform: String(rawValue.platform || "kuaishou").trim() || "kuaishou", + shopId: String(rawValue.shopId || "").trim(), internalSkuCode, - internalSkuName: String(rawValue.internalSkuName || '').trim() || internalSkuCode, + internalSkuName: + String(rawValue.internalSkuName || "").trim() || internalSkuCode, externalSkuCode, externalItemId, externalSkuName, - resolvedSkuName: String(rawValue.resolvedSkuName || rawValue.internalSkuName || '').trim(), - cloudSourceKey: String(rawValue.cloudSourceKey || 'default').trim() || 'default', + resolvedSkuName: String( + rawValue.resolvedSkuName || rawValue.internalSkuName || "" + ).trim(), + cloudSourceKey: + String(rawValue.cloudSourceKey || "default").trim() || "default", + cloudSourceKeyFallbacks: normalizeStringArray( + rawValue.cloudSourceKeyFallbacks + ), cloudSkuId, - cloudSkuName: String(rawValue.cloudSkuName || '').trim(), - vnKey: '1', + cloudSkuName: String(rawValue.cloudSkuName || "").trim(), + vnKey: "1", autoBuyEnabled: rawValue.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), - autoReturnNumberAfterDispatch: rawValue.autoReturnNumberAfterDispatch === true, + autoReturnNumberAfterDispatch: + rawValue.autoReturnNumberAfterDispatch === true, autoConsumeAfterDispatch: rawValue.autoConsumeAfterDispatch === true, - kuaishouConsumeShopId: String(kuaishouShopConfig?.shopId || kuaishouConsumeShopId).trim(), - kuaishouConsumeShopName: String(kuaishouShopConfig?.kshopName || kuaishouConsumeShopName).trim(), - notes: String(rawValue.notes || '').trim(), - } + kuaishouConsumeShopId: String( + kuaishouShopConfig?.shopId || kuaishouConsumeShopId + ).trim(), + kuaishouConsumeShopName: String( + kuaishouShopConfig?.kshopName || kuaishouConsumeShopName + ).trim(), + notes: String(rawValue.notes || "").trim(), + }; } function normalizePositiveInteger(value) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : 0 + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; } function normalizeNonNegativeInteger(value, fallback) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } function normalizePriority(value) { - const parsed = Number(value) + const parsed = Number(value); 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) { + return Object.prototype.toString.call(value) === "[object Object]"; } -function isPlainObject(value) { - 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 []; } diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 12d48f77..65b80aae 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -82,6 +82,7 @@ export {}; * externalSkuName?: string * resolvedSkuName?: string * cloudSourceKey?: string + * cloudSourceKeyFallbacks?: string[] * cloudSkuId?: number | string * cloudSkuName?: string * vnKey?: string