1564 lines
44 KiB
JavaScript
1564 lines
44 KiB
JavaScript
// @ts-check
|
|
|
|
import { createTaskEvent } from "../../repositories/task-event-repo.js";
|
|
import { getOrderById } from "../../repositories/order-repo.js";
|
|
import { updateTask } from "../../repositories/task-repo.js";
|
|
import { buildClaimUrl, createTaskClaimToken } from "../claim/claim-service.js";
|
|
import { normalizeProductName } from "../order/product-match-service.js";
|
|
import {
|
|
getCloudtentaclesSourceConfig,
|
|
getCloudtentaclesSourceByKey,
|
|
} from "../platforms/cloudtentacles/source-config-service.js";
|
|
import {
|
|
getCloudtentaclesSessionState,
|
|
getCloudtentaclesSessionStateByKey,
|
|
} from "../platforms/cloudtentacles/session-state-service.js";
|
|
import {
|
|
buyCloudtentaclesSku,
|
|
getCloudtentaclesAsset,
|
|
listCloudtentaclesSku,
|
|
useCloudtentaclesSku,
|
|
} from "../platforms/cloudtentacles/catalog-service.js";
|
|
import { getCloudtentaclesKnapsack } from "../platforms/cloudtentacles/knapsack-service.js";
|
|
import {
|
|
appointCloudtentaclesVirtualNumber,
|
|
backCloudtentaclesVirtualNumber,
|
|
fetchCloudtentaclesVirtualNumberCode,
|
|
generateCloudtentaclesLoginCode,
|
|
getCloudtentaclesBindInfo,
|
|
getCloudtentaclesBindUrl,
|
|
probeCloudtentaclesBindUrl,
|
|
verifyCloudtentaclesLoginCode,
|
|
} from "../platforms/cloudtentacles/virtual-number-service.js";
|
|
import { resolveCloudtentaclesConfig } from "../platforms/cloudtentacles/shared.js";
|
|
import { consumeKuaishouEticket } from "../platforms/kuaishou-eticket/consume-service.js";
|
|
import {
|
|
getKuaishouEticketSourceConfig,
|
|
resolveKuaishouEticketShopConfig,
|
|
} from "../platforms/kuaishou-eticket/source-config-service.js";
|
|
import {
|
|
notifyKuaishouCloudAssetNotEnough,
|
|
notifyKuaishouCloudBindUrlRefreshFailed,
|
|
notifyKuaishouCloudConsumeFailed,
|
|
} from "../notification/domain-notifications.js";
|
|
import { createHttpError } from "../../utils/http.js";
|
|
import { nowIso } from "../../utils/time.js";
|
|
|
|
const KUAISHOU_CLOUD_FIXED_VN_KEY = "1";
|
|
|
|
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",
|
|
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 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),
|
|
};
|
|
}
|
|
|
|
export async function ensureTaskClaimLink(task) {
|
|
const tokenStatus = String(task?.primary_claim_token_status || "").trim();
|
|
const token = String(
|
|
task?.primary_claim_token || task?.claim_token || ""
|
|
).trim();
|
|
const expiredAt = getTaskClaimExpiresAt(task);
|
|
|
|
if (tokenStatus === "active" && token && !isClaimExpired(expiredAt)) {
|
|
return {
|
|
token,
|
|
expiredAt,
|
|
claimUrl: buildClaimUrl(token),
|
|
};
|
|
}
|
|
|
|
const claimToken = await createTaskClaimToken(task.id);
|
|
return {
|
|
token: claimToken.token,
|
|
expiredAt: claimToken.expired_at,
|
|
claimUrl: claimToken.claimUrl,
|
|
};
|
|
}
|
|
|
|
export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
|
|
if (!isKuaishouCloudTask(task)) {
|
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_task_invalid",
|
|
});
|
|
}
|
|
|
|
const now = nowIso();
|
|
const actor = normalizeActor(options.actor);
|
|
const force = options.force === true;
|
|
const taskContext = parseTaskContext(task);
|
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
|
const claimLinkState = await ensureTaskClaimLink(task);
|
|
|
|
if (
|
|
!force &&
|
|
flow.binding.prepareStatus === "ready" &&
|
|
flow.binding.vnId > 0 &&
|
|
flow.binding.vnPhone &&
|
|
flow.binding.bindUrl
|
|
) {
|
|
if (!isKuaishouCloudBindUrlFresh(flow)) {
|
|
return refreshKuaishouCloudTaskBindUrl(task, {
|
|
source: options.source || "system_refresh_expired_bind_url",
|
|
actor,
|
|
claimLinkState,
|
|
});
|
|
}
|
|
|
|
let readyTask = task;
|
|
|
|
if (String(task.task_status || "").trim() !== "waiting_binding") {
|
|
readyTask = await updateTask(task.id, {
|
|
task_status: "waiting_binding",
|
|
claim_token: claimLinkState.token || task.claim_token || "",
|
|
claim_expires_at:
|
|
claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
|
updated_at: now,
|
|
});
|
|
}
|
|
|
|
return {
|
|
task: readyTask,
|
|
claimUrl: claimLinkState.claimUrl,
|
|
token: claimLinkState.token,
|
|
flow,
|
|
};
|
|
}
|
|
|
|
const cloudContext = resolvePersistedCloudtentaclesContext(
|
|
flow.binding.cloudSourceKey || "default"
|
|
);
|
|
const [knapsack, skuList] = await Promise.all([
|
|
getCloudtentaclesKnapsack(cloudContext),
|
|
listCloudtentaclesSku(cloudContext),
|
|
]);
|
|
const resolvedBinding = resolveKuaishouCloudBindingResources(flow, {
|
|
skuItems: Array.isArray(skuList.items) ? skuList.items : [],
|
|
knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [],
|
|
});
|
|
const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({
|
|
flow,
|
|
binding: resolvedBinding,
|
|
});
|
|
|
|
if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) {
|
|
throw createHttpError("当前任务缺少可用 cloud 资源,无法自动准备绑定", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_missing_binding_config",
|
|
});
|
|
}
|
|
|
|
const flowWithResolvedBinding = {
|
|
...flow,
|
|
binding: {
|
|
...flow.binding,
|
|
skuId: resolvedBinding.skuId,
|
|
skuName: resolvedBinding.skuName,
|
|
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
|
},
|
|
};
|
|
|
|
const knapsackItem = resolvedBinding.knapsackItem;
|
|
let usedKnapsack = Number(knapsackItem?.count || 0) > 0;
|
|
let purchaseTriggered = false;
|
|
let assetBefore = 0;
|
|
let assetAfter = 0;
|
|
|
|
if (!usedKnapsack) {
|
|
if (!flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
|
throw createHttpError("背包中没有现成库存,且当前配置未开启自动购买", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_auto_buy_disabled",
|
|
});
|
|
}
|
|
|
|
const asset = await getCloudtentaclesAsset(cloudContext);
|
|
const targetSku = resolvedBinding.skuItem;
|
|
|
|
if (!targetSku) {
|
|
throw createHttpError(
|
|
`cloudtentacles 未找到 SKU ${flowWithResolvedBinding.binding.skuId}`,
|
|
{
|
|
statusCode: 404,
|
|
errorCode: "kuaishou_cloud_sku_not_found",
|
|
}
|
|
);
|
|
}
|
|
|
|
assetBefore = Number(asset.asset || 0) || 0;
|
|
const targetPrice = Number(targetSku.price || 0) || 0;
|
|
const requiredAsset =
|
|
targetPrice + flowWithResolvedBinding.purchase.minAssetReserve;
|
|
if (assetBefore < requiredAsset) {
|
|
await notifyKuaishouCloudAssetNotEnough({
|
|
task,
|
|
flow: flowWithResolvedBinding,
|
|
assetBefore,
|
|
requiredAsset,
|
|
skuName: targetSku.name,
|
|
});
|
|
throw createHttpError(
|
|
`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`,
|
|
{
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_asset_not_enough",
|
|
}
|
|
);
|
|
}
|
|
|
|
await buyCloudtentaclesSku({
|
|
...cloudContext,
|
|
id: flowWithResolvedBinding.binding.skuId,
|
|
count: 1,
|
|
});
|
|
purchaseTriggered = true;
|
|
|
|
const assetResult = await getCloudtentaclesAsset(cloudContext);
|
|
assetAfter = Number(assetResult.asset || 0) || 0;
|
|
}
|
|
|
|
const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
|
cloudContext,
|
|
vnKeyCandidates,
|
|
});
|
|
|
|
const nextContext = {
|
|
...taskContext,
|
|
kuaishouCloudFulfillment: {
|
|
...flowWithResolvedBinding,
|
|
binding: {
|
|
...flowWithResolvedBinding.binding,
|
|
vnKey: preparedBinding.vnKey,
|
|
prepareStatus: "ready",
|
|
vnId: preparedBinding.vnId,
|
|
vnPhone: preparedBinding.vnPhone,
|
|
bindUrl: preparedBinding.bindUrl,
|
|
bindPreparedAt: now,
|
|
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
|
|
bindProbeAt: null,
|
|
bindProbeStatus: "pending",
|
|
bindProbeMessage: "",
|
|
roleName: "",
|
|
roleId: "",
|
|
},
|
|
role: {
|
|
status: "pending",
|
|
name: "",
|
|
rid: "",
|
|
refreshedAt: null,
|
|
errorMessage: "",
|
|
rawInfo: null,
|
|
},
|
|
purchase: {
|
|
...flowWithResolvedBinding.purchase,
|
|
usedKnapsack,
|
|
purchaseTriggered,
|
|
assetBefore,
|
|
assetAfter,
|
|
purchaseAt: purchaseTriggered
|
|
? now
|
|
: flowWithResolvedBinding.purchase.purchaseAt,
|
|
},
|
|
},
|
|
};
|
|
|
|
const updatedTask = await updateTask(task.id, {
|
|
task_status: "waiting_binding",
|
|
inventory_status: "not_required",
|
|
user_action_status: "pending_claim",
|
|
claim_token: claimLinkState.token || task.claim_token || "",
|
|
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
|
role_id: "",
|
|
role_name: "",
|
|
last_error: "",
|
|
context_json: JSON.stringify(nextContext),
|
|
updated_at: now,
|
|
});
|
|
|
|
await createTaskEvent(
|
|
task.id,
|
|
"kuaishou_cloud_binding_prepared",
|
|
{
|
|
source: String(options.source || "system").trim() || "system",
|
|
skuId: flowWithResolvedBinding.binding.skuId,
|
|
skuName: flowWithResolvedBinding.binding.skuName,
|
|
vnKey: preparedBinding.vnKey,
|
|
vnId: preparedBinding.vnId,
|
|
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
|
|
purchaseTriggered,
|
|
usedKnapsack,
|
|
resolvedByName: resolvedBinding.resolvedByName,
|
|
actor,
|
|
},
|
|
now
|
|
);
|
|
|
|
return {
|
|
task: updatedTask,
|
|
claimUrl: claimLinkState.claimUrl,
|
|
token: claimLinkState.token,
|
|
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
|
};
|
|
}
|
|
|
|
export async function refreshKuaishouCloudTaskBindUrl(task, options = {}) {
|
|
if (!isKuaishouCloudTask(task)) {
|
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_task_invalid",
|
|
});
|
|
}
|
|
|
|
const now = nowIso();
|
|
const actor = normalizeActor(options.actor);
|
|
const taskContext = parseTaskContext(task);
|
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
|
|
|
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
|
throw createHttpError("当前任务缺少可刷新绑定链接的虚拟号信息", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_missing_bind_url_context",
|
|
});
|
|
}
|
|
|
|
const cloudContext = resolvePersistedCloudtentaclesContext(
|
|
flow.binding.cloudSourceKey || "default"
|
|
);
|
|
const oldVnKey = flow.binding.vnKey;
|
|
const oldVnId = flow.binding.vnId;
|
|
const oldVnPhone = flow.binding.vnPhone;
|
|
|
|
await backCloudtentaclesVirtualNumber({
|
|
...cloudContext,
|
|
key: oldVnKey,
|
|
id: oldVnId,
|
|
});
|
|
|
|
await createTaskEvent(
|
|
task.id,
|
|
"kuaishou_cloud_expired_bind_number_returned",
|
|
{
|
|
source: String(options.source || "system").trim() || "system",
|
|
vnKey: oldVnKey,
|
|
vnId: oldVnId,
|
|
vnPhoneMasked: maskPhone(oldVnPhone),
|
|
actor,
|
|
},
|
|
now
|
|
);
|
|
|
|
let preparedBinding;
|
|
try {
|
|
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
|
cloudContext,
|
|
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
|
|
flow,
|
|
binding: flow.binding,
|
|
}),
|
|
});
|
|
|
|
if (!String(preparedBinding.bindUrl || "").trim()) {
|
|
throw createHttpError("cloudtentacles 未返回新的绑定链接", {
|
|
statusCode: 502,
|
|
errorCode: "kuaishou_cloud_empty_bind_url",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, {
|
|
taskContext,
|
|
flow,
|
|
now,
|
|
actor,
|
|
error,
|
|
});
|
|
|
|
return {
|
|
task: failedTask,
|
|
claimUrl: buildClaimUrl(
|
|
String(task.primary_claim_token || task.claim_token || "")
|
|
),
|
|
token: String(task.primary_claim_token || task.claim_token || ""),
|
|
flow: normalizeKuaishouCloudFlow(
|
|
parseTaskContext(failedTask).kuaishouCloudFulfillment
|
|
),
|
|
};
|
|
}
|
|
|
|
const nextContext = {
|
|
...taskContext,
|
|
kuaishouCloudFulfillment: {
|
|
...flow,
|
|
binding: {
|
|
...flow.binding,
|
|
prepareStatus: "ready",
|
|
vnKey: preparedBinding.vnKey,
|
|
vnId: preparedBinding.vnId,
|
|
vnPhone: preparedBinding.vnPhone,
|
|
bindUrl: preparedBinding.bindUrl,
|
|
bindPreparedAt: now,
|
|
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
|
|
bindProbeAt: null,
|
|
bindProbeStatus: "pending",
|
|
bindProbeMessage: "",
|
|
roleName:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? flow.binding.roleName
|
|
: "",
|
|
roleId:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? flow.binding.roleId
|
|
: "",
|
|
},
|
|
role:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? flow.role
|
|
: {
|
|
status: "pending",
|
|
name: "",
|
|
rid: "",
|
|
refreshedAt: null,
|
|
errorMessage: "",
|
|
rawInfo: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
const claimLinkState =
|
|
options.claimLinkState || (await ensureTaskClaimLink(task));
|
|
const updatedTask = await updateTask(task.id, {
|
|
task_status:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? task.task_status
|
|
: "waiting_binding",
|
|
claim_token: claimLinkState.token || task.claim_token || "",
|
|
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
|
role_id:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? task.role_id
|
|
: "",
|
|
role_name:
|
|
String(task.task_status || "").trim() === "role_confirmed"
|
|
? task.role_name
|
|
: "",
|
|
last_error: "",
|
|
context_json: JSON.stringify(nextContext),
|
|
updated_at: now,
|
|
});
|
|
|
|
await createTaskEvent(
|
|
task.id,
|
|
"kuaishou_cloud_bind_url_refreshed",
|
|
{
|
|
source: String(options.source || "system").trim() || "system",
|
|
oldVnId,
|
|
oldVnPhoneMasked: maskPhone(oldVnPhone),
|
|
vnKey: preparedBinding.vnKey,
|
|
vnId: preparedBinding.vnId,
|
|
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
|
|
actor,
|
|
},
|
|
now
|
|
);
|
|
|
|
return {
|
|
task: updatedTask,
|
|
claimUrl: claimLinkState.claimUrl,
|
|
token: claimLinkState.token,
|
|
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
|
};
|
|
}
|
|
|
|
export async function probeKuaishouCloudTaskBindUrl(task, options = {}) {
|
|
if (!isKuaishouCloudTask(task)) {
|
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_task_invalid",
|
|
});
|
|
}
|
|
|
|
const now = nowIso();
|
|
const taskContext = parseTaskContext(task);
|
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
|
|
|
if (!flow.binding.bindUrl) {
|
|
return {
|
|
task,
|
|
flow,
|
|
probe: null,
|
|
};
|
|
}
|
|
|
|
const probeIntervalMs =
|
|
Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) *
|
|
1000;
|
|
if (
|
|
!options.force &&
|
|
flow.binding.bindProbeAt &&
|
|
Date.now() - Date.parse(flow.binding.bindProbeAt) < probeIntervalMs
|
|
) {
|
|
return {
|
|
task,
|
|
flow,
|
|
probe: null,
|
|
};
|
|
}
|
|
|
|
const probe = await probeCloudtentaclesBindUrl({
|
|
bindUrl: flow.binding.bindUrl,
|
|
});
|
|
|
|
if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) {
|
|
const refreshed = await refreshKuaishouCloudTaskBindUrl(task, {
|
|
source: options.source || "claim_page_bind_url_expired",
|
|
actor: options.actor || { source: "system" },
|
|
});
|
|
return {
|
|
...refreshed,
|
|
probe,
|
|
};
|
|
}
|
|
|
|
const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo);
|
|
const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid);
|
|
const nextContext = {
|
|
...taskContext,
|
|
kuaishouCloudFulfillment: {
|
|
...flow,
|
|
binding: {
|
|
...flow.binding,
|
|
bindProbeAt: now,
|
|
bindProbeStatus: probe.valid ? "valid" : "invalid",
|
|
bindProbeMessage: String(probe.message || probe.reason || "").trim(),
|
|
roleName: hasRoleInfo ? probeRoleInfo.name : flow.binding.roleName,
|
|
roleId: hasRoleInfo ? probeRoleInfo.rid : flow.binding.roleId,
|
|
},
|
|
role: {
|
|
...flow.role,
|
|
status: hasRoleInfo ? "ready" : flow.role.status,
|
|
name: hasRoleInfo ? probeRoleInfo.name : flow.role.name,
|
|
rid: hasRoleInfo ? probeRoleInfo.rid : flow.role.rid,
|
|
refreshedAt: hasRoleInfo ? now : flow.role.refreshedAt,
|
|
errorMessage: hasRoleInfo ? "" : flow.role.errorMessage,
|
|
rawInfo: hasRoleInfo ? probeRoleInfo.rawInfo : flow.role.rawInfo,
|
|
},
|
|
},
|
|
};
|
|
|
|
const updatedTask = await updateTask(task.id, {
|
|
role_id: hasRoleInfo ? probeRoleInfo.rid : task.role_id,
|
|
role_name: hasRoleInfo ? probeRoleInfo.name : task.role_name,
|
|
context_json: JSON.stringify(nextContext),
|
|
updated_at: now,
|
|
});
|
|
|
|
return {
|
|
task: updatedTask,
|
|
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
|
probe,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {any} task
|
|
* @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input]
|
|
*/
|
|
async function markKuaishouCloudBindUrlRefreshFailed(
|
|
task,
|
|
{ taskContext, flow, now, actor, error } = {}
|
|
) {
|
|
const errorMessage =
|
|
error instanceof Error
|
|
? error.message
|
|
: String(error || "新绑定链接准备失败");
|
|
const nextContext = {
|
|
...taskContext,
|
|
kuaishouCloudFulfillment: {
|
|
...flow,
|
|
binding: {
|
|
...flow.binding,
|
|
prepareStatus: "pending",
|
|
vnId: 0,
|
|
vnPhone: "",
|
|
bindUrl: "",
|
|
bindPreparedAt: null,
|
|
bindExpiresAt: null,
|
|
bindProbeAt: now,
|
|
bindProbeStatus: "refresh_failed",
|
|
bindProbeMessage: errorMessage,
|
|
roleName: "",
|
|
roleId: "",
|
|
},
|
|
role: {
|
|
status: "pending",
|
|
name: "",
|
|
rid: "",
|
|
refreshedAt: now,
|
|
errorMessage:
|
|
"绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理",
|
|
rawInfo: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
const updatedTask = await updateTask(task.id, {
|
|
task_status: "pending_binding_prepare",
|
|
role_id: "",
|
|
role_name: "",
|
|
last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`,
|
|
context_json: JSON.stringify(nextContext),
|
|
updated_at: now,
|
|
});
|
|
|
|
await createTaskEvent(
|
|
task.id,
|
|
"kuaishou_cloud_bind_url_refresh_failed",
|
|
{
|
|
source: "system_refresh_expired_bind_url",
|
|
errorMessage,
|
|
actor,
|
|
},
|
|
now
|
|
);
|
|
|
|
await notifyKuaishouCloudBindUrlRefreshFailed({
|
|
task: updatedTask,
|
|
errorMessage,
|
|
});
|
|
|
|
return updatedTask;
|
|
}
|
|
|
|
export async function refreshKuaishouCloudTaskRoleInfo(task, options = {}) {
|
|
if (!isKuaishouCloudTask(task)) {
|
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_task_invalid",
|
|
});
|
|
}
|
|
|
|
const now = nowIso();
|
|
const actor = normalizeActor(options.actor);
|
|
const taskContext = parseTaskContext(task);
|
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
|
|
|
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
|
throw createHttpError(
|
|
"当前任务还没有可查询的绑定角色信息,请先准备绑定资源",
|
|
{
|
|
statusCode: 409,
|
|
errorCode: "kuaishou_cloud_missing_bind_info_context",
|
|
}
|
|
);
|
|
}
|
|
|
|
if (flow.binding.bindUrl) {
|
|
const probed = await probeKuaishouCloudTaskBindUrl(task, {
|
|
source: options.source || "system_role_refresh_bind_url_probe",
|
|
actor,
|
|
force: options.forceProbe === true,
|
|
});
|
|
const probedFlow = normalizeKuaishouCloudFlow(
|
|
parseTaskContext(probed.task).kuaishouCloudFulfillment
|
|
);
|
|
if (probed.probe?.expired) {
|
|
return {
|
|
task: probed.task,
|
|
roleInfo: normalizeKuaishouCloudRoleInfo(null),
|
|
flow: probedFlow,
|
|
};
|
|
}
|
|
|
|
if (
|
|
(probed.probe === null || probed.probe?.valid) &&
|
|
(probedFlow.binding.roleName || probedFlow.binding.roleId)
|
|
) {
|
|
return {
|
|
task: probed.task,
|
|
roleInfo: {
|
|
name: probedFlow.binding.roleName,
|
|
rid: probedFlow.binding.roleId,
|
|
rawInfo: probedFlow.role.rawInfo,
|
|
},
|
|
flow: probedFlow,
|
|
};
|
|
}
|
|
}
|
|
|
|
const cloudContext = resolvePersistedCloudtentaclesContext(
|
|
flow.binding.cloudSourceKey || "default"
|
|
);
|
|
const bindInfoResult = await getCloudtentaclesBindInfo({
|
|
...cloudContext,
|
|
key: flow.binding.vnKey,
|
|
id: flow.binding.vnId,
|
|
});
|
|
|
|
const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo);
|
|
const hasRoleInfo = Boolean(bindInfo.name || bindInfo.rid);
|
|
const nextContext = {
|
|
...taskContext,
|
|
kuaishouCloudFulfillment: {
|
|
...flow,
|
|
binding: {
|
|
...flow.binding,
|
|
roleName: hasRoleInfo ? bindInfo.name : "",
|
|
roleId: hasRoleInfo ? bindInfo.rid : "",
|
|
},
|
|
role: {
|
|
status: hasRoleInfo ? "ready" : "pending",
|
|
name: hasRoleInfo ? bindInfo.name : "",
|
|
rid: hasRoleInfo ? bindInfo.rid : "",
|
|
refreshedAt: now,
|
|
errorMessage: hasRoleInfo
|
|
? ""
|
|
: "当前还没有查询到角色信息,请完成绑定后稍等片刻再试",
|
|
rawInfo: bindInfo.rawInfo,
|
|
},
|
|
},
|
|
};
|
|
|
|
const updatedTask = await updateTask(task.id, {
|
|
role_id: hasRoleInfo ? bindInfo.rid : "",
|
|
role_name: hasRoleInfo ? bindInfo.name : "",
|
|
context_json: JSON.stringify(nextContext),
|
|
updated_at: now,
|
|
});
|
|
|
|
if (options.recordEvent !== false) {
|
|
await createTaskEvent(
|
|
task.id,
|
|
"kuaishou_cloud_role_info_refreshed",
|
|
{
|
|
source: String(options.source || "system").trim() || "system",
|
|
roleName: bindInfo.name,
|
|
roleId: bindInfo.rid,
|
|
vnId: flow.binding.vnId,
|
|
actor,
|
|
},
|
|
now
|
|
);
|
|
}
|
|
|
|
return {
|
|
task: updatedTask,
|
|
roleInfo: bindInfo,
|
|
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
|
};
|
|
}
|
|
|
|
export async function dispatchKuaishouCloudFulfillmentTask(task, options = {}) {
|
|
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 cloudContext = resolvePersistedCloudtentaclesContext(
|
|
flow.binding.cloudSourceKey || "default"
|
|
);
|
|
|
|
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 = {}) {
|
|
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 cloudContext = resolvePersistedCloudtentaclesContext(
|
|
flow.binding.cloudSourceKey || "default"
|
|
);
|
|
|
|
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
|
|
),
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
function resolveKuaishouCloudVnKeyCandidates(_input = {}) {
|
|
return [KUAISHOU_CLOUD_FIXED_VN_KEY];
|
|
}
|
|
|
|
async function prepareKuaishouCloudBindResourceWithFallback(input = {}) {
|
|
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("不支持的游戏类型")
|
|
);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 {};
|
|
}
|
|
}
|
|
|
|
function getTaskClaimExpiresAt(task) {
|
|
return task?.claim_expires_at || task?.primary_claim_expires_at || null;
|
|
}
|
|
|
|
function isClaimExpired(expiredAt) {
|
|
if (!expiredAt) {
|
|
return false;
|
|
}
|
|
|
|
const timestamp = new Date(expiredAt).getTime();
|
|
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
|
}
|