优化领取结果和CloudTentacles平台兑换购买记录
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user