diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 911d8cb8..9701262b 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -305,6 +305,10 @@ export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) dispatch: { status: String(dispatch.status || 'pending').trim() || 'pending', dispatchAt: dispatch.dispatchAt || null, + failedAt: dispatch.failedAt || null, + failedStage: String(dispatch.failedStage || '').trim(), + errorCode: String(dispatch.errorCode || '').trim(), + errorMessage: String(dispatch.errorMessage || '').trim(), note: String(dispatch.note || '').trim(), }, returnNumber: { diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 55607e6f..8bbef1fd 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -549,8 +549,17 @@ export async function redeemKuaishouCloudClaim(token: unknown) { }) } catch (error) { const latestTask = await getTaskById(lockedTask.id) - if (latestTask && normalizeTaskStatus(latestTask.task_status) !== TASK_STATUS.REDEEMING) { - return getKuaishouCloudClaimDetail(token) + const latestStatus = latestTask ? normalizeTaskStatus(latestTask.task_status) : '' + if (latestTask && latestStatus !== TASK_STATUS.REDEEMING) { + if ( + latestStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN || + latestStatus === TASK_STATUS.COMPLETED || + latestStatus === TASK_STATUS.REDEEMED + ) { + return getKuaishouCloudClaimDetail(token) + } + + throw error } const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理' diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index a3387f6b..e78be0d1 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -128,15 +128,25 @@ export async function dispatchKuaishouCloudFulfillmentTask( }); } - const { dispatchResults, stockResult } = await withCloudSkuDispatchLocks( - resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems), - () => prepareStockAndDispatch({ - task, - flow: syncedFlow, - cloudContext, - deliveryItems, - }) - ); + let dispatchResults: DispatchResultItem[]; + let stockResult: DispatchStockResult; + try { + ({ dispatchResults, stockResult } = await withCloudSkuDispatchLocks( + resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems), + () => prepareStockAndDispatch({ + task, + flow: syncedFlow, + cloudContext, + deliveryItems, + }) + )); + } catch (error) { + await markKuaishouCloudDispatchFailed(task, syncedTaskContext, syncedFlow, error, { + actor, + source: options.source || "system", + }); + throw error; + } const firstDispatchResult: DispatchResultItem = dispatchResults[0] || { cloudSkuId: syncedFlow.binding.skuId, cloudSkuName: syncedFlow.binding.skuName, @@ -248,6 +258,72 @@ export async function dispatchKuaishouCloudFulfillmentTask( }; } +async function markKuaishouCloudDispatchFailed( + task: TaskRow, + taskContext: JsonObject, + flow: JsonObject, + error: unknown, + options: JsonObject = {} +) { + const now = nowIso(); + const errorMessage = resolveErrorMessage(error); + const errorCode = resolveErrorCode(error) || "kuaishou_cloud_dispatch_failed"; + const failureContext = resolveDispatchFailureContext(error); + const stockResult = isPlainObject(failureContext.stockResult) + ? (failureContext.stockResult as Partial) + : null; + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + dispatch: { + ...flow.dispatch, + status: "failed", + failedAt: now, + failedStage: String(failureContext.stage || "").trim(), + errorCode, + errorMessage, + note: buildDispatchFailedMessage(errorMessage), + }, + purchase: { + ...flow.purchase, + ...(stockResult + ? { + usedKnapsack: stockResult.usedKnapsack, + purchaseTriggered: stockResult.purchaseTriggered, + assetBefore: stockResult.assetBefore, + assetAfter: stockResult.assetAfter, + items: stockResult.items, + } + : {}), + }, + }, + }; + + await updateTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: "not_required", + last_error: errorMessage, + result_code: errorCode, + result_message: buildDispatchFailedMessage(errorMessage), + context_json: JSON.stringify(nextContext), + updated_at: now, + }); + + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_failed", + { + source: String(options.source || "system").trim() || "system", + actor: options.actor, + errorCode, + errorMessage, + failureContext, + }, + now + ); +} + async function prepareStockAndDispatch({ task, flow, @@ -316,11 +392,62 @@ async function prepareStockAndDispatch({ } for (const item of missingItems) { - await buyCloudtentaclesSku({ - ...cloudContext, - id: item.cloudSkuId, - count: item.purchasedCount, - }); + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_started", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + assetBefore, + }, + nowIso() + ); + + try { + const buyResult = await buyCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + count: item.purchasedCount, + }); + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_succeeded", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + responseMessage: buyResult.responseMessage, + }, + nowIso() + ); + } catch (error) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_failed", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + errorCode: resolveErrorCode(error), + errorMessage: resolveErrorMessage(error), + }, + nowIso() + ); + throw enrichCloudtentaclesDispatchError(error, { + stage: "purchase", + stockResult: buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, + }), + item, + }); + } } purchaseTriggered = true; @@ -331,12 +458,78 @@ async function prepareStockAndDispatch({ const dispatchResults: DispatchResultItem[] = []; for (const item of deliveryItems) { for (let index = 0; index < item.quantity; index += 1) { - const dispatchResult = await useCloudtentaclesSku({ - ...cloudContext, - id: item.cloudSkuId, - virtualNumberId: flow.binding.vnId, - phone: flow.binding.vnPhone, - }); + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_started", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }, + nowIso() + ); + + let dispatchResult: Awaited>; + try { + dispatchResult = await useCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + virtualNumberId: flow.binding.vnId, + phone: flow.binding.vnPhone, + }); + } catch (error) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_failed", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + errorCode: resolveErrorCode(error), + errorMessage: resolveErrorMessage(error), + }, + nowIso() + ); + throw enrichCloudtentaclesDispatchError(error, { + stage: "dispatch", + stockResult: buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, + }), + item, + unitIndex: index + 1, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }); + } + + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_succeeded", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + sendType: Number(dispatchResult.sendType || 0) || 0, + note: String(dispatchResult.note || "").trim(), + responseMessage: String(dispatchResult.responseMessage || "").trim(), + }, + nowIso() + ); dispatchResults.push({ cloudSkuId: item.cloudSkuId, cloudSkuName: item.cloudSkuName, @@ -361,6 +554,82 @@ async function prepareStockAndDispatch({ }; } +function buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, +}: { + stockItems: DispatchStockItem[]; + purchaseTriggered: boolean; + assetBefore: number; + assetAfter: number; +}): DispatchStockResult { + return { + usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0), + purchaseTriggered, + assetBefore, + assetAfter, + items: stockItems, + }; +} + +function enrichCloudtentaclesDispatchError(error: unknown, context: JsonObject) { + const currentError = error instanceof Error + ? (error as Error & { context?: unknown }) + : createHttpError(resolveErrorMessage(error), { + statusCode: 500, + errorCode: "kuaishou_cloud_dispatch_failed", + }) as Error & { context?: unknown }; + const currentContext = isPlainObject(currentError.context) ? currentError.context : {}; + currentError.context = { + ...currentContext, + dispatchFailure: { + ...(isPlainObject((currentContext as JsonObject).dispatchFailure) + ? (currentContext as JsonObject).dispatchFailure + : {}), + ...context, + }, + }; + + return currentError; +} + +function resolveDispatchFailureContext(error: unknown): JsonObject { + if (!error || typeof error !== "object") { + return {}; + } + + const context = (error as { context?: unknown }).context; + if (!isPlainObject(context)) { + return {}; + } + + const dispatchFailure = context.dispatchFailure; + return isPlainObject(dispatchFailure) ? dispatchFailure : {}; +} + +function resolveErrorCode(error: unknown) { + if (!error || typeof error !== "object") { + return ""; + } + + return String((error as { errorCode?: unknown }).errorCode || "").trim(); +} + +function resolveErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error || "CloudTentacles 发货失败"); +} + +function buildDispatchFailedMessage(message: unknown) { + const reason = String(message || "").trim() || "未知错误"; + return `CloudTentacles 发货失败:${reason}`; +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + export function buildDispatchStockItems( deliveryItems: DispatchDeliveryItem[], { skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {} diff --git a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts index 728d5bd1..9b86e2d0 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts @@ -1,4 +1,4 @@ -import { createHttpError } from '../../../utils/http.js' +import { createHttpError, type HttpErrorLike } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' import { resolveCloudtentaclesConfig } from './helpers.js' @@ -86,6 +86,13 @@ export async function buyCloudtentaclesSku(payload: JsonObject = {}) { }, businessErrorStatusCode: 401, businessErrorCode: 'cloudtentacles_sku_buy_failed', + context: { + operation: 'sku_buy', + skuId, + count, + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, + }, }) return { @@ -113,17 +120,14 @@ export async function useCloudtentaclesSku(payload: JsonObject = {}) { } const config = resolveCloudtentaclesConfig(payload) - const result = await cloudtentaclesRequest(config.skuUsePath, { + const result = await useCloudtentaclesSkuRequest({ ...config, - method: 'POST', token, - body: { - id: skuId, - virtual_number_id: virtualNumberId, - phone, - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_sku_use_failed', + skuId, + virtualNumberId, + phone, + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, }) const responseData = isPlainObject(result.payload?.data) ? result.payload.data : {} @@ -140,6 +144,53 @@ export async function useCloudtentaclesSku(payload: JsonObject = {}) { } } +async function useCloudtentaclesSkuRequest(payload: JsonObject) { + try { + return await cloudtentaclesRequest(payload.skuUsePath, { + ...payload, + method: 'POST', + token: payload.token, + body: { + id: payload.skuId, + virtual_number_id: payload.virtualNumberId, + phone: payload.phone, + }, + businessErrorStatusCode: 401, + businessErrorCode: 'cloudtentacles_sku_use_failed', + context: { + operation: 'sku_use', + skuId: payload.skuId, + virtualNumberId: payload.virtualNumberId, + phoneMasked: maskPhone(payload.phone), + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, + }, + }) + } catch (error) { + if (isCloudtentaclesSkuUseLimitMessage(error instanceof Error ? error.message : '')) { + const currentError = error as Error & HttpErrorLike + currentError.statusCode = 409 + currentError.errorCode = 'cloudtentacles_sku_use_limit_reached' + } + + throw error + } +} + +function isCloudtentaclesSkuUseLimitMessage(message: unknown) { + const normalized = String(message || '').trim() + return normalized.includes('最大领取次数') || normalized.includes('领取次数') +} + +function maskPhone(value: unknown) { + const text = String(value || '').trim() + if (text.length <= 4) { + return text ? '****' : '' + } + + return `${text.slice(0, 3)}****${text.slice(-4)}` +} + function mapCategoryItem(item: unknown) { const source = isPlainObject(item) ? item : {} diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts index bec85330..f2aee1ce 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts @@ -67,11 +67,30 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje continue } + logWarn('[cloudtentacles/http]', '请求 HTTP 失败', { + method, + pathname: normalizedPathname, + status: response.status, + attempt: attempt + 1, + sourceKey: options.sourceKey, + accountLabel: options.accountLabel, + context: options.context, + upstreamPayload: payload, + rawText: truncateText(rawText), + }) + throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, { statusCode: isCloudtentaclesHttpRateLimited(response.status) ? 429 : 502, errorCode: isCloudtentaclesHttpRateLimited(response.status) ? 'cloudtentacles_rate_limited' : 'cloudtentacles_http_error', + context: { + method, + pathname: normalizedPathname, + upstreamStatus: response.status, + upstreamPayload: payload, + requestContext: options.context, + }, }) } @@ -106,9 +125,32 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje }) } + logWarn('[cloudtentacles/http]', '请求业务失败', { + method, + pathname: normalizedPathname, + status: response.status, + attempt: attempt + 1, + errorCode, + upstreamCode: payload?.code, + upstreamMessage: message, + sourceKey: options.sourceKey, + accountLabel: options.accountLabel, + context: options.context, + upstreamPayload: payload, + }) + throw createHttpError(message, { statusCode, errorCode, + context: { + method, + pathname: normalizedPathname, + upstreamStatus: response.status, + upstreamCode: payload?.code, + upstreamMessage: message, + upstreamPayload: payload, + requestContext: options.context, + }, }) } @@ -157,6 +199,15 @@ function isCloudtentaclesHttpRateLimited(status: unknown) { return Number(status) === 429 } +function truncateText(value: unknown, maxLength = 1000) { + const text = String(value || '') + if (text.length <= maxLength) { + return text + } + + return `${text.slice(0, maxLength)}...` +} + async function executeCloudtentaclesRequest({ url, method, diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index db14b384..38d682bf 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -106,6 +106,10 @@ export interface ClaimKuaishouCloudFlowInfo { dispatch: { status: string dispatchAt: string | null + failedAt: string | null + failedStage: string + errorCode: string + errorMessage: string note: string } returnNumber: { diff --git a/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue b/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue index 9fef5d6d..8393eb44 100644 --- a/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue +++ b/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue @@ -78,6 +78,7 @@ const claim = useKuaishouCloudClaim(() => props.token) v-else :result-title="claim.resultTitle.value" :result-description="claim.resultDescription.value" + :result-variant="claim.resultVariant.value" :flow="claim.flow.value" :order="claim.order.value" :product="claim.product.value" diff --git a/apps/frontend/src/views/claim/kuaishou-cloud/components/ClaimResultStep.vue b/apps/frontend/src/views/claim/kuaishou-cloud/components/ClaimResultStep.vue index 846d445c..50ebe345 100644 --- a/apps/frontend/src/views/claim/kuaishou-cloud/components/ClaimResultStep.vue +++ b/apps/frontend/src/views/claim/kuaishou-cloud/components/ClaimResultStep.vue @@ -1,5 +1,5 @@