// @ts-check import { getOrderById } from '../../../repositories/order-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { buyCloudtentaclesSku, getCloudtentaclesAsset, listCloudtentaclesSku, useCloudtentaclesSku, } from '../../platforms/cloudtentacles/catalog-service.js' import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' import { backCloudtentaclesVirtualNumber, getCloudtentaclesBindInfo, } 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 { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { createAdminViewerContext, parseTaskContext, } from '../admin-read-shared-helpers.js' import { getRequiredTask, mapTaskActionPayload, } from '../admin-task-read-helpers.js' import { ensureTaskClaimLink, getTaskClaimExpiresAt, maskCode, maskPhone, } from './shared.js' import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, prepareKuaishouCloudBindResourceWithFallback, resolveKuaishouCloudBindUrlExpiresAt, resolveKuaishouCloudBindingResources, resolveKuaishouCloudVnKeyCandidates, resolvePersistedCloudtentaclesContext, } from './kuaishou-cloud-helpers.js' /** @typedef {import('../../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */ /** @typedef {import('../../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */ /** @typedef {import('../../../types/admin-write-inputs.js').AdminTaskKuaishouCloudDispatchInput} AdminTaskKuaishouCloudDispatchInput */ /** @typedef {import('../../../types/admin-write-models.js').AdminTaskActionResponse} AdminTaskActionResponse */ /** @returns {Promise} */ /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = null) { const task = await getRequiredTask(taskId) const now = nowIso() const viewerContext = createAdminViewerContext(session) if (!viewerContext.canManageTaskLifecycle) { throw createHttpError('当前账号没有权限准备绑定资源', { statusCode: 403, errorCode: 'admin_task_prepare_kuaishou_cloud_forbidden', }) } if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是快手 cloud 履约任务', { statusCode: 409, errorCode: 'admin_task_not_kuaishou_cloud', }) } const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const cloudContext = resolvePersistedCloudtentaclesContext() 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 资源,且无法根据商品名自动解析 SKU / VN Key', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_binding_config', }) } const flowWithResolvedBinding = { ...flow, binding: { ...flow.binding, skuId: resolvedBinding.skuId, skuName: resolvedBinding.skuName, vnKey: resolvedBinding.vnKey, }, } const knapsackItem = resolvedBinding.knapsackItem let usedKnapsack = Number(knapsackItem?.count || 0) > 0 let purchaseTriggered = false let assetBefore = 0 let assetAfter = 0 const claimLinkState = await ensureTaskClaimLink(task) if (!usedKnapsack) { if (!flowWithResolvedBinding.purchase.autoBuyEnabled) { throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { statusCode: 409, errorCode: 'admin_task_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: 'admin_task_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) { throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { statusCode: 409, errorCode: 'admin_task_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 vnId = preparedBinding.vnId const vnPhone = preparedBinding.vnPhone const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flowWithResolvedBinding, binding: { ...flowWithResolvedBinding.binding, vnKey: preparedBinding.vnKey, prepareStatus: 'ready', vnId, 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_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), last_error: '', context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', { skuId: flowWithResolvedBinding.binding.skuId, skuName: flowWithResolvedBinding.binding.skuName, vnKey: preparedBinding.vnKey, vnId, vnPhoneMasked: maskPhone(vnPhone), bindUrl: preparedBinding.bindUrl, purchaseTriggered, usedKnapsack, resolvedByName: resolvedBinding.resolvedByName, }, now) return { task: mapTaskActionPayload(updatedTask), claimUrl: claimLinkState.claimUrl, token: claimLinkState.token, } } /** @returns {Promise} */ /** @param {AdminEntityIdInput} taskId */ /** @param {AdminTaskKuaishouCloudDispatchInput} [payload] */ /** @param {AdminViewerSessionInput | null} [session] */ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload = {}, session = null) { const task = await getRequiredTask(taskId) const now = nowIso() const viewerContext = createAdminViewerContext(session) if (!viewerContext.canManageTaskLifecycle) { throw createHttpError('当前账号没有权限执行发货', { statusCode: 403, errorCode: 'admin_task_dispatch_kuaishou_cloud_forbidden', }) } if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是快手 cloud 履约任务', { statusCode: 409, errorCode: 'admin_task_not_kuaishou_cloud', }) } const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const cloudContext = resolvePersistedCloudtentaclesContext() if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { throw createHttpError('当前任务还没有准备好绑定资源,请先准备绑定资源', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_not_prepared', }) } const ticketCode = String(payload.ticketCode || '').trim() const persistedTicketCode = String(flow.ticket.code || '').trim() if (!persistedTicketCode && !ticketCode) { throw createHttpError('客户还没有在领取页提交核销码,暂时不能直接发货', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_ticket_code', }) } 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 && session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } : flow.ticket.capturedBy, }, dispatch: { ...flow.dispatch, status: 'success', dispatchAt: now, dispatchBy: session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } : null, sendType: Number(dispatchResult.sendType || 0) || 0, note: String(dispatchResult.note || dispatchResult.responseMessage || '').trim(), }, }, } const 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(), last_error: '', context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', { ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), skuId: flow.binding.skuId, vnId: flow.binding.vnId, vnPhoneMasked: maskPhone(flow.binding.vnPhone), sendType: dispatchResult.sendType, note: dispatchResult.note, }, now) return { task: mapTaskActionPayload(updatedTask), } } /** @returns {Promise} */ /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function refreshAdminTaskKuaishouCloudRoleInfo(taskId, session = null) { const task = await getRequiredTask(taskId) const now = nowIso() const viewerContext = createAdminViewerContext(session) if (!viewerContext.canOperateAssistedTask) { throw createHttpError('当前账号没有权限刷新角色信息', { statusCode: 403, errorCode: 'admin_task_refresh_kuaishou_cloud_role_forbidden', }) } if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是快手 cloud 履约任务', { statusCode: 409, errorCode: 'admin_task_not_kuaishou_cloud', }) } const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务还没有可查询的绑定角色信息,请先准备绑定资源', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_bind_info_context', }) } const cloudContext = resolvePersistedCloudtentaclesContext() const bindInfoResult = await getCloudtentaclesBindInfo({ ...cloudContext, key: flow.binding.vnKey, id: flow.binding.vnId, }) const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) const nextContext = { ...taskContext, kuaishouCloudFulfillment: { ...flow, binding: { ...flow.binding, roleName: bindInfo.name, roleId: bindInfo.rid, }, role: { status: bindInfo.name || bindInfo.rid ? 'ready' : 'pending', name: bindInfo.name, rid: bindInfo.rid, refreshedAt: now, errorMessage: bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', rawInfo: bindInfo.rawInfo, }, }, } const updatedTask = await updateTask(task.id, { role_id: bindInfo.rid || '', role_name: bindInfo.name || '', context_json: JSON.stringify(nextContext), updated_at: now, }) await createTaskEvent(task.id, 'kuaishou_cloud_role_info_refreshed', { roleName: bindInfo.name, roleId: bindInfo.rid, vnId: flow.binding.vnId, refreshedBy: session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } : null, }, now) return { task: mapTaskActionPayload(updatedTask), } } /** @returns {Promise} */ /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, session = null) { const task = await getRequiredTask(taskId) const now = nowIso() const viewerContext = createAdminViewerContext(session) if (!viewerContext.canManageTaskLifecycle) { throw createHttpError('当前账号没有权限退还号码', { statusCode: 403, errorCode: 'admin_task_return_kuaishou_cloud_forbidden', }) } if (!isKuaishouCloudTask(task)) { throw createHttpError('当前任务不是快手 cloud 履约任务', { statusCode: 409, errorCode: 'admin_task_not_kuaishou_cloud', }) } const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const cloudContext = resolvePersistedCloudtentaclesContext() if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务缺少可退还的虚拟号信息', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_return_context', }) } 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(order?.shop_id || '').trim() const eticketSource = getKuaishouEticketSourceConfig() const shopConfig = resolveKuaishouEticketShopConfig({ shopId, shopName: String(order?.shop_name || '').trim(), }) 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: session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } : null, }, consume: { ...flow.consume, status: consumeStatus, shopId: shopId || flow.consume.shopId, shopName: String(flow.consume.shopName || order?.shop_name || '').trim(), 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', { vnId: flow.binding.vnId, vnPhoneMasked: maskPhone(flow.binding.vnPhone), }, now) await createTaskEvent( task.id, consumeStatus === 'success' ? 'kuaishou_cloud_consumed' : 'kuaishou_cloud_consume_failed', { ticketCodeMasked: maskCode(ticketCode), shopId, consumeStatus, errorMessage: consumeErrorMessage, }, now, ) return { task: mapTaskActionPayload(updatedTask), } }