领取/发货链路优化
This commit is contained in:
@@ -2,8 +2,6 @@ import { createTaskEvent } from "../../../repositories/task-event-repo.js";
|
||||
import { updateTask } from "../../../repositories/task-repo.js";
|
||||
import { buildClaimUrl, createTaskClaimToken } from "../../claim/claim-service.js";
|
||||
import {
|
||||
buyCloudtentaclesSku,
|
||||
getCloudtentaclesAsset,
|
||||
listCloudtentaclesSku,
|
||||
} from "../../platforms/cloudtentacles/catalog-service.js";
|
||||
import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js";
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js";
|
||||
import {
|
||||
notifyKuaishouCloudAssetNotEnough,
|
||||
notifyKuaishouCloudBindUrlRefreshFailed,
|
||||
} from "../../notification/domain-notifications.js";
|
||||
import { createHttpError } from "../../../utils/http.js";
|
||||
@@ -189,77 +186,27 @@ export async function prepareKuaishouCloudFulfillmentTask(
|
||||
});
|
||||
}
|
||||
|
||||
let usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0);
|
||||
let purchaseTriggered = false;
|
||||
let assetBefore = 0;
|
||||
let assetAfter = 0;
|
||||
|
||||
if (!usedKnapsack) {
|
||||
if (!flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
||||
const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0);
|
||||
if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
||||
throw createHttpError("背包中没有现成库存,且当前配置未开启自动购买", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_auto_buy_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const asset = await getCloudtentaclesAsset(cloudContext);
|
||||
const missingSku = deliveryPlan.items.find(
|
||||
(item) => item.missingCount > 0 && !item.skuItem
|
||||
);
|
||||
if (missingSku) {
|
||||
throw createHttpError(
|
||||
`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`,
|
||||
{
|
||||
throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, {
|
||||
statusCode: 404,
|
||||
errorCode: "kuaishou_cloud_sku_not_found",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
assetBefore = Number(asset.asset || 0) || 0;
|
||||
const targetPrice = deliveryPlan.items.reduce(
|
||||
(sum, item) =>
|
||||
sum + Math.max(0, item.missingCount) * Number(item.skuItem?.price || 0),
|
||||
0
|
||||
);
|
||||
const requiredAsset =
|
||||
targetPrice + flowWithResolvedBinding.purchase.minAssetReserve;
|
||||
if (assetBefore < requiredAsset) {
|
||||
await notifyKuaishouCloudAssetNotEnough({
|
||||
task,
|
||||
flow: flowWithResolvedBinding,
|
||||
assetBefore,
|
||||
requiredAsset,
|
||||
skuName: deliveryPlan.items
|
||||
.filter((item) => item.missingCount > 0)
|
||||
.map((item) => item.cloudSkuName || `SKU ${item.cloudSkuId}`)
|
||||
.join("、"),
|
||||
});
|
||||
throw createHttpError(
|
||||
`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`,
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_asset_not_enough",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of deliveryPlan.items) {
|
||||
if (item.missingCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await buyCloudtentaclesSku({
|
||||
...cloudContext,
|
||||
id: item.cloudSkuId,
|
||||
count: item.missingCount,
|
||||
});
|
||||
}
|
||||
purchaseTriggered = true;
|
||||
|
||||
const assetResult = await getCloudtentaclesAsset(cloudContext);
|
||||
assetAfter = Number(assetResult.asset || 0) || 0;
|
||||
}
|
||||
const purchaseTriggered = false;
|
||||
const assetBefore = 0;
|
||||
const assetAfter = 0;
|
||||
|
||||
const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
@@ -305,11 +252,9 @@ export async function prepareKuaishouCloudFulfillmentTask(
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
requiredCount: item.quantity,
|
||||
knapsackCount: item.knapsackCount,
|
||||
purchasedCount: Math.max(0, item.missingCount),
|
||||
purchasedCount: 0,
|
||||
})),
|
||||
purchaseAt: purchaseTriggered
|
||||
? now
|
||||
: flowWithResolvedBinding.purchase.purchaseAt,
|
||||
purchaseAt: flowWithResolvedBinding.purchase.purchaseAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -343,7 +288,7 @@ export async function prepareKuaishouCloudFulfillmentTask(
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
quantity: item.quantity,
|
||||
knapsackCount: item.knapsackCount,
|
||||
purchasedCount: Math.max(0, item.missingCount),
|
||||
purchasedCount: 0,
|
||||
})),
|
||||
resolvedByName: resolvedBinding.resolvedByName,
|
||||
actor,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildDispatchStockItems } from "./task-finalization.js";
|
||||
|
||||
test("buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口", () => {
|
||||
const result = buildDispatchStockItems(
|
||||
[
|
||||
{
|
||||
cloudSkuId: 101,
|
||||
cloudSkuName: "",
|
||||
quantity: 3,
|
||||
},
|
||||
],
|
||||
{
|
||||
skuItems: [
|
||||
{
|
||||
id: 101,
|
||||
name: "套装-暗影哥特",
|
||||
price: 88,
|
||||
},
|
||||
],
|
||||
knapsackItems: [
|
||||
{
|
||||
id: 101,
|
||||
name: "套装-暗影哥特",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
cloudSkuId: 101,
|
||||
cloudSkuName: "套装-暗影哥特",
|
||||
quantity: 3,
|
||||
requiredCount: 3,
|
||||
knapsackCount: 1,
|
||||
purchasedCount: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildDispatchStockItems 背包足够时不需要补买", () => {
|
||||
const result = buildDispatchStockItems(
|
||||
[
|
||||
{
|
||||
cloudSkuId: 202,
|
||||
cloudSkuName: "套装-浪漫天命",
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
knapsackItems: [
|
||||
{
|
||||
id: 202,
|
||||
name: "套装-浪漫天命",
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.knapsackCount, 5);
|
||||
assert.equal(result[0]?.purchasedCount, 0);
|
||||
});
|
||||
@@ -4,8 +4,17 @@ import { updateTask } from "../../../repositories/task-repo.js";
|
||||
import { createHttpError } from "../../../utils/http.js";
|
||||
import { nowIso } from "../../../utils/time.js";
|
||||
import { TASK_STATUS, normalizeTaskStatus, type TaskStatus } from "../../../domain/task-status.js";
|
||||
import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-notifications.js";
|
||||
import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js";
|
||||
import {
|
||||
notifyKuaishouCloudAssetNotEnough,
|
||||
notifyKuaishouCloudConsumeFailed,
|
||||
} from "../../notification/domain-notifications.js";
|
||||
import {
|
||||
buyCloudtentaclesSku,
|
||||
getCloudtentaclesAsset,
|
||||
listCloudtentaclesSku,
|
||||
useCloudtentaclesSku,
|
||||
} from "../../platforms/cloudtentacles/catalog-service.js";
|
||||
import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js";
|
||||
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
|
||||
@@ -38,6 +47,22 @@ type DispatchResultItem = DispatchDeliveryItem & {
|
||||
responseMessage: string;
|
||||
};
|
||||
|
||||
type DispatchStockItem = DispatchDeliveryItem & {
|
||||
requiredCount: number;
|
||||
knapsackCount: number;
|
||||
purchasedCount: number;
|
||||
};
|
||||
|
||||
type DispatchStockResult = {
|
||||
usedKnapsack: boolean;
|
||||
purchaseTriggered: boolean;
|
||||
assetBefore: number;
|
||||
assetAfter: number;
|
||||
items: DispatchStockItem[];
|
||||
};
|
||||
|
||||
const cloudSkuDispatchLocks = new Map<string, Promise<void>>();
|
||||
|
||||
export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
task: TaskRow,
|
||||
options: JsonObject = {}
|
||||
@@ -103,26 +128,15 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
});
|
||||
}
|
||||
|
||||
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: syncedFlow.binding.vnId,
|
||||
phone: syncedFlow.binding.vnPhone,
|
||||
});
|
||||
dispatchResults.push({
|
||||
cloudSkuId: item.cloudSkuId,
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
unitIndex: index + 1,
|
||||
quantity: item.quantity,
|
||||
sendType: Number(dispatchResult.sendType || 0) || 0,
|
||||
note: String(dispatchResult.note || "").trim(),
|
||||
responseMessage: String(dispatchResult.responseMessage || "").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
const { dispatchResults, stockResult } = await withCloudSkuDispatchLocks(
|
||||
resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems),
|
||||
() => prepareStockAndDispatch({
|
||||
task,
|
||||
flow: syncedFlow,
|
||||
cloudContext,
|
||||
deliveryItems,
|
||||
})
|
||||
);
|
||||
const firstDispatchResult: DispatchResultItem = dispatchResults[0] || {
|
||||
cloudSkuId: syncedFlow.binding.skuId,
|
||||
cloudSkuName: syncedFlow.binding.skuName,
|
||||
@@ -160,6 +174,17 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
note: dispatchSummary,
|
||||
items: dispatchResults,
|
||||
},
|
||||
purchase: {
|
||||
...syncedFlow.purchase,
|
||||
usedKnapsack: stockResult.usedKnapsack,
|
||||
purchaseTriggered: stockResult.purchaseTriggered,
|
||||
assetBefore: stockResult.assetBefore,
|
||||
assetAfter: stockResult.assetAfter,
|
||||
purchaseAt: stockResult.purchaseTriggered
|
||||
? now
|
||||
: syncedFlow.purchase.purchaseAt,
|
||||
items: stockResult.items,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -193,6 +218,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
note: dispatchSummary,
|
||||
deliveryItems,
|
||||
dispatchResults,
|
||||
stockResult,
|
||||
actor,
|
||||
},
|
||||
now
|
||||
@@ -222,6 +248,184 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareStockAndDispatch({
|
||||
task,
|
||||
flow,
|
||||
cloudContext,
|
||||
deliveryItems,
|
||||
}: {
|
||||
task: TaskRow;
|
||||
flow: JsonObject;
|
||||
cloudContext: JsonObject;
|
||||
deliveryItems: DispatchDeliveryItem[];
|
||||
}) {
|
||||
const [knapsack, skuList] = await Promise.all([
|
||||
getCloudtentaclesKnapsack(cloudContext),
|
||||
listCloudtentaclesSku(cloudContext),
|
||||
]);
|
||||
const skuItems = Array.isArray(skuList.items) ? skuList.items : [];
|
||||
const knapsackItems = Array.isArray(knapsack.items) ? knapsack.items : [];
|
||||
const stockItems = buildDispatchStockItems(deliveryItems, {
|
||||
skuItems,
|
||||
knapsackItems,
|
||||
});
|
||||
const missingItems = stockItems.filter((item) => item.purchasedCount > 0);
|
||||
|
||||
let assetBefore = 0;
|
||||
let assetAfter = 0;
|
||||
let purchaseTriggered = false;
|
||||
|
||||
if (missingItems.length > 0) {
|
||||
if (flow.purchase?.autoBuyEnabled === false) {
|
||||
throw createHttpError("背包中没有现成库存,且当前配置未开启自动购买", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_auto_buy_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const missingSku = missingItems.find((item) => !findCloudSkuItem(skuItems, item.cloudSkuId));
|
||||
if (missingSku) {
|
||||
throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, {
|
||||
statusCode: 404,
|
||||
errorCode: "kuaishou_cloud_sku_not_found",
|
||||
});
|
||||
}
|
||||
|
||||
const asset = await getCloudtentaclesAsset(cloudContext);
|
||||
assetBefore = Number(asset.asset || 0) || 0;
|
||||
const targetPrice = missingItems.reduce((sum, item) => {
|
||||
const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId);
|
||||
return sum + item.purchasedCount * Number(skuItem?.price || 0);
|
||||
}, 0);
|
||||
const requiredAsset = targetPrice + Number(flow.purchase?.minAssetReserve || 0);
|
||||
|
||||
if (assetBefore < requiredAsset) {
|
||||
await notifyKuaishouCloudAssetNotEnough({
|
||||
task,
|
||||
flow,
|
||||
assetBefore,
|
||||
requiredAsset,
|
||||
skuName: missingItems
|
||||
.map((item) => item.cloudSkuName || `SKU ${item.cloudSkuId}`)
|
||||
.join("、"),
|
||||
});
|
||||
throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_asset_not_enough",
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of missingItems) {
|
||||
await buyCloudtentaclesSku({
|
||||
...cloudContext,
|
||||
id: item.cloudSkuId,
|
||||
count: item.purchasedCount,
|
||||
});
|
||||
}
|
||||
|
||||
purchaseTriggered = true;
|
||||
const assetResult = await getCloudtentaclesAsset(cloudContext);
|
||||
assetAfter = Number(assetResult.asset || 0) || 0;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
dispatchResults.push({
|
||||
cloudSkuId: item.cloudSkuId,
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
unitIndex: index + 1,
|
||||
quantity: item.quantity,
|
||||
sendType: Number(dispatchResult.sendType || 0) || 0,
|
||||
note: String(dispatchResult.note || "").trim(),
|
||||
responseMessage: String(dispatchResult.responseMessage || "").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dispatchResults,
|
||||
stockResult: {
|
||||
usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0),
|
||||
purchaseTriggered,
|
||||
assetBefore,
|
||||
assetAfter,
|
||||
items: stockItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDispatchStockItems(
|
||||
deliveryItems: DispatchDeliveryItem[],
|
||||
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
|
||||
): DispatchStockItem[] {
|
||||
return deliveryItems.map((item) => {
|
||||
const knapsackItem = findCloudSkuItem(knapsackItems, item.cloudSkuId);
|
||||
const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId);
|
||||
const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0);
|
||||
|
||||
return {
|
||||
...item,
|
||||
cloudSkuName: item.cloudSkuName || String(skuItem?.name || knapsackItem?.name || "").trim(),
|
||||
requiredCount: item.quantity,
|
||||
knapsackCount,
|
||||
purchasedCount: Math.max(0, item.quantity - knapsackCount),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function findCloudSkuItem(items: JsonObject[], cloudSkuId: number) {
|
||||
return items.find((item) => Number(item.id || 0) === cloudSkuId) || null;
|
||||
}
|
||||
|
||||
function resolveCloudSkuDispatchLockKeys(sourceKey: unknown, deliveryItems: DispatchDeliveryItem[]) {
|
||||
const normalizedSourceKey = String(sourceKey || "default").trim() || "default";
|
||||
return deliveryItems.map((item) => `${normalizedSourceKey}:${item.cloudSkuId}`);
|
||||
}
|
||||
|
||||
async function withCloudSkuDispatchLocks<T>(keys: string[], callback: () => Promise<T>): Promise<T> {
|
||||
const normalizedKeys = [...new Set(keys.map((key) => String(key || "").trim()).filter(Boolean))].sort();
|
||||
|
||||
async function run(index: number): Promise<T> {
|
||||
const key = normalizedKeys[index];
|
||||
if (!key) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
return withCloudSkuDispatchLock(key, () => run(index + 1));
|
||||
}
|
||||
|
||||
return run(0);
|
||||
}
|
||||
|
||||
async function withCloudSkuDispatchLock<T>(key: string, callback: () => Promise<T>): Promise<T> {
|
||||
const previous = cloudSkuDispatchLocks.get(key) || Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.catch(() => {}).then(() => current);
|
||||
|
||||
cloudSkuDispatchLocks.set(key, queued);
|
||||
|
||||
await previous.catch(() => {});
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
release();
|
||||
if (cloudSkuDispatchLocks.get(key) === queued) {
|
||||
cloudSkuDispatchLocks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDispatchDeliveryItems(flow: JsonObject): DispatchDeliveryItem[] {
|
||||
const rawItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : [];
|
||||
const items = rawItems
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
const statusCode = Number(options.businessErrorStatusCode || 400)
|
||||
const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error')
|
||||
|
||||
if (statusCode === 401 || isCloudtentaclesExpiredMessage(message)) {
|
||||
if (isCloudtentaclesExpiredMessage(message)) {
|
||||
await notifyCloudtentaclesAuthExpired({
|
||||
pathname: normalizedPathname,
|
||||
errorCode,
|
||||
|
||||
Reference in New Issue
Block a user