优化领取结果和CloudTentacles平台兑换购买记录

This commit is contained in:
yml2213
2026-05-31 16:58:13 +08:00
parent 904ecc9303
commit c9913f51ff
9 changed files with 487 additions and 42 deletions
@@ -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: {
@@ -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 : '兑换请求提交失败,请联系客服处理'
@@ -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<DispatchStockResult>)
: 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<ReturnType<typeof useCloudtentaclesSku>>;
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[] } = {}
@@ -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 : {}
@@ -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,
+4
View File
@@ -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: {
@@ -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"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CircleCheck } from '@element-plus/icons-vue'
import { CircleCheck, WarningFilled } from '@element-plus/icons-vue'
import ClaimProductItems from './ClaimProductItems.vue'
@@ -8,6 +8,7 @@ import type { ClaimKuaishouCloudFlowInfo, ClaimOrderInfo, ClaimProductInfo } fro
defineProps<{
resultTitle: string
resultDescription: string
resultVariant: 'success' | 'warning' | 'info'
flow: ClaimKuaishouCloudFlowInfo
order: ClaimOrderInfo | null
product: ClaimProductInfo | null
@@ -20,7 +21,10 @@ defineProps<{
<template>
<section class="content-card">
<div class="result-header">
<el-icon class="large-icon success"><CircleCheck /></el-icon>
<el-icon :class="['large-icon', resultVariant]">
<WarningFilled v-if="resultVariant === 'warning'" />
<CircleCheck v-else />
</el-icon>
<div>
<h2>{{ resultTitle }}</h2>
<p class="muted">{{ resultDescription }}</p>
@@ -56,9 +60,13 @@ defineProps<{
<ClaimProductItems :product="product" />
<div class="status-banner success">
<el-icon><CircleCheck /></el-icon>
<span>客户侧操作已经完成后续履约结果请以后台任务界面为准</span>
<div :class="['status-banner', resultVariant]">
<el-icon>
<WarningFilled v-if="resultVariant === 'warning'" />
<CircleCheck v-else />
</el-icon>
<span v-if="resultVariant === 'warning'">当前兑换未完成客服会根据后台任务记录继续处理</span>
<span v-else>客户侧操作已经完成后续履约结果请以后台任务界面为准</span>
</div>
</section>
</template>
@@ -103,6 +111,14 @@ defineProps<{
color: #16a34a;
}
.large-icon.warning {
color: #d97706;
}
.large-icon.info {
color: #2563eb;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -143,6 +159,16 @@ defineProps<{
color: #166534;
}
.status-banner.warning {
background: #fffbeb;
color: #92400e;
}
.status-banner.info {
background: #eff6ff;
color: #1d4ed8;
}
@media (max-width: 720px) {
.content-card {
max-width: 100%;
@@ -4,10 +4,12 @@ import { ElMessageBox } from 'element-plus'
import { showError, showSuccess } from '@/lib/feedback'
import {
TASK_STATUS,
hasKuaishouCloudRedeemResultStatus,
isClaimInactiveTaskStatus,
isKuaishouCloudCompletedStatus,
isKuaishouCloudRoleConfirmedStatus,
normalizeTaskStatus,
} from '@/domain/task-status'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
@@ -78,12 +80,16 @@ export function useKuaishouCloudClaim(token: () => string) {
const isDispatched = computed(
() => String(flow.value?.dispatch.status || '').trim() === 'success',
)
const isRedeemFailed = computed(() => {
const status = normalizeTaskStatus(task.value?.status)
return (
status === TASK_STATUS.MANUAL_REVIEW ||
status === TASK_STATUS.FAILED ||
String(flow.value?.dispatch.status || '').trim() === 'failed'
)
})
const isCompleted = computed(
() =>
isKuaishouCloudCompletedStatus(task.value?.status) ||
String(flow.value?.consume.status || '').trim() === 'success',
)
const isCompleted = computed(() => isKuaishouCloudCompletedStatus(task.value?.status))
const hasRedeemResult = computed(() => {
return isDispatched.value || hasKuaishouCloudRedeemResultStatus(task.value?.status)
@@ -120,6 +126,9 @@ export function useKuaishouCloudClaim(token: () => string) {
})
const resultTitle = computed(() => {
if (isRedeemFailed.value) {
return '兑换遇到问题'
}
if (isCompleted.value) {
return '兑换成功'
}
@@ -130,12 +139,31 @@ export function useKuaishouCloudClaim(token: () => string) {
})
const resultDescription = computed(() => {
if (isRedeemFailed.value) {
const message = String(
task.value?.lastError ||
flow.value?.dispatch.errorMessage ||
detail.value?.result?.resultMessage ||
'',
).trim()
return message || '当前兑换流程需要客服处理,后续结果请以后台任务界面为准。'
}
if (isCompleted.value) {
return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。'
}
return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。'
})
const resultVariant = computed(() => {
if (isRedeemFailed.value) {
return 'warning'
}
if (isCompleted.value || isDispatched.value) {
return 'success'
}
return 'info'
})
// ── actions ─────────────────────────────────────────────
async function generateQRCode(url: string) {
@@ -415,6 +443,7 @@ export function useKuaishouCloudClaim(token: () => string) {
isRoleReady,
isRoleConfirmed,
isDispatched,
isRedeemFailed,
isCompleted,
hasRedeemResult,
canSubmitTicket,
@@ -422,6 +451,7 @@ export function useKuaishouCloudClaim(token: () => string) {
progressText,
resultTitle,
resultDescription,
resultVariant,
// actions
loadDetail,
submitTicket,