优化了任务

This commit is contained in:
yml
2026-05-03 19:13:22 +08:00
parent 90cb7aa154
commit dd2192b6ae
8 changed files with 258 additions and 72 deletions
@@ -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<AdminInventoryMutationResponse>} */
/** @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<string, unknown>, 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()
@@ -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: '',
@@ -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,