refactor backend route and fulfillment modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
import { createHttpError } from "../../../utils/http.js";
|
||||
import { normalizeProductName } from "../../order/product-match-service.js";
|
||||
import {
|
||||
appointCloudtentaclesVirtualNumber,
|
||||
backCloudtentaclesVirtualNumber,
|
||||
fetchCloudtentaclesVirtualNumberCode,
|
||||
generateCloudtentaclesLoginCode,
|
||||
getCloudtentaclesBindUrl,
|
||||
verifyCloudtentaclesLoginCode,
|
||||
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
|
||||
|
||||
export 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 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),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveKuaishouCloudVnKeyCandidates(_input = {}) {
|
||||
return [KUAISHOU_CLOUD_FIXED_VN_KEY];
|
||||
}
|
||||
|
||||
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
|
||||
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: "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: "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 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("不支持的游戏类型")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHttpError } from "../../../utils/http.js";
|
||||
import {
|
||||
getCloudtentaclesSourceConfig,
|
||||
getCloudtentaclesSourceByKey,
|
||||
} from "../../platforms/cloudtentacles/source-config-service.js";
|
||||
import {
|
||||
getCloudtentaclesSessionState,
|
||||
getCloudtentaclesSessionStateByKey,
|
||||
} from "../../platforms/cloudtentacles/session-state-service.js";
|
||||
import { normalizeStringArray } from "./domain.js";
|
||||
|
||||
export function resolvePersistedCloudtentaclesContext(sourceKey = "default") {
|
||||
const source =
|
||||
getCloudtentaclesSourceByKey(sourceKey) || getCloudtentaclesSourceConfig();
|
||||
const session =
|
||||
getCloudtentaclesSessionStateByKey(sourceKey) ||
|
||||
getCloudtentaclesSessionState();
|
||||
const token = String(session.token || "").trim();
|
||||
|
||||
if (!token) {
|
||||
throw createHttpError(
|
||||
"当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验",
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_missing_cloud_token",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl:
|
||||
String(session.baseUrl || source.baseUrl || "").trim() ||
|
||||
"https://123.207.217.176",
|
||||
token,
|
||||
deviceId: String(session.deviceId || source.deviceId || "-").trim() || "-",
|
||||
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级依次尝试 cloudSourceKey 和 fallbacks 列表中的账号,
|
||||
* 找到第一个有可用 token 的账号返回其 context。
|
||||
* 将实际使用的 resolvedSourceKey 也返回,确保后续操作(退号、发货等)
|
||||
* 使用同一个 sourceKey,防止跨账号操作导致数据不一致。
|
||||
*/
|
||||
export function resolvePersistedCloudtentaclesContextWithFallback(
|
||||
primarySourceKey = "default",
|
||||
fallbacks = []
|
||||
) {
|
||||
const candidates = [
|
||||
String(primarySourceKey || "default").trim() || "default",
|
||||
...normalizeStringArray(fallbacks),
|
||||
];
|
||||
|
||||
const uniqueCandidates = [...new Set(candidates)];
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (const sourceKey of uniqueCandidates) {
|
||||
const source = getCloudtentaclesSourceByKey(sourceKey);
|
||||
const session = getCloudtentaclesSessionStateByKey(sourceKey);
|
||||
|
||||
if (!source) continue;
|
||||
|
||||
const token = String(session?.token || "").trim();
|
||||
if (!token) {
|
||||
lastError = createHttpError(
|
||||
`cloudtentacles 账号 ${sourceKey} 没有可用 token`,
|
||||
{ statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl:
|
||||
String(session.baseUrl || source.baseUrl || "").trim() ||
|
||||
"https://123.207.217.176",
|
||||
token,
|
||||
deviceId:
|
||||
String(session.deviceId || source.deviceId || "-").trim() || "-",
|
||||
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||
resolvedSourceKey: sourceKey,
|
||||
};
|
||||
}
|
||||
|
||||
throw (
|
||||
lastError ||
|
||||
createHttpError(
|
||||
"所有 cloudtentacles 备选账号均不可用,请先到平台配置完成登录校验",
|
||||
{ statusCode: 409, errorCode: "kuaishou_cloud_all_source_keys_exhausted" }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js";
|
||||
|
||||
export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1";
|
||||
|
||||
export type JsonObject = Record<string, any>;
|
||||
|
||||
export function isKuaishouCloudTask(task) {
|
||||
return String(task?.executor_key || "").trim() === "kuaishou_ct_assisted";
|
||||
}
|
||||
|
||||
export function normalizeKuaishouCloudFlow(value) {
|
||||
const source = value && typeof value === "object" ? value : {};
|
||||
const binding =
|
||||
source.binding && typeof source.binding === "object" ? source.binding : {};
|
||||
const role =
|
||||
source.role && typeof source.role === "object" ? source.role : {};
|
||||
const purchase =
|
||||
source.purchase && typeof source.purchase === "object"
|
||||
? source.purchase
|
||||
: {};
|
||||
const dispatch =
|
||||
source.dispatch && typeof source.dispatch === "object"
|
||||
? source.dispatch
|
||||
: {};
|
||||
const returnNumber =
|
||||
source.returnNumber && typeof source.returnNumber === "object"
|
||||
? source.returnNumber
|
||||
: {};
|
||||
const consume =
|
||||
source.consume && typeof source.consume === "object" ? source.consume : {};
|
||||
const ticket =
|
||||
source.ticket && typeof source.ticket === "object" ? source.ticket : {};
|
||||
|
||||
const roleName = String(role.name || binding.roleName || "").trim();
|
||||
const roleId = String(role.rid || binding.roleId || "").trim();
|
||||
const bindPreparedAt = binding.bindPreparedAt || null;
|
||||
const bindExpiresAt =
|
||||
binding.bindExpiresAt ||
|
||||
resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt);
|
||||
|
||||
return {
|
||||
...source,
|
||||
configId: String(source.configId || "").trim(),
|
||||
internalSkuCode: String(source.internalSkuCode || "").trim(),
|
||||
internalSkuName: String(source.internalSkuName || "").trim(),
|
||||
ticket: {
|
||||
code: String(ticket.code || "").trim(),
|
||||
status: String(ticket.status || "pending").trim() || "pending",
|
||||
capturedAt: ticket.capturedAt || null,
|
||||
capturedBy: ticket.capturedBy || null,
|
||||
verifiedAt: ticket.verifiedAt || null,
|
||||
oid: String(ticket.oid || "").trim(),
|
||||
formToken: String(ticket.formToken || "").trim(),
|
||||
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||
goodsTitle: String(ticket.goodsTitle || "").trim(),
|
||||
},
|
||||
binding: {
|
||||
prepareStatus:
|
||||
String(binding.prepareStatus || "pending").trim() || "pending",
|
||||
cloudSourceKey:
|
||||
String(binding.cloudSourceKey || "default").trim() || "default",
|
||||
cloudSourceKeyFallbacks: normalizeStringArray(
|
||||
binding.cloudSourceKeyFallbacks
|
||||
),
|
||||
resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(),
|
||||
skuId: Number(binding.skuId || 0) || 0,
|
||||
skuName: String(binding.skuName || "").trim(),
|
||||
vnKey:
|
||||
String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() ||
|
||||
KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||
vnId: Number(binding.vnId || 0) || 0,
|
||||
vnPhone: String(binding.vnPhone || "").trim(),
|
||||
bindUrl: String(binding.bindUrl || "").trim(),
|
||||
bindPreparedAt,
|
||||
bindExpiresAt,
|
||||
bindProbeAt: binding.bindProbeAt || null,
|
||||
bindProbeStatus: String(binding.bindProbeStatus || "").trim(),
|
||||
bindProbeMessage: String(binding.bindProbeMessage || "").trim(),
|
||||
roleName,
|
||||
roleId,
|
||||
},
|
||||
role: {
|
||||
status:
|
||||
String(
|
||||
role.status || (roleName || roleId ? "ready" : "pending")
|
||||
).trim() || "pending",
|
||||
name: roleName,
|
||||
rid: roleId,
|
||||
refreshedAt: role.refreshedAt || null,
|
||||
errorMessage: String(role.errorMessage || "").trim(),
|
||||
rawInfo:
|
||||
role.rawInfo && typeof role.rawInfo === "object" ? role.rawInfo : null,
|
||||
},
|
||||
purchase: {
|
||||
autoBuyEnabled: purchase.autoBuyEnabled !== false,
|
||||
minAssetReserve: Number(purchase.minAssetReserve || 0) || 0,
|
||||
usedKnapsack: purchase.usedKnapsack === true,
|
||||
purchaseTriggered: purchase.purchaseTriggered === true,
|
||||
assetBefore: Number(purchase.assetBefore || 0) || 0,
|
||||
assetAfter: Number(purchase.assetAfter || 0) || 0,
|
||||
purchaseAt: purchase.purchaseAt || null,
|
||||
},
|
||||
dispatch: {
|
||||
status: String(dispatch.status || "pending").trim() || "pending",
|
||||
dispatchAt: dispatch.dispatchAt || null,
|
||||
dispatchBy: dispatch.dispatchBy || null,
|
||||
sendType: Number(dispatch.sendType || 0) || 0,
|
||||
note: String(dispatch.note || "").trim(),
|
||||
},
|
||||
returnNumber: {
|
||||
status: String(returnNumber.status || "pending").trim() || "pending",
|
||||
returnedAt: returnNumber.returnedAt || null,
|
||||
returnedBy: returnNumber.returnedBy || null,
|
||||
autoReturnEnabled: returnNumber.autoReturnEnabled === true,
|
||||
},
|
||||
consume: {
|
||||
status: String(consume.status || "pending").trim() || "pending",
|
||||
shopId: String(consume.shopId || "").trim(),
|
||||
shopName: String(consume.shopName || "").trim(),
|
||||
autoConsumeEnabled: consume.autoConsumeEnabled === true,
|
||||
consumedAt: consume.consumedAt || null,
|
||||
errorMessage: String(consume.errorMessage || "").trim(),
|
||||
},
|
||||
notes: String(source.notes || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKuaishouCloudRoleInfo(value) {
|
||||
const rawInfo = value && typeof value === "object" ? value : null;
|
||||
const nestedBindInfo =
|
||||
rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object"
|
||||
? rawInfo.sBindInfo
|
||||
: null;
|
||||
const source = nestedBindInfo || rawInfo;
|
||||
|
||||
return {
|
||||
name: String(
|
||||
source?.name ||
|
||||
source?.roleName ||
|
||||
source?.nickname ||
|
||||
source?.sRoleName ||
|
||||
""
|
||||
).trim(),
|
||||
rid: String(
|
||||
source?.rid ||
|
||||
source?.roleId ||
|
||||
source?.uid ||
|
||||
source?.sRoleId ||
|
||||
source?.sUserId ||
|
||||
""
|
||||
).trim(),
|
||||
rawInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskPhone(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (text.length <= 7) {
|
||||
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
||||
}
|
||||
|
||||
return `${text.slice(0, 3)}****${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function maskCode(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (text.length <= 8) {
|
||||
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
||||
}
|
||||
|
||||
return `${text.slice(0, 4)}****${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) {
|
||||
const preparedTime = Date.parse(String(preparedAt || ""));
|
||||
if (!Number.isFinite(preparedTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = resolveCloudtentaclesConfig();
|
||||
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600);
|
||||
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString();
|
||||
}
|
||||
|
||||
export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) {
|
||||
const normalizedFlow = normalizeKuaishouCloudFlow(flow);
|
||||
if (!normalizedFlow.binding.bindUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = normalizedFlow.binding.bindExpiresAt;
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresTime = Date.parse(String(expiresAt || ""));
|
||||
if (!Number.isFinite(expiresTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expiresTime > now.getTime();
|
||||
}
|
||||
|
||||
export function normalizeStringArray(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => String(v || "").trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export function normalizeActor(actor) {
|
||||
if (!actor || typeof actor !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = String(actor.source || "").trim();
|
||||
const userId = Number(actor.userId || 0) || 0;
|
||||
const username = String(actor.username || "").trim();
|
||||
const role = String(actor.role || "").trim();
|
||||
|
||||
if (!source && !userId && !username && !role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
userId,
|
||||
username,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTaskContext(task) {
|
||||
const value = task?.context_json;
|
||||
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || "{}"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskClaimExpiresAt(task) {
|
||||
return task?.claim_expires_at || task?.primary_claim_expires_at || null;
|
||||
}
|
||||
|
||||
export function isClaimExpired(expiredAt) {
|
||||
if (!expiredAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamp = new Date(expiredAt).getTime();
|
||||
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { getOrderById } from "../../../repositories/order-repo.js";
|
||||
import { createTaskEvent } from "../../../repositories/task-event-repo.js";
|
||||
import { updateTask } from "../../../repositories/task-repo.js";
|
||||
import { createHttpError } from "../../../utils/http.js";
|
||||
import { nowIso } from "../../../utils/time.js";
|
||||
import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-notifications.js";
|
||||
import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js";
|
||||
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
} from "../../platforms/kuaishou-eticket/source-config-service.js";
|
||||
import {
|
||||
isKuaishouCloudTask,
|
||||
maskCode,
|
||||
maskPhone,
|
||||
normalizeKuaishouCloudFlow,
|
||||
type JsonObject,
|
||||
} from "./domain.js";
|
||||
import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js";
|
||||
import { normalizeActor, parseTaskContext } from "./task-context.js";
|
||||
|
||||
export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
task,
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_task_invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const actor = normalizeActor(options.actor);
|
||||
const now = nowIso();
|
||||
const taskContext = parseTaskContext(task);
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
||||
const effectiveSourceKey =
|
||||
flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
|
||||
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
|
||||
effectiveSourceKey,
|
||||
flow.binding.cloudSourceKeyFallbacks || []
|
||||
);
|
||||
|
||||
if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) {
|
||||
throw createHttpError(
|
||||
"当前任务还没有准备好绑定资源,请先完成绑定资源准备",
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_not_prepared",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const ticketCode = String(options.ticketCode || "").trim();
|
||||
const persistedTicketCode = String(flow.ticket.code || "").trim();
|
||||
if (!persistedTicketCode && !ticketCode) {
|
||||
throw createHttpError("客户还没有提交有效核销码,暂时不能继续兑换", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_missing_ticket_code",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
flow.dispatch.status === "success" &&
|
||||
String(task.task_status || "").trim() === "dispatched_pending_return"
|
||||
) {
|
||||
return { task, flow };
|
||||
}
|
||||
|
||||
const dispatchResult = await useCloudtentaclesSku({
|
||||
...cloudContext,
|
||||
id: flow.binding.skuId,
|
||||
virtualNumberId: flow.binding.vnId,
|
||||
phone: flow.binding.vnPhone,
|
||||
});
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: ticketCode || persistedTicketCode,
|
||||
capturedAt: ticketCode ? now : flow.ticket.capturedAt,
|
||||
capturedBy: ticketCode && actor ? actor : flow.ticket.capturedBy,
|
||||
},
|
||||
dispatch: {
|
||||
...flow.dispatch,
|
||||
status: "success",
|
||||
dispatchAt: now,
|
||||
dispatchBy: actor,
|
||||
sendType: Number(dispatchResult.sendType || 0) || 0,
|
||||
note: String(
|
||||
dispatchResult.note ||
|
||||
dispatchResult.responseMessage ||
|
||||
"cloudtentacles 发货成功"
|
||||
).trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let updatedTask = await updateTask(task.id, {
|
||||
task_status: "dispatched_pending_return",
|
||||
delivery_status: "delivered",
|
||||
result_code: "kuaishou_cloud_dispatched",
|
||||
result_message: String(
|
||||
dispatchResult.responseMessage ||
|
||||
dispatchResult.note ||
|
||||
"cloudtentacles 发货成功"
|
||||
).trim(),
|
||||
user_action_status: "not_required",
|
||||
last_error: "",
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
"kuaishou_cloud_dispatched",
|
||||
{
|
||||
source: String(options.source || "system").trim() || "system",
|
||||
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
|
||||
skuId: flow.binding.skuId,
|
||||
vnId: flow.binding.vnId,
|
||||
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||
sendType: dispatchResult.sendType,
|
||||
note: dispatchResult.note,
|
||||
actor,
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
const shouldAutoFinalize =
|
||||
options.autoFinalize === true &&
|
||||
normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
|
||||
.returnNumber.autoReturnEnabled === true;
|
||||
|
||||
if (shouldAutoFinalize) {
|
||||
const finalizeResult = await returnKuaishouCloudFulfillmentTask(
|
||||
updatedTask,
|
||||
{
|
||||
actor,
|
||||
source: options.source || "system_auto_finalize",
|
||||
}
|
||||
);
|
||||
updatedTask = finalizeResult.task;
|
||||
}
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
flow: normalizeKuaishouCloudFlow(
|
||||
parseTaskContext(updatedTask).kuaishouCloudFulfillment
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function returnKuaishouCloudFulfillmentTask(
|
||||
task,
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_task_invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const actor = normalizeActor(options.actor);
|
||||
const now = nowIso();
|
||||
const taskContext = parseTaskContext(task);
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
||||
const effectiveSourceKey =
|
||||
flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
|
||||
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
|
||||
effectiveSourceKey,
|
||||
flow.binding.cloudSourceKeyFallbacks || []
|
||||
);
|
||||
|
||||
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
||||
throw createHttpError("当前任务缺少可退还的虚拟号信息", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_missing_return_context",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
flow.returnNumber.status === "success" &&
|
||||
["completed", "manual_review"].includes(
|
||||
String(task.task_status || "").trim()
|
||||
)
|
||||
) {
|
||||
return { task, flow };
|
||||
}
|
||||
|
||||
await backCloudtentaclesVirtualNumber({
|
||||
...cloudContext,
|
||||
key: flow.binding.vnKey,
|
||||
id: flow.binding.vnId,
|
||||
});
|
||||
|
||||
const order = await getOrderById(task.order_id);
|
||||
const ticketCode = String(flow.ticket.code || "").trim();
|
||||
const shopId = String(flow.consume.shopId || order?.shop_id || "").trim();
|
||||
const shopName = String(
|
||||
flow.consume.shopName || order?.shop_name || ""
|
||||
).trim();
|
||||
const eticketSource = getKuaishouEticketSourceConfig();
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
shopName,
|
||||
});
|
||||
|
||||
let consumeStatus = "pending";
|
||||
let consumeErrorMessage = "";
|
||||
let consumedAt = null;
|
||||
let nextTaskStatus = "completed";
|
||||
let nextResultCode = "kuaishou_cloud_completed";
|
||||
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
|
||||
|
||||
if (!order) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
|
||||
} else if (!ticketCode) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销";
|
||||
} else if (
|
||||
!shopConfig ||
|
||||
shopConfig.enabled === false ||
|
||||
!String(shopConfig.cookie || "").trim()
|
||||
) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = "订单对应快手小店缺少可用 Cookie,无法执行快手核销";
|
||||
} else {
|
||||
try {
|
||||
const consumeResult = await consumeKuaishouEticket({
|
||||
baseUrl: eticketSource.baseUrl,
|
||||
cookie: shopConfig.cookie,
|
||||
eTicketId: ticketCode,
|
||||
oid: String(flow.ticket.oid || "").trim(),
|
||||
formToken: String(flow.ticket.formToken || "").trim(),
|
||||
});
|
||||
|
||||
if (consumeResult.consumed) {
|
||||
consumeStatus = "success";
|
||||
consumedAt = now;
|
||||
} else {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = String(
|
||||
consumeResult.errorMessage || "快手核销失败"
|
||||
).trim();
|
||||
}
|
||||
} catch (error) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage =
|
||||
error instanceof Error ? error.message : "快手核销失败";
|
||||
}
|
||||
}
|
||||
|
||||
if (consumeStatus !== "success") {
|
||||
nextTaskStatus = "manual_review";
|
||||
nextResultCode = "kuaishou_cloud_consume_failed";
|
||||
nextResultMessage =
|
||||
consumeErrorMessage || "号码已退还,但快手核销未完成,请人工处理";
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
returnNumber: {
|
||||
...flow.returnNumber,
|
||||
status: "success",
|
||||
returnedAt: now,
|
||||
returnedBy: actor,
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
status: consumeStatus,
|
||||
shopId: shopId || flow.consume.shopId,
|
||||
shopName,
|
||||
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
|
||||
consumedAt,
|
||||
errorMessage: consumeErrorMessage,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
delivery_status: "delivered",
|
||||
result_code: nextResultCode,
|
||||
result_message: nextResultMessage,
|
||||
redeemed_at: consumeStatus === "success" ? now : task.redeemed_at,
|
||||
last_error: consumeErrorMessage,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
"kuaishou_cloud_number_returned",
|
||||
{
|
||||
source: String(options.source || "system").trim() || "system",
|
||||
vnId: flow.binding.vnId,
|
||||
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||
actor,
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
consumeStatus === "success"
|
||||
? "kuaishou_cloud_consumed"
|
||||
: "kuaishou_cloud_consume_failed",
|
||||
{
|
||||
source: String(options.source || "system").trim() || "system",
|
||||
ticketCodeMasked: maskCode(ticketCode),
|
||||
shopId,
|
||||
shopName,
|
||||
consumeStatus,
|
||||
errorMessage: consumeErrorMessage,
|
||||
actor,
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
if (consumeStatus !== "success") {
|
||||
await notifyKuaishouCloudConsumeFailed({
|
||||
task: updatedTask,
|
||||
order,
|
||||
ticketCodeMasked: maskCode(ticketCode),
|
||||
shopId,
|
||||
shopName,
|
||||
errorMessage: consumeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
flow: normalizeKuaishouCloudFlow(
|
||||
parseTaskContext(updatedTask).kuaishouCloudFulfillment
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user