diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index 7803062d..9ecca422 100644 --- a/apps/backend/data/khhao-sync-state.json +++ b/apps/backend/data/khhao-sync-state.json @@ -1,11 +1,11 @@ { "syncFromCreatedAt": "2026-05-02T10:53:28.550Z", - "lastRunStartedAt": "2026-05-03T10:44:18.857Z", - "lastRunFinishedAt": "2026-05-03T10:44:19.569Z", + "lastRunStartedAt": "2026-05-03T11:11:55.949Z", + "lastRunFinishedAt": "2026-05-03T11:11:56.571Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, - "syncedCount": 1, - "ignoredCount": 39, - "lastOrderCreatedAt": "2026-05-03T10:44:07.000Z" + "syncedCount": 0, + "ignoredCount": 40, + "lastOrderCreatedAt": "2026-05-03T11:11:29.000Z" } diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index 3916b77d..458986a8 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -20,6 +20,7 @@ import { getWebhookEventById } from '../../repositories/webhook-event-repo.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js' import { reserveInventoryForTask } from '../order/inventory-service.js' +import { normalizeProductName } from '../order/product-match-service.js' import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js' @@ -75,6 +76,8 @@ import { /** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */ /** @typedef {import('../../types/admin-write-inputs.js').AdminTaskKuaishouCloudDispatchInput} AdminTaskKuaishouCloudDispatchInput */ +const KUAISHOU_CLOUD_FIXED_VN_KEY = '1' + /** @returns {Promise} */ /** @param {AdminInventoryCreateInput} [payload] */ export async function createAdminInventoryItem(payload = /** @type {AdminInventoryCreateInput} */ ({})) { @@ -718,37 +721,54 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const cloudContext = resolvePersistedCloudtentaclesContext() + const [knapsack, skuList] = await Promise.all([ + getCloudtentaclesKnapsack(cloudContext), + listCloudtentaclesSku(cloudContext), + ]) + const resolvedBinding = resolveKuaishouCloudBindingResources(flow, { + skuItems: Array.isArray(skuList.items) ? skuList.items : [], + knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], + }) + const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({ + flow, + binding: resolvedBinding, + }) - if (!flow.binding.skuId || !flow.binding.vnKey) { - throw createHttpError('当前任务缺少 cloud SKU 或 VN Key 配置', { + if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) { + throw createHttpError('当前任务缺少可用 cloud 资源,且无法根据商品名自动解析 SKU / VN Key', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_binding_config', }) } - const knapsack = await getCloudtentaclesKnapsack(cloudContext) - const knapsackItem = (Array.isArray(knapsack.items) ? knapsack.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null + const flowWithResolvedBinding = { + ...flow, + binding: { + ...flow.binding, + skuId: resolvedBinding.skuId, + skuName: resolvedBinding.skuName, + vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, + }, + } + const knapsackItem = resolvedBinding.knapsackItem let usedKnapsack = Number(knapsackItem?.count || 0) > 0 let purchaseTriggered = false let assetBefore = 0 let assetAfter = 0 if (!usedKnapsack) { - if (!flow.purchase.autoBuyEnabled) { + if (!flowWithResolvedBinding.purchase.autoBuyEnabled) { throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_auto_buy_disabled', }) } - const [asset, skuList] = await Promise.all([ - getCloudtentaclesAsset(cloudContext), - listCloudtentaclesSku(cloudContext), - ]) - const targetSku = (Array.isArray(skuList.items) ? skuList.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null + const asset = await getCloudtentaclesAsset(cloudContext) + const targetSku = resolvedBinding.skuItem if (!targetSku) { - throw createHttpError(`cloudtentacles 未找到 SKU ${flow.binding.skuId}`, { + throw createHttpError(`cloudtentacles 未找到 SKU ${flowWithResolvedBinding.binding.skuId}`, { statusCode: 404, errorCode: 'admin_task_kuaishou_cloud_sku_not_found', }) @@ -756,7 +776,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = assetBefore = Number(asset.asset || 0) || 0 const targetPrice = Number(targetSku.price || 0) || 0 - const requiredAsset = targetPrice + flow.purchase.minAssetReserve + const requiredAsset = targetPrice + flowWithResolvedBinding.purchase.minAssetReserve if (assetBefore < requiredAsset) { throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { statusCode: 409, @@ -766,7 +786,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = await buyCloudtentaclesSku({ ...cloudContext, - id: flow.binding.skuId, + id: flowWithResolvedBinding.binding.skuId, count: 1, }) purchaseTriggered = true @@ -775,51 +795,23 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = assetAfter = Number(assetResult.asset || 0) || 0 } - const appointed = await appointCloudtentaclesVirtualNumber({ - ...cloudContext, - key: flow.binding.vnKey, + const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ + cloudContext, + vnKeyCandidates, }) - const vnId = Number(appointed.item?.id || 0) - const vnPhone = String(appointed.item?.phone || '').trim() - - if (!vnId || !vnPhone) { - throw createHttpError('申请虚拟号成功但返回数据不完整', { - statusCode: 502, - errorCode: 'admin_task_kuaishou_cloud_invalid_vn', - }) + const vnId = preparedBinding.vnId + const vnPhone = preparedBinding.vnPhone + const bindUrlResult = { + bindUrl: preparedBinding.bindUrl, } - await generateCloudtentaclesLoginCode({ - ...cloudContext, - key: flow.binding.vnKey, - id: vnId, - }) - - const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({ - ...cloudContext, - key: flow.binding.vnKey, - phone: vnPhone, - }) - - await verifyCloudtentaclesLoginCode({ - ...cloudContext, - key: flow.binding.vnKey, - id: vnId, - code: fetchedCode.code, - }) - - const bindUrlResult = await getCloudtentaclesBindUrl({ - ...cloudContext, - key: flow.binding.vnKey, - id: vnId, - }) - const nextContext = { ...taskContext, kuaishouCloudFulfillment: { - ...flow, + ...flowWithResolvedBinding, binding: { - ...flow.binding, + ...flowWithResolvedBinding.binding, + vnKey: preparedBinding.vnKey, prepareStatus: 'ready', vnId, vnPhone, @@ -827,12 +819,12 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = bindPreparedAt: now, }, purchase: { - ...flow.purchase, + ...flowWithResolvedBinding.purchase, usedKnapsack, purchaseTriggered, assetBefore, assetAfter, - purchaseAt: purchaseTriggered ? now : flow.purchase.purchaseAt, + purchaseAt: purchaseTriggered ? now : flowWithResolvedBinding.purchase.purchaseAt, }, }, } @@ -847,13 +839,15 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = }) await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', { - skuId: flow.binding.skuId, - vnKey: flow.binding.vnKey, + skuId: flowWithResolvedBinding.binding.skuId, + skuName: flowWithResolvedBinding.binding.skuName, + vnKey: preparedBinding.vnKey, vnId, vnPhoneMasked: maskPhone(vnPhone), bindUrl: bindUrlResult.bindUrl, purchaseTriggered, usedKnapsack, + resolvedByName: resolvedBinding.resolvedByName, }, now) return { @@ -1173,6 +1167,196 @@ function normalizeKuaishouCloudFlow(value) { } } +function resolveKuaishouCloudBindingResources(flow, { skuItems = [], knapsackItems = [] } = {}) { + 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 currentVnKey = String(flow?.binding?.vnKey || '').trim() + 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), + } +} + +/** + * @param {{ flow?: any, binding?: any }} [input] + */ +function resolveKuaishouCloudVnKeyCandidates(input = {}) { + return [KUAISHOU_CLOUD_FIXED_VN_KEY] +} + +/** + * @param {{ cloudContext?: Record, vnKeyCandidates?: string[] }} [input] + */ +async function prepareKuaishouCloudBindResourceWithFallback(input = {}) { + const { cloudContext = {}, vnKeyCandidates = [] } = input + const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [] + let lastError = null + + for (const vnKey of candidates) { + let vnId = 0 + let vnPhone = '' + + try { + const appointed = await appointCloudtentaclesVirtualNumber({ + ...cloudContext, + key: vnKey, + }) + vnId = Number(appointed.item?.id || 0) + vnPhone = String(appointed.item?.phone || '').trim() + + if (!vnId || !vnPhone) { + throw createHttpError('申请虚拟号成功但返回数据不完整', { + statusCode: 502, + errorCode: 'admin_task_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 (vnId > 0) { + try { + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: vnKey, + id: vnId, + }) + } catch { + // 兜底退号失败时保留原始错误,避免吞掉主链路异常 + } + } + + if (!isRecoverableKuaishouCloudVnKeyError(error)) { + throw error + } + } + } + + throw lastError || createHttpError('没有找到可用的 VN Key', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_missing_binding_config', + }) +} + +function collectKuaishouCloudNameCandidates(flow) { + return Array.from(new Set([ + String(flow?.binding?.skuName || '').trim(), + String(flow?.internalSkuName || '').trim(), + String(flow?.internalSkuCode || '').trim(), + ].filter(Boolean))) +} + +function findCloudItemByNames(items, nameCandidates, preferredId = 0) { + const normalizedItems = Array.isArray(items) ? items : [] + const normalizedNames = nameCandidates + .map((item) => ({ + 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) => Number(item.id || 0) === preferredId) || null + : null + } + + if (preferredId > 0) { + const preferred = normalizedItems.find((item) => Number(item.id || 0) === preferredId) || null + if (preferred) { + return preferred + } + } + + const exactMatches = normalizedItems.filter((item) => { + const itemName = normalizeProductName(item.name) + return normalizedNames.some((candidate) => candidate.normalized === itemName) + }) + if (exactMatches.length > 0) { + return exactMatches[0] + } + + const partialMatches = normalizedItems.filter((item) => { + const itemName = normalizeProductName(item.name) + return normalizedNames.some((candidate) => 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) { + return Boolean(item) && typeof item === 'object' && Number(item.id || 0) > 0 +} + function maskPhone(value) { const text = String(value || '').trim() if (!text) { @@ -1258,6 +1442,13 @@ function isRecoverableTaskSessionCloseError(error) { return errorCode === 'session_not_found' || errorCode === 'session_closed' } +function isRecoverableKuaishouCloudVnKeyError(error) { + const errorCode = String(error?.errorCode || error?.code || '').trim() + const errorMessage = String(error?.message || '').trim() + return errorCode === 'cloudtentacles_vn_bind_url_failed' + && errorMessage.includes('不支持的游戏类型') +} + function normalizeManualDispatchOutcome(value) { const normalized = String(value || '').trim().toLowerCase() diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index cd678e14..93a84adf 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -101,7 +101,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { cloudSourceKey: String(fulfillmentConfig.cloudtentacles?.cloudSourceKey || 'default').trim() || 'default', skuId: Number(fulfillmentConfig.cloudtentacles?.skuId || 0) || 0, skuName: String(fulfillmentConfig.cloudtentacles?.skuName || '').trim(), - vnKey: String(fulfillmentConfig.cloudtentacles?.vnKey || '').trim(), + vnKey: '1', vnId: 0, vnPhone: '', bindUrl: '', 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 c802c595..90368b99 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 @@ -45,7 +45,7 @@ export function mapKuaishouCloudFulfillmentItemsToBindings(config = {}) { cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default', skuId: normalizePositiveInteger(item.cloudSkuId), skuName: String(item.cloudSkuName || '').trim(), - vnKey: String(item.vnKey || '').trim(), + vnKey: '1', autoBuyEnabled: item.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, @@ -99,7 +99,6 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue) { const internalSkuCode = String(rawValue.internalSkuCode || '').trim() const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId) - const vnKey = String(rawValue.vnKey || '').trim() const externalSkuCode = String(rawValue.externalSkuCode || '').trim() const externalItemId = String(rawValue.externalItemId || '').trim() const externalSkuName = String(rawValue.externalSkuName || '').trim() @@ -108,7 +107,7 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue) { return null } - if (!cloudSkuId || !vnKey) { + if (!cloudSkuId) { return null } @@ -132,7 +131,7 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue) { cloudSourceKey: String(rawValue.cloudSourceKey || 'default').trim() || 'default', cloudSkuId, cloudSkuName: String(rawValue.cloudSkuName || '').trim(), - vnKey, + vnKey: '1', autoBuyEnabled: rawValue.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), autoReturnNumberAfterDispatch: rawValue.autoReturnNumberAfterDispatch === true, diff --git a/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue index f4b45488..1ec5f975 100644 --- a/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue +++ b/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue @@ -59,7 +59,7 @@ function createEmptyItem(): EditableItem { cloudSourceKey: 'default', cloudSkuId: 0, cloudSkuName: '', - vnKey: '', + vnKey: '1', autoBuyEnabled: true, minAssetReserve: 0, autoReturnNumberAfterDispatch: false, @@ -157,7 +157,7 @@ function isItemComplete(item: EditableItem) { return Boolean( item.internalSkuCode.trim() && Number(item.cloudSkuId || 0) > 0 - && item.vnKey.trim() + && true && hasExternalMatch(item), ) } @@ -177,10 +177,6 @@ function resolveValidation(item: EditableItem, index: number): ValidationState { return { localId: item.localId, message: `${title} 需要填写 cloud SKU ID` } } - if (!item.vnKey.trim()) { - return { localId: item.localId, message: `${title} 需要填写虚拟号 VN Key` } - } - if (!hasExternalMatch(item)) { return { localId: item.localId, message: `${title} 至少填写一种外部匹配条件` } } @@ -466,7 +462,7 @@ onMounted(loadConfigs) diff --git a/tems/imgs/1.png b/tems/imgs/1.png new file mode 100644 index 00000000..6fb49bd6 Binary files /dev/null and b/tems/imgs/1.png differ diff --git a/tems/imgs/2.png b/tems/imgs/2.png new file mode 100644 index 00000000..5c839ae7 Binary files /dev/null and b/tems/imgs/2.png differ diff --git a/tems/imgs/3.png b/tems/imgs/3.png new file mode 100644 index 00000000..e01e0b80 Binary files /dev/null and b/tems/imgs/3.png differ