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"; import { backCloudtentaclesVirtualNumber, getCloudtentaclesBindInfo, probeCloudtentaclesBindUrl, } 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"; import { nowIso } from "../../../utils/time.js"; import { KUAISHOU_CLOUD_FIXED_VN_KEY, isKuaishouCloudBindUrlFresh, isKuaishouCloudTask, maskPhone, normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, resolveKuaishouCloudBindUrlExpiresAt, type JsonObject, } from "./domain.js"; import { prepareKuaishouCloudBindResourceWithFallback, resolveKuaishouCloudBindingResources, resolveKuaishouCloudVnKeyCandidates, } from "./binding-resources.js"; import { resolvePersistedCloudtentaclesContextWithFallback, } from "./cloudtentacles-context.js"; import { getTaskClaimExpiresAt, isClaimExpired, normalizeActor, parseTaskContext, } from "./task-context.js"; import type { TaskRow } from "../../../types/repository/rows.js"; export { isKuaishouCloudBindUrlFresh, isKuaishouCloudTask, maskCode, maskPhone, normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, resolveKuaishouCloudBindUrlExpiresAt, } from "./domain.js"; export { resolvePersistedCloudtentaclesContext, resolvePersistedCloudtentaclesContextWithFallback, } from "./cloudtentacles-context.js"; export async function ensureTaskClaimLink(task: TaskRow) { 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: TaskRow, options: JsonObject = {} ) { 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, })) || task; } return { task: readyTask, claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, flow, }; } const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( flow.binding.cloudSourceKey || "default", flow.binding.cloudSourceKeyFallbacks || [] ); 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, resolvedSourceKey: cloudContext.resolvedSourceKey, vnKey: preparedBinding.vnKey, prepareStatus: "ready", vnId: preparedBinding.vnId, vnPhone: preparedBinding.vnPhone, bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), bindProbeAt: null as null, bindProbeStatus: "pending", bindProbeMessage: "", roleName: "", roleId: "", }, role: { status: "pending", name: "", rid: "", refreshedAt: null as null, errorMessage: "", rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, usedKnapsack, purchaseTriggered, assetBefore, assetAfter, purchaseAt: purchaseTriggered ? now : flowWithResolvedBinding.purchase.purchaseAt, }, }, }; const updatedTask = await updateTask(task.id, { task_status: "waiting_binding", 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: TaskRow, options: JsonObject = {} ) { 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 effectiveSourceKey = flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( effectiveSourceKey, flow.binding.cloudSourceKeyFallbacks || [] ); 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 as 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: TaskRow, options: JsonObject = {} ) { 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 as 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 as 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: TaskRow, { taskContext, flow, now, actor, error }: JsonObject = {} ) { 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: TaskRow, options: JsonObject = {} ) { 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 effectiveSourceKey = flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( effectiveSourceKey, flow.binding.cloudSourceKeyFallbacks || [] ); 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 { dispatchKuaishouCloudFulfillmentTask, returnKuaishouCloudFulfillmentTask, } from "./task-finalization.js";