From 1c50569ea9536e0b9ed9e732364224560e488c0f Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 30 May 2026 11:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BF=AB=E6=89=8B=20Cloud=20?= =?UTF-8?q?=E6=8D=A2=E7=BB=91=E8=A7=92=E8=89=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/domain/task-status.ts | 33 +- apps/backend/src/routes/admin/tasks.ts | 207 +++++----- apps/backend/src/routes/claims.ts | 109 ++--- .../src/services/admin/admin-read-service.ts | 142 ++++--- .../admin/admin-read-shared-helpers.ts | 135 +++++-- .../admin/write/kuaishou-cloud-actions.ts | 185 ++++++--- .../claim/kuaishou-cloud-claim-service.ts | 144 ++++--- .../fulfillment/kuaishou-cloud/domain.ts | 238 +++++------ .../fulfillment/kuaishou-cloud/index.ts | 280 +++++++++++++ apps/frontend/src/services/admin/tasks.ts | 7 + apps/frontend/src/services/claim.ts | 4 + apps/frontend/src/types/admin/tasks.ts | 30 ++ apps/frontend/src/types/claim.ts | 4 + apps/frontend/src/utils/admin-display.ts | 169 ++++---- apps/frontend/src/utils/admin-options.ts | 2 +- .../views/admin/tasks/AdminTaskDetailView.vue | 10 +- .../src/views/admin/tasks/AdminTasksView.vue | 277 ++++++------- .../tasks/components/AdminTaskActionBar.vue | 10 +- .../AdminTaskKuaishouCloudPanel.vue | 77 +++- .../kuaishou-cloud/KuaishouCloudClaimView.vue | 2 + .../components/ClaimConfirmStep.vue | 45 ++- .../composables/useKuaishouCloudClaim.ts | 380 +++++++++--------- 22 files changed, 1537 insertions(+), 953 deletions(-) diff --git a/apps/backend/src/domain/task-status.ts b/apps/backend/src/domain/task-status.ts index ccdf41c5..50683d4a 100644 --- a/apps/backend/src/domain/task-status.ts +++ b/apps/backend/src/domain/task-status.ts @@ -17,7 +17,7 @@ export const TASK_STATUS = { CLOSED: 'closed', } as const -export type KnownTaskStatus = typeof TASK_STATUS[keyof typeof TASK_STATUS] +export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS] export type TaskStatus = KnownTaskStatus | (string & {}) const TASK_TRANSITIONS: Record = { @@ -66,6 +66,7 @@ const TASK_TRANSITIONS: Record = { TASK_STATUS.CLOSED, ], [TASK_STATUS.ROLE_CONFIRMED]: [ + TASK_STATUS.WAITING_BINDING, TASK_STATUS.REDEEMING, TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, @@ -102,11 +103,7 @@ const TASK_TRANSITIONS: Record = { TASK_STATUS.REDEEMED, TASK_STATUS.CLOSED, ], - [TASK_STATUS.FAILED]: [ - TASK_STATUS.RETRY_PENDING, - TASK_STATUS.MANUAL_REVIEW, - TASK_STATUS.CLOSED, - ], + [TASK_STATUS.FAILED]: [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED], [TASK_STATUS.COMPLETED]: [], [TASK_STATUS.REDEEMED]: [], [TASK_STATUS.EXPIRED]: [], @@ -206,6 +203,13 @@ const KUAISHOU_CLOUD_PREPARABLE_STATUSES = new Set([ TASK_STATUS.FAILED, ]) +const KUAISHOU_CLOUD_REBINDABLE_STATUSES = new Set([ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.RETRY_PENDING, +]) + export function normalizeTaskStatus(value: unknown): TaskStatus { return String(value || '').trim() as TaskStatus } @@ -285,6 +289,10 @@ export function canPrepareKuaishouCloudFulfillmentStatus(status: unknown): boole return KUAISHOU_CLOUD_PREPARABLE_STATUSES.has(normalizeTaskStatus(status)) } +export function canRebindKuaishouCloudRoleStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_REBINDABLE_STATUSES.has(normalizeTaskStatus(status)) +} + export function canDispatchKuaishouCloudFulfillmentStatus(status: unknown): boolean { return normalizeTaskStatus(status) === TASK_STATUS.WAITING_BINDING } @@ -293,10 +301,15 @@ export function canReturnKuaishouCloudFulfillmentStatus(status: unknown): boolea return normalizeTaskStatus(status) === TASK_STATUS.DISPATCHED_PENDING_RETURN } -export function resolveInitialPaidTaskStatus(profile: { - requires_claim?: boolean - executor_key?: unknown -} | null | undefined): TaskStatus { +export function resolveInitialPaidTaskStatus( + profile: + | { + requires_claim?: boolean + executor_key?: unknown + } + | null + | undefined, +): TaskStatus { if (Boolean(profile?.requires_claim)) { return TASK_STATUS.PAID } diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index b9c7c8cf..5af2e215 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -1,177 +1,166 @@ -import type { Request } from "express"; -import { Router } from "express"; +import type { Request } from 'express' +import { Router } from 'express' -import { - getAdminTaskDetail, - getAdminTasks, -} from "../../services/admin/admin-read-service.js"; -import { closeAdminTask } from "../../services/admin/write/task-actions.js"; +import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js' +import { closeAdminTask } from '../../services/admin/write/task-actions.js' import { dispatchAdminTaskKuaishouCloudFulfillment, prepareAdminTaskKuaishouCloudFulfillment, + rebindAdminTaskKuaishouCloudRole, refreshAdminTaskKuaishouCloudRoleInfo, returnNumberAdminTaskKuaishouCloudFulfillment, -} from "../../services/admin/write/kuaishou-cloud-actions.js"; -import { - createJsonHandler, - requireAdminRoles, -} from "./session.js"; +} from '../../services/admin/write/kuaishou-cloud-actions.js' +import { createJsonHandler, requireAdminRoles } from './session.js' import type { AdminTaskKuaishouCloudDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, -} from "../../types/admin/route-inputs.js"; -import type { AdminTaskActionResponse } from "../../types/admin/write-models.js"; +} from '../../types/admin/route-inputs.js' +import type { AdminTaskActionResponse } from '../../types/admin/write-models.js' -const router = Router(); +const router = Router() function getTaskId(req: Request): string { - return String((req.params as AdminTaskRouteParams).taskId || ""); + return String((req.params as AdminTaskRouteParams).taskId || '') } router.get( - "/tasks", + '/tasks', createJsonHandler( - (req) => - getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), + (req) => getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), { - successMessage: "ok", - errorMessage: "读取任务列表失败", - scope: "[admin/tasks]", - } - ) -); + successMessage: 'ok', + errorMessage: '读取任务列表失败', + scope: '[admin/tasks]', + }, + ), +) router.get( - "/tasks/:taskId", - createJsonHandler( - (req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), - { - successMessage: "ok", - errorMessage: "读取任务详情失败", - scope: "[admin/tasks/:taskId]", - } - ) -); + '/tasks/:taskId', + createJsonHandler((req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), { + successMessage: 'ok', + errorMessage: '读取任务详情失败', + scope: '[admin/tasks/:taskId]', + }), +) router.post( - "/tasks/:taskId/close", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => closeAdminTask(getTaskId(req), req.adminSession || null), - { - successMessage: "任务已关闭", - errorMessage: "关闭任务失败", - scope: "[admin/tasks/:taskId/close]", - audit: (req, data) => buildTaskAudit("task_closed", req, data), - } - ) -); + '/tasks/:taskId/close', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => closeAdminTask(getTaskId(req), req.adminSession || null), { + successMessage: '任务已关闭', + errorMessage: '关闭任务失败', + scope: '[admin/tasks/:taskId/close]', + audit: (req, data) => buildTaskAudit('task_closed', req, data), + }), +) router.post( - "/tasks/:taskId/kuaishou-cloud/prepare", - requireAdminRoles(["admin", "operator"]), + '/tasks/:taskId/kuaishou-cloud/prepare', + requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => - prepareAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), + (req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), { - successMessage: "绑定资源已准备完成", - errorMessage: "准备绑定资源失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), - } - ) -); + successMessage: '绑定资源已准备完成', + errorMessage: '准备绑定资源失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data), + }, + ), +) router.post( - "/tasks/:taskId/kuaishou-cloud/refresh-role-info", - requireAdminRoles(["admin", "operator", "support"]), + '/tasks/:taskId/kuaishou-cloud/refresh-role-info', + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => - refreshAdminTaskKuaishouCloudRoleInfo( - getTaskId(req), - req.adminSession || null - ), + (req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null), { - successMessage: "角色信息已刷新", - errorMessage: "刷新角色信息失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), - } - ) -); + successMessage: '角色信息已刷新', + errorMessage: '刷新角色信息失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data), + }, + ), +) router.post( - "/tasks/:taskId/kuaishou-cloud/dispatch", - requireAdminRoles(["admin", "operator"]), + '/tasks/:taskId/kuaishou-cloud/rebind-role', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null), + { + successMessage: '角色换绑资源已准备完成', + errorMessage: '换绑角色失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data), + }, + ), +) + +router.post( + '/tasks/:taskId/kuaishou-cloud/dispatch', + requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => dispatchAdminTaskKuaishouCloudFulfillment( getTaskId(req), req.body as AdminTaskKuaishouCloudDispatchRouteBody, - req.adminSession || null + req.adminSession || null, ), { - successMessage: "已完成绑定确认并发货", - errorMessage: "执行发货失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/dispatch]", + successMessage: '已完成绑定确认并发货', + errorMessage: '执行发货失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]', audit: (req, data) => { - const result = data as AdminTaskActionResponse; - const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; + const result = data as AdminTaskActionResponse + const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody return { - action: "task_kuaishou_cloud_dispatch", - targetType: "task", + action: 'task_kuaishou_cloud_dispatch', + targetType: 'task', targetId: String(getTaskId(req)), data: { taskId: result.task.taskId, taskNo: result.task.taskNo, status: result.task.status, deliveryStatus: result.task.deliveryStatus, - ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), + ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()), }, - }; + } }, - } - ) -); + }, + ), +) router.post( - "/tasks/:taskId/kuaishou-cloud/return-number", - requireAdminRoles(["admin", "operator"]), + '/tasks/:taskId/kuaishou-cloud/return-number', + requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => - returnNumberAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), + returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), { - successMessage: "号码已退还", - errorMessage: "退还号码失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_return_number", req, data), - } - ) -); + successMessage: '号码已退还', + errorMessage: '退还号码失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data), + }, + ), +) function buildTaskAudit(action: string, req: Request, data: unknown) { - const result = data as AdminTaskActionResponse; + const result = data as AdminTaskActionResponse return { action, - targetType: "task", + targetType: 'task', targetId: String(getTaskId(req)), data: { taskId: result.task.taskId, taskNo: result.task.taskNo, status: result.task.status, }, - }; + } } -export default router; +export default router diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index f8eb6df8..f1f0f0f2 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -1,88 +1,89 @@ -import { Router } from "express"; +import { Router } from 'express' -import { createRateLimitMiddleware, getParamRateLimitKey } from "../middleware/rate-limit.js"; +import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js' import { confirmKuaishouCloudClaimRole, getKuaishouCloudClaimDetail, getKuaishouCloudClaimGuideAssetPath, + rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, verifyKuaishouCloudClaimTicket, -} from "../services/claim/kuaishou-cloud-claim-service.js"; -import { - buildNotFoundPayload, - createRouteFileHandler, - createRouteHandler, -} from "../utils/http.js"; +} from '../services/claim/kuaishou-cloud-claim-service.js' +import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js' -const router = Router(); +const router = Router() const claimReadRateLimit = createRateLimitMiddleware({ - scope: "claim:read", + scope: 'claim:read', windowMs: 60_000, max: 120, - key: getParamRateLimitKey("token"), -}); + key: getParamRateLimitKey('token'), +}) const claimWriteRateLimit = createRateLimitMiddleware({ - scope: "claim:write", + scope: 'claim:write', windowMs: 60_000, max: 30, - key: getParamRateLimitKey("token"), -}); + key: getParamRateLimitKey('token'), +}) router.get( - "/:token", + '/:token', claimReadRateLimit, createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { - errorMessage: "查询快手领取详情失败", - scope: "[claims/:token]", - }) -); + errorMessage: '查询快手领取详情失败', + scope: '[claims/:token]', + }), +) router.post( - "/:token/kuaishou-cloud/verify-ticket", + '/:token/kuaishou-cloud/verify-ticket', claimWriteRateLimit, - createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { - successMessage: "核销码校验成功", - errorMessage: "校验核销码失败", - scope: "[claims/:token/kuaishou-cloud/verify-ticket]", - } - ) -); + createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), { + successMessage: '核销码校验成功', + errorMessage: '校验核销码失败', + scope: '[claims/:token/kuaishou-cloud/verify-ticket]', + }), +) router.post( - "/:token/kuaishou-cloud/confirm-role", + '/:token/kuaishou-cloud/confirm-role', claimWriteRateLimit, createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { - successMessage: "角色已确认", - errorMessage: "确认角色失败", - scope: "[claims/:token/kuaishou-cloud/confirm-role]", - }) -); + successMessage: '角色已确认', + errorMessage: '确认角色失败', + scope: '[claims/:token/kuaishou-cloud/confirm-role]', + }), +) router.post( - "/:token/kuaishou-cloud/redeem", + '/:token/kuaishou-cloud/rebind-role', + claimWriteRateLimit, + createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), { + successMessage: '角色换绑资源已准备完成', + errorMessage: '换绑角色失败', + scope: '[claims/:token/kuaishou-cloud/rebind-role]', + }), +) + +router.post( + '/:token/kuaishou-cloud/redeem', claimWriteRateLimit, createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { - successMessage: "兑换请求已提交", - errorMessage: "兑换失败", - scope: "[claims/:token/kuaishou-cloud/redeem]", - }) -); + successMessage: '兑换请求已提交', + errorMessage: '兑换失败', + scope: '[claims/:token/kuaishou-cloud/redeem]', + }), +) router.get( - "/assets/kuaishou-cloud/:filename", - createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { - errorMessage: "读取指引图片失败", - scope: "[claims/assets/kuaishou-cloud/:filename]", - } - ) -); + '/assets/kuaishou-cloud/:filename', + createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), { + errorMessage: '读取指引图片失败', + scope: '[claims/assets/kuaishou-cloud/:filename]', + }), +) router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); + res.status(404).json(buildNotFoundPayload(req)) +}) -export default router; +export default router diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 1abf9353..a74a0236 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -7,12 +7,18 @@ import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' -import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' +import { + normalizeDateQuery, + normalizePage, + normalizePageSize, + safeParseJson, +} from './admin-query-utils.js' import { canCompleteManualDispatchTaskStatus, canDispatchKuaishouCloudFulfillmentStatus, canMarkManualReviewTaskStatus, canPrepareKuaishouCloudFulfillmentStatus, + canRebindKuaishouCloudRoleStatus, canReturnKuaishouCloudFulfillmentStatus, canRetryTaskStatus, } from '../../domain/task-status.js' @@ -55,7 +61,9 @@ import type { type JsonRecord = Record -export async function getAdminOrders(query: AdminOrderListQueryInput = {}): Promise { +export async function getAdminOrders( + query: AdminOrderListQueryInput = {}, +): Promise { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listOrders({ @@ -110,9 +118,10 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< createdAt: order.created_at, updatedAt: order.updated_at, itemSummary, - rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' - ? order.raw_payload_json - : JSON.parse(String(order.raw_payload_json || '{}')), + rawPayload: + order.raw_payload_json && typeof order.raw_payload_json === 'object' + ? order.raw_payload_json + : JSON.parse(String(order.raw_payload_json || '{}')), fulfillmentProgress: buildOrderFulfillmentProgress(tasks), }, items: items.map((item) => ({ @@ -122,9 +131,10 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< itemTitle: resolveOrderItemTitle(item), quantity: item.quantity, deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), - spec: item.spec_json && typeof item.spec_json === 'object' - ? item.spec_json - : JSON.parse(String(item.spec_json || '{}')), + spec: + item.spec_json && typeof item.spec_json === 'object' + ? item.spec_json + : JSON.parse(String(item.spec_json || '{}')), })), tasks: tasks.map((task) => mapAdminTaskSummary(task)), } @@ -182,46 +192,49 @@ export async function getAdminTaskDetail( const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() - const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext( - taskContext.kuaishouCloudFulfillment, - { cloudSourceLabelMap }, - ) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap }) + const kuaishouCloudFulfillment = + mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, { + cloudSourceLabelMap, + }) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap }) return { - task: mapAdminTaskListItem({ - ...task, - sku_code: orderItem?.sku_code || '', - sku_name: orderItem?.sku_name || '', - claim_token: claimToken?.token || '', - }, viewerContext), + task: mapAdminTaskListItem( + { + ...task, + sku_code: orderItem?.sku_code || '', + sku_name: orderItem?.sku_name || '', + claim_token: claimToken?.token || '', + }, + viewerContext, + ), order: order ? { - orderId: order.id, - provider: order.provider || '', - platform: order.platform, - shopId: order.shop_id || '', - shopName: order.shop_name || '', - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - } + orderId: order.id, + provider: order.provider || '', + platform: order.platform, + shopId: order.shop_id || '', + shopName: order.shop_name || '', + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + } : null, orderItem: orderItem ? { - orderItemId: orderItem.id, - skuCode: orderItem.sku_code, - skuName: orderItem.sku_name, - quantity: orderItem.quantity, - } + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: orderItem.sku_name, + quantity: orderItem.quantity, + } : null, claimToken: claimToken ? { - primaryClaimTokenId: claimToken.id, - token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', - status: claimToken.status, - expiredAt: claimToken.expired_at, - claimUrl, - } + primaryClaimTokenId: claimToken.id, + token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', + status: claimToken.status, + expiredAt: claimToken.expired_at, + claimUrl, + } : null, artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, screenshotUrl, @@ -236,27 +249,42 @@ export async function getAdminTaskDetail( manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), events: taskEvents.map(mapAdminTaskEvent), operations: { - canRetry: viewerContext.canManageTaskLifecycle - && !isManualDispatchTask(task) - && canRetryTaskStatus(task.task_status), + canRetry: + viewerContext.canManageTaskLifecycle && + !isManualDispatchTask(task) && + canRetryTaskStatus(task.task_status), canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), canClose: canViewerCloseTask(task, viewerContext), - canMarkManualReview: viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), - canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && canCompleteManualDispatchTaskStatus(task.task_status), - canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' - && String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' - && canPrepareKuaishouCloudFulfillmentStatus(task.task_status), - canRefreshKuaishouCloudRoleInfo: viewerContext.canOperateAssistedTask - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, - canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && canDispatchKuaishouCloudFulfillmentStatus(task.task_status), - canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && canReturnKuaishouCloudFulfillmentStatus(task.task_status), + canMarkManualReview: + viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), + canCompleteManualDispatch: + viewerContext.canManageTaskLifecycle && + isManualDispatchTask(task) && + canCompleteManualDispatchTaskStatus(task.task_status), + canPrepareKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' && + String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' && + canPrepareKuaishouCloudFulfillmentStatus(task.task_status), + canRefreshKuaishouCloudRoleInfo: + viewerContext.canOperateAssistedTask && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, + canRebindKuaishouCloudRole: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canRebindKuaishouCloudRoleStatus(task.task_status) && + String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() !== 'success' && + Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, + canDispatchKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canDispatchKuaishouCloudFulfillmentStatus(task.task_status), + canReturnKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canReturnKuaishouCloudFulfillmentStatus(task.task_status), canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, }, } diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index d1b65276..05ccda0e 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -4,10 +4,7 @@ import { parseTaskContext as parseTaskContextValue, parseTaskState as parseTaskStateValue, } from '../../utils/task-json.js' -import { - canRegenerateClaimLinkStatus, - isTaskFinalStatus, -} from '../../domain/task-status.js' +import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js' import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js' import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js' @@ -51,16 +48,19 @@ export function mapManualDispatchContext( return { outcome: String(record.outcome || '').trim(), deliveryReference: String(record.deliveryReference || '').trim(), - deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(record.deliveredCredential || '').trim() : '', + deliveredCredential: viewerContext.canViewSensitiveTaskData + ? String(record.deliveredCredential || '').trim() + : '', resultMessage: String(record.resultMessage || '').trim(), completedAt: record.completedAt || null, - completedBy: record.completedBy && typeof record.completedBy === 'object' - ? { - userId: Number(record.completedBy.userId || 0) || 0, - username: String(record.completedBy.username || '').trim(), - role: String(record.completedBy.role || '').trim(), - } - : null, + completedBy: + record.completedBy && typeof record.completedBy === 'object' + ? { + userId: Number(record.completedBy.userId || 0) || 0, + username: String(record.completedBy.username || '').trim(), + role: String(record.completedBy.role || '').trim(), + } + : null, } } @@ -78,7 +78,8 @@ export function mapKuaishouCloudFulfillmentContext( const role = record.role && typeof record.role === 'object' ? record.role : {} const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {} const dispatch = record.dispatch && typeof record.dispatch === 'object' ? record.dispatch : {} - const returnNumber = record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {} + const returnNumber = + record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {} const consume = record.consume && typeof record.consume === 'object' ? record.consume : {} const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys) ? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean) @@ -156,6 +157,7 @@ export function mapKuaishouCloudFulfillmentContext( consumedAt: consume.consumedAt || null, errorMessage: String(consume.errorMessage || '').trim(), }, + rebind: mapKuaishouCloudRebindContext(record.rebind), notes: String(record.notes || '').trim(), } } @@ -204,9 +206,13 @@ export function mapKuaishouCloudTaskStateProjection( binding: { prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending', cloudSourceKeys: sourceKey ? [sourceKey] : [], - cloudSourceLabels: sourceKey ? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)] : [], + cloudSourceLabels: sourceKey + ? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)] + : [], resolvedSourceKey: sourceKey, - resolvedSourceLabel: sourceKey ? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap) : '', + resolvedSourceLabel: sourceKey + ? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap) + : '', skuId: 0, skuName: '', vnKey: '', @@ -255,6 +261,10 @@ export function mapKuaishouCloudTaskStateProjection( consumedAt: null, errorMessage: '', }, + rebind: { + currentAttempt: 0, + history: [], + }, notes: '', } } @@ -263,13 +273,67 @@ function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMa const key = String(sourceKey || '').trim() if (!key) return '' - const label = labelMap instanceof Map - ? labelMap.get(key) - : labelMap?.[key] + const label = labelMap instanceof Map ? labelMap.get(key) : labelMap?.[key] return String(label || key).trim() || key } +function mapKuaishouCloudRebindContext(value: unknown): JsonRecord { + const record = value && typeof value === 'object' ? (value as JsonRecord) : {} + const history = Array.isArray(record.history) ? record.history : [] + + return { + currentAttempt: Math.max(0, Number(record.currentAttempt || 0) || 0), + history: history.map((item) => mapKuaishouCloudRebindHistoryItem(item)).filter(Boolean), + } +} + +function mapKuaishouCloudRebindHistoryItem(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object') { + return null + } + + const record = value as JsonRecord + const oldBinding = + record.oldBinding && typeof record.oldBinding === 'object' + ? (record.oldBinding as JsonRecord) + : {} + const newBinding = + record.newBinding && typeof record.newBinding === 'object' + ? (record.newBinding as JsonRecord) + : null + + return { + attempt: Math.max(1, Number(record.attempt || 1) || 1), + source: String(record.source || '').trim(), + requestedAt: record.requestedAt || null, + requestedBy: + record.requestedBy && typeof record.requestedBy === 'object' ? record.requestedBy : null, + status: String(record.status || '').trim(), + errorMessage: String(record.errorMessage || '').trim(), + oldBinding: { + vnKey: String(oldBinding.vnKey || '').trim(), + vnId: Number(oldBinding.vnId || 0) || 0, + vnPhone: String(oldBinding.vnPhone || '').trim(), + bindUrl: String(oldBinding.bindUrl || '').trim(), + bindPreparedAt: oldBinding.bindPreparedAt || null, + bindExpiresAt: oldBinding.bindExpiresAt || null, + roleName: String(oldBinding.roleName || '').trim(), + roleId: String(oldBinding.roleId || '').trim(), + }, + newBinding: newBinding + ? { + vnKey: String(newBinding.vnKey || '').trim(), + vnId: Number(newBinding.vnId || 0) || 0, + vnPhone: String(newBinding.vnPhone || '').trim(), + bindUrl: String(newBinding.bindUrl || '').trim(), + bindPreparedAt: newBinding.bindPreparedAt || null, + bindExpiresAt: newBinding.bindExpiresAt || null, + } + : null, + } +} + export function mapRedeemResolutionContext(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object') { return null @@ -278,8 +342,8 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null { const record = value as JsonRecord const attempts = Array.isArray(record.attempts) ? record.attempts - .map((attempt) => mapRedeemResolutionAttempt(attempt)) - .filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt)) + .map((attempt) => mapRedeemResolutionAttempt(attempt)) + .filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt)) : [] return { @@ -296,7 +360,9 @@ export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): n return Number.isFinite(value) && value > 0 ? value : null } -export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext { +export function createAdminViewerContext( + session: AdminViewerSessionInput | null = null, +): AdminViewerContext { const role = normalizeAdminRole(session?.role) return { @@ -326,7 +392,11 @@ export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerCon return !isTaskFinalStatus(task?.task_status) } -export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopName: unknown): string { +export function resolveDisplayShopName( + provider: unknown, + shopId: unknown, + shopName: unknown, +): string { const normalizedShopName = String(shopName || '').trim() if (normalizedShopName) { return normalizedShopName @@ -367,8 +437,13 @@ export function resolveOrderItemTitle(item: Partial | null | undef ]) } -export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefined, orderItemId: number | string): string { - const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId) +export function resolveOrderItemDeliveryMode( + tasks: TaskLike[] | null | undefined, + orderItemId: number | string, +): string { + const task = (Array.isArray(tasks) ? tasks : []).find( + (item) => item.order_item_id === orderItemId, + ) if (!task) { return '' @@ -382,7 +457,12 @@ export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefine return 'kuaishou_cloud' } - if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) { + if ( + task.requires_claim || + getTaskPrimaryClaimTokenId(task) || + task.primary_claim_token || + task.claim_token + ) { return 'claim_link' } @@ -393,7 +473,10 @@ export function isManualDispatchTask(task: TaskLike | null | undefined): boolean return String(task?.executor_key || '').trim() === 'manual_dispatch' } -export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: AdminViewerContext): boolean { +export function canRegenerateClaimLinkForViewer( + task: TaskLike, + viewerContext: AdminViewerContext, +): boolean { if (isManualDispatchTask(task)) { return false } diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 106ad3bc..ca5f0c24 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -21,20 +21,15 @@ import { import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { TASK_STATUS } from '../../../domain/task-status.js' -import { - createAdminViewerContext, - parseTaskContext, -} from '../admin-read-shared-helpers.js' -import { - getRequiredTask, - mapTaskActionPayload, -} from '../admin-task-read-helpers.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 '../write-helpers.js' +import { rebindKuaishouCloudTaskRole } from '../../fulfillment/kuaishou-cloud/index.js' import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, @@ -210,17 +205,22 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( 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) + 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), @@ -280,10 +280,10 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( now, actor: session ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } : null, source: 'admin_task_dispatch', errorCodePrefix: 'admin_task_kuaishou_cloud', @@ -309,13 +309,14 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( ...syncedFlow.ticket, code: ticketCode || persistedTicketCode, capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, - capturedBy: ticketCode && session - ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } - : syncedFlow.ticket.capturedBy, + capturedBy: + ticketCode && session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : syncedFlow.ticket.capturedBy, }, dispatch: { ...syncedFlow.dispatch, @@ -323,10 +324,10 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( dispatchAt: now, dispatchBy: session ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } + 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(), @@ -338,20 +339,27 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN, delivery_status: 'delivered', result_code: 'kuaishou_cloud_dispatched', - result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(), + 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: syncedFlow.binding.skuId, - vnId: syncedFlow.binding.vnId, - vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), - sendType: dispatchResult.sendType, - note: dispatchResult.note, - }, now) + await createTaskEvent( + task.id, + 'kuaishou_cloud_dispatched', + { + ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), + skuId: syncedFlow.binding.skuId, + vnId: syncedFlow.binding.vnId, + vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), + sendType: dispatchResult.sendType, + note: dispatchResult.note, + }, + now, + ) return { task: mapTaskActionPayload(updatedTask), @@ -414,7 +422,8 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo( name: bindInfo.name, rid: bindInfo.rid, refreshedAt: now, - errorMessage: bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', + errorMessage: + bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', rawInfo: bindInfo.rawInfo, }, }, @@ -427,24 +436,61 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo( 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) + 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), } } +export async function rebindAdminTaskKuaishouCloudRole( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号没有权限换绑角色', { + statusCode: 403, + errorCode: 'admin_task_rebind_kuaishou_cloud_forbidden', + }) + } + + const result = await rebindKuaishouCloudTaskRole(task, { + source: 'admin_task_rebind_role', + actor: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + }) + + return { + task: mapTaskActionPayload(result.task), + claimUrl: result.claimUrl, + token: result.token, + } +} + export async function returnNumberAdminTaskKuaishouCloudFulfillment( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, @@ -519,7 +565,11 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( consumeStatus = consumeResult.consumed ? 'success' : 'failed' consumedAt = consumeResult.consumed ? now : null consumeErrorMessage = String(consumeResult.errorMessage || '').trim() - } else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { + } else if ( + !shopConfig || + shopConfig.enabled === false || + !String(shopConfig.cookie || '').trim() + ) { consumeStatus = 'failed' consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销' } else { @@ -561,10 +611,10 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( returnedAt: now, returnedBy: session ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } : null, }, consume: { @@ -590,10 +640,15 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( 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, + 'kuaishou_cloud_number_returned', + { + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }, + now, + ) await createTaskEvent( task.id, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 6a186b44..0e95cb8f 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -26,6 +26,7 @@ import { maskCode, normalizeKuaishouCloudFlow, prepareKuaishouCloudFulfillmentTask, + rebindKuaishouCloudTaskRole, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud/index.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' @@ -59,7 +60,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js } const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode) - let shopId = String(flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : '')).trim() + let shopId = String( + flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : ''), + ).trim() let shopName = String(flow.consume.shopName || context.order.shop_name || '').trim() let detailResult @@ -134,13 +137,16 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js } else { const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) { - await prepareKuaishouCloudFulfillmentTask({ - ...context.task, - context_json: JSON.stringify(nextContext), - }, { - source: 'claim_page_ticket_verified', - actor: { source: 'claim_page' }, - }) + await prepareKuaishouCloudFulfillmentTask( + { + ...context.task, + context_json: JSON.stringify(nextContext), + }, + { + source: 'claim_page_ticket_verified', + actor: { source: 'claim_page' }, + }, + ) } else { await updateTask(context.task.id, { claim_token: context.claimToken.token, @@ -159,13 +165,18 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js } } - await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', { - ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), - shopId, - shopName, - goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), - mock: mockTicketCode, - }, now) + await createTaskEvent( + context.task.id, + 'kuaishou_cloud_ticket_verified', + { + ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), + shopId, + shopName, + goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), + mock: mockTicketCode, + }, + now, + ) return getKuaishouCloudClaimDetail(token) } @@ -195,10 +206,10 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { let task = context.task if ( - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && !isKuaishouCloudMockTask(task) + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + !isKuaishouCloudMockTask(task) ) { - task = await syncKuaishouCloudRoleInfo(task) || task + task = (await syncKuaishouCloudRoleInfo(task)) || task } return buildClaimDetailPayload({ @@ -209,6 +220,24 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { }) } +export async function rebindKuaishouCloudClaimRole(token: unknown) { + const context = await getClaimContext(token) + + if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', { + statusCode: 409, + errorCode: 'claim_not_kuaishou_cloud', + }) + } + + await rebindKuaishouCloudTaskRole(context.task, { + source: 'claim_page_rebind_role', + actor: { source: 'claim_page' }, + }) + + return getKuaishouCloudClaimDetail(token) +} + function parseTaskContext(task: Partial | null | undefined): JsonObject { const rawValue = task?.context_json if (!rawValue) { @@ -289,10 +318,13 @@ async function queryKuaishouEticketConsumeDetailWithShopFallback({ } } - throw createHttpError(lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', { - statusCode: 409, - errorCode: 'claim_kuaishou_cloud_ticket_invalid', - }) + throw createHttpError( + lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', + { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_ticket_invalid', + }, + ) } function resolveKuaishouEticketDetailCandidateShops({ @@ -333,9 +365,7 @@ function isUsableKuaishouEticketShopConfig( shopConfig: KuaishouEticketShopConfig | null | undefined, ): shopConfig is KuaishouEticketShopConfig { return Boolean( - shopConfig && - shopConfig.enabled !== false && - String(shopConfig.cookie || '').trim(), + shopConfig && shopConfig.enabled !== false && String(shopConfig.cookie || '').trim(), ) } @@ -373,11 +403,11 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { const refreshed = mockMode ? { task: context.task } : await refreshKuaishouCloudTaskRoleInfo(context.task, { - source: 'claim_page_role_confirm', - actor: { source: 'claim_page' }, - recordEvent: false, - forceProbe: true, - }) + source: 'claim_page_role_confirm', + actor: { source: 'claim_page' }, + recordEvent: false, + forceProbe: true, + }) const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment) if (flow.ticket.status !== 'verified') { @@ -404,11 +434,16 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { updated_at: now, }) - await createTaskEvent(context.task.id, 'kuaishou_cloud_role_confirmed', { - vnPhone: flow.binding.vnPhone, - roleName: flow.binding.roleName, - roleId: flow.binding.roleId, - }, now) + await createTaskEvent( + context.task.id, + 'kuaishou_cloud_role_confirmed', + { + vnPhone: flow.binding.vnPhone, + roleName: flow.binding.roleName, + roleId: flow.binding.roleId, + }, + now, + ) return getKuaishouCloudClaimDetail(token) } @@ -474,10 +509,15 @@ export async function redeemKuaishouCloudClaim(token: unknown) { updated_at: nowIso(), }) - await createTaskEvent(lockedTask.id, 'kuaishou_cloud_redeem_failed', { - source: 'claim_page_redeem', - errorMessage: message, - }, nowIso()) + await createTaskEvent( + lockedTask.id, + 'kuaishou_cloud_redeem_failed', + { + source: 'claim_page_redeem', + errorMessage: message, + }, + nowIso(), + ) throw error } @@ -490,10 +530,10 @@ function isKuaishouCloudMockTask(task: Partial | null | undefined) { } function isKuaishouCloudMockContext(context: JsonObject = {}) { - const flow = context.kuaishouCloudFulfillment - && typeof context.kuaishouCloudFulfillment === 'object' - ? context.kuaishouCloudFulfillment - : {} + const flow = + context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object' + ? context.kuaishouCloudFulfillment + : {} const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock return Boolean(mock && typeof mock === 'object' && mock.enabled === true) @@ -502,8 +542,9 @@ function isKuaishouCloudMockContext(context: JsonObject = {}) { function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) { const flow = normalizeKuaishouCloudFlow(value) const source = flow as JsonObject - const bindUrl = flow.binding.bindUrl - || `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}` + const bindUrl = + flow.binding.bindUrl || + `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}` const vnPhone = flow.binding.vnPhone || '13800000000' const roleName = flow.binding.roleName || flow.role.name || '测试角色' const roleId = flow.binding.roleId || flow.role.rid || '10001' @@ -590,8 +631,13 @@ async function completeMockKuaishouCloudClaimTask(task: TaskRow, timestamp: stri updated_at: timestamp, }) - await createTaskEvent(task.id, 'kuaishou_cloud_mock_redeemed', { - source: 'claim_page_mock', - deliveryItems: flow.deliveryItems, - }, timestamp) + await createTaskEvent( + task.id, + 'kuaishou_cloud_mock_redeemed', + { + source: 'claim_page_mock', + deliveryItems: flow.deliveryItems, + }, + timestamp, + ) } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index 788f22fc..35db1dce 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -1,98 +1,77 @@ -import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js"; -import { - maskCode as maskCodeValue, - maskPhone as maskPhoneValue, -} from "../../../utils/masking.js"; +import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' +import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js' -export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; +export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1' -export type JsonObject = Record; +export type JsonObject = Record export function isKuaishouCloudTask(task: unknown) { - const source = task && typeof task === "object" ? task as JsonObject : {}; - return String(source.executor_key || "").trim() === "kuaishou_ct_assisted"; + const source = task && typeof task === 'object' ? (task as JsonObject) : {} + return String(source.executor_key || '').trim() === 'kuaishou_ct_assisted' } export function normalizeKuaishouCloudFlow(value: unknown) { - const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; - 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 source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} + 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 deliveryItems = normalizeDeliveryItems(source, binding); + 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 rebind = source.rebind && typeof source.rebind === 'object' ? source.rebind : {} + const deliveryItems = normalizeDeliveryItems(source, binding) - const roleName = String(role.name || binding.roleName || "").trim(); - const roleId = String(role.rid || binding.roleId || "").trim(); - const bindPreparedAt = binding.bindPreparedAt || null; + 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); + binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt) return { ...source, - configId: String(source.configId || "").trim(), - internalSkuCode: String(source.internalSkuCode || "").trim(), - internalSkuName: String(source.internalSkuName || "").trim(), + configId: String(source.configId || '').trim(), + internalSkuCode: String(source.internalSkuCode || '').trim(), + internalSkuName: String(source.internalSkuName || '').trim(), deliveryItems, ticket: { - code: String(ticket.code || "").trim(), - status: String(ticket.status || "pending").trim() || "pending", + 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(), + oid: String(ticket.oid || '').trim(), + formToken: String(ticket.formToken || '').trim(), leftCount: Number(ticket.leftCount || 0) || 0, - goodsTitle: String(ticket.goodsTitle || "").trim(), + goodsTitle: String(ticket.goodsTitle || '').trim(), }, binding: { - prepareStatus: - String(binding.prepareStatus || "pending").trim() || "pending", + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys), - resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(), + resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), skuId: Number(binding.skuId || 0) || 0, - skuName: String(binding.skuName || "").trim(), + skuName: String(binding.skuName || '').trim(), vnKey: - String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || - KUAISHOU_CLOUD_FIXED_VN_KEY, + 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(), + 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(), + bindProbeStatus: String(binding.bindProbeStatus || '').trim(), + bindProbeMessage: String(binding.bindProbeMessage || '').trim(), roleName, roleId, }, role: { - status: - String( - role.status || (roleName || roleId ? "ready" : "pending") - ).trim() || "pending", + 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, + errorMessage: String(role.errorMessage || '').trim(), + rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null, }, purchase: { autoBuyEnabled: purchase.autoBuyEnabled !== false, @@ -105,171 +84,166 @@ export function normalizeKuaishouCloudFlow(value: unknown) { items: Array.isArray(purchase.items) ? purchase.items : [], }, dispatch: { - status: String(dispatch.status || "pending").trim() || "pending", + 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(), + note: String(dispatch.note || '').trim(), items: Array.isArray(dispatch.items) ? dispatch.items : [], }, returnNumber: { - status: String(returnNumber.status || "pending").trim() || "pending", + 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(), + 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(), + errorMessage: String(consume.errorMessage || '').trim(), }, - notes: String(source.notes || "").trim(), - }; + rebind: { + currentAttempt: Math.max(0, Number(rebind.currentAttempt || 0) || 0), + history: Array.isArray(rebind.history) ? rebind.history : [], + }, + notes: String(source.notes || '').trim(), + } } export function normalizeKuaishouCloudDeliveryItems(value: unknown) { - const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; - const binding = - source.binding && typeof source.binding === "object" ? source.binding : {}; - return normalizeDeliveryItems(source, binding); + const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + return normalizeDeliveryItems(source, binding) } export function normalizeKuaishouCloudRoleInfo(value: unknown) { - const rawInfo: JsonObject | null = value && typeof value === "object" ? value as JsonObject : null; + const rawInfo: JsonObject | null = + value && typeof value === 'object' ? (value as JsonObject) : null const nestedBindInfo = - rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object" - ? rawInfo.sBindInfo - : null; - const source = nestedBindInfo || rawInfo; + rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null + const source = nestedBindInfo || rawInfo return { name: String( - source?.name || - source?.roleName || - source?.nickname || - source?.sRoleName || - "" + source?.name || source?.roleName || source?.nickname || source?.sRoleName || '', ).trim(), rid: String( - source?.rid || - source?.roleId || - source?.uid || - source?.sRoleId || - source?.sUserId || - "" + source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '', ).trim(), rawInfo, - }; + } } export function maskPhone(value: unknown) { - return maskPhoneValue(value); + return maskPhoneValue(value) } export function maskCode(value: unknown) { - return maskCodeValue(value); + return maskCodeValue(value) } export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { - const preparedTime = Date.parse(String(preparedAt || "")); + const preparedTime = Date.parse(String(preparedAt || '')) if (!Number.isFinite(preparedTime)) { - return null; + return null } - const config = resolveCloudtentaclesConfig(); - const ttlSeconds = Number(config.bindUrlTtlSeconds || 600); - return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString(); + const config = resolveCloudtentaclesConfig() + const ttlSeconds = Number(config.bindUrlTtlSeconds || 600) + return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString() } export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { - const normalizedFlow = normalizeKuaishouCloudFlow(flow); + const normalizedFlow = normalizeKuaishouCloudFlow(flow) if (!normalizedFlow.binding.bindUrl) { - return false; + return false } - const expiresAt = normalizedFlow.binding.bindExpiresAt; + const expiresAt = normalizedFlow.binding.bindExpiresAt if (!expiresAt) { - return false; + return false } - const expiresTime = Date.parse(String(expiresAt || "")); + const expiresTime = Date.parse(String(expiresAt || '')) if (!Number.isFinite(expiresTime)) { - return false; + return false } - return expiresTime > now.getTime(); + return expiresTime > now.getTime() } export function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { - return value.map((v) => String(v || "").trim()).filter(Boolean); + return value.map((v) => String(v || '').trim()).filter(Boolean) } - if (typeof value === "string") { + if (typeof value === 'string') { return value - .split(",") + .split(',') .map((v) => v.trim()) - .filter(Boolean); + .filter(Boolean) } - return []; + return [] } function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) { - const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : []; - const items = rawItems - .map((item) => normalizeDeliveryItem(item)) - .filter(Boolean) as Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>; + const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : [] + const items = rawItems.map((item) => normalizeDeliveryItem(item)).filter(Boolean) as Array<{ + cloudSkuId: number + cloudSkuName: string + quantity: number + }> if (items.length > 0) { - return mergeDeliveryItems(items); + return mergeDeliveryItems(items) } - const skuId = Number(binding.skuId || 0) || 0; + const skuId = Number(binding.skuId || 0) || 0 if (!skuId) { - return []; + return [] } return [ { cloudSkuId: skuId, - cloudSkuName: String(binding.skuName || "").trim(), + cloudSkuName: String(binding.skuName || '').trim(), quantity: 1, }, - ]; + ] } function normalizeDeliveryItem(value: unknown) { - const source = value && typeof value === "object" ? value as JsonObject : {}; - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + const source = value && typeof value === 'object' ? (value as JsonObject) : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null; + return null } - const quantity = Number(source.quantity || 1) || 1; + const quantity = Number(source.quantity || 1) || 1 return { cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - }; + } } function mergeDeliveryItems( - items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }> + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, ) { - const merged = new Map(); + const merged = new Map() for (const item of items) { - const existing = merged.get(item.cloudSkuId); + const existing = merged.get(item.cloudSkuId) if (existing) { - existing.quantity += item.quantity; - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName; - continue; + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue } - merged.set(item.cloudSkuId, { ...item }); + merged.set(item.cloudSkuId, { ...item }) } - return Array.from(merged.values()); + return Array.from(merged.values()) } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index af2375f6..e7b99052 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -456,6 +456,286 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js } } +export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const normalizedStatus = normalizeTaskStatus(task.task_status) + if ( + ![ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.RETRY_PENDING, + ].includes(normalizedStatus as any) + ) { + throw createHttpError('当前任务状态不可换绑角色', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_not_allowed', + }) + } + + const now = nowIso() + const actor = normalizeActor(options.actor) + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + + if ( + flow.dispatch.status === 'success' || + normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN + ) { + throw createHttpError('当前任务已经发货,不能换绑角色', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_after_dispatch_forbidden', + }) + } + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError('当前任务缺少可退还的虚拟号信息,请先准备绑定资源', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_missing_binding_context', + }) + } + + const source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role' + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) + const oldBinding = { + vnKey: flow.binding.vnKey, + vnId: flow.binding.vnId, + vnPhone: flow.binding.vnPhone, + bindUrl: flow.binding.bindUrl, + bindPreparedAt: flow.binding.bindPreparedAt, + bindExpiresAt: flow.binding.bindExpiresAt, + roleName: flow.binding.roleName || flow.role.name || task.role_name || '', + roleId: flow.binding.roleId || flow.role.rid || task.role_id || '', + } + const previousRebind: JsonObject = + flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {} + const history = Array.isArray(previousRebind.history) ? previousRebind.history : [] + const attempt = Math.max(1, Number(previousRebind.currentAttempt || history.length || 0) + 1) + const baseHistoryItem = { + attempt, + source, + requestedAt: now, + requestedBy: actor, + oldBinding, + } + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_requested', + { + source, + attempt, + oldVnId: oldBinding.vnId, + oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), + oldRoleName: oldBinding.roleName, + oldRoleId: oldBinding.roleId, + actor, + }, + now, + ) + + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: oldBinding.vnKey, + id: oldBinding.vnId, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_old_number_returned', + { + source, + attempt, + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhoneMasked: maskPhone(oldBinding.vnPhone), + actor, + }, + now, + ) + + let preparedBinding + try { + preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ + cloudContext, + vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ + flow, + binding: flow.binding, + }), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '新绑定资源准备失败' + const failedContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'pending', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: now, + bindProbeStatus: 'rebind_failed', + bindProbeMessage: errorMessage, + roleName: '', + roleId: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: now, + errorMessage: `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + rawInfo: null, + }, + rebind: { + ...previousRebind, + currentAttempt: attempt, + history: [ + ...history, + { + ...baseHistoryItem, + status: 'failed', + errorMessage, + }, + ], + }, + }, + } + const failedTask = await updateTask(task.id, { + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, + role_id: '', + role_name: '', + role_confirmed_at: null, + last_error: `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + context_json: JSON.stringify(failedContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_failed', + { + source, + attempt, + errorMessage, + actor, + }, + now, + ) + + return { + task: failedTask || task, + claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), + token: String(task.primary_claim_token || task.claim_token || ''), + flow: normalizeKuaishouCloudFlow( + parseTaskContext(failedTask || task).kuaishouCloudFulfillment, + ), + } + } + + const claimLinkState = await ensureTaskClaimLink(task) + const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now) + 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: nextBindExpiresAt, + bindProbeAt: null, + bindProbeStatus: 'pending', + bindProbeMessage: '', + roleName: '', + roleId: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: null, + errorMessage: '', + rawInfo: null, + }, + rebind: { + ...previousRebind, + currentAttempt: attempt, + history: [ + ...history, + { + ...baseHistoryItem, + newBinding: { + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhone: preparedBinding.vnPhone, + bindUrl: preparedBinding.bindUrl, + bindPreparedAt: now, + bindExpiresAt: nextBindExpiresAt, + }, + status: 'success', + errorMessage: '', + }, + ], + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: 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: '', + role_confirmed_at: null, + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_prepared', + { + source, + attempt, + oldVnId: oldBinding.vnId, + oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhoneMasked: maskPhone(preparedBinding.vnPhone), + bindUrl: preparedBinding.bindUrl, + 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 履约任务', { diff --git a/apps/frontend/src/services/admin/tasks.ts b/apps/frontend/src/services/admin/tasks.ts index a574863f..4e715205 100644 --- a/apps/frontend/src/services/admin/tasks.ts +++ b/apps/frontend/src/services/admin/tasks.ts @@ -66,6 +66,13 @@ export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) { ) } +export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) { + return apiPost( + `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`, + {}, + ) +} + export function dispatchAdminTaskKuaishouCloudFulfillment( taskId: number | string, payload: { diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index ad6f1899..e5b508c2 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -18,6 +18,10 @@ export function confirmKuaishouCloudClaimRole(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) } +export function rebindKuaishouCloudClaimRole(token: string) { + return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {}) +} + export function redeemKuaishouCloudClaim(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {}) } diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index ec69c354..20193cb5 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -34,6 +34,7 @@ export interface AdminTaskOperations { canCompleteManualDispatch: boolean canPrepareKuaishouCloudFulfillment: boolean canRefreshKuaishouCloudRoleInfo: boolean + canRebindKuaishouCloudRole: boolean canDispatchKuaishouCloudFulfillment: boolean canReturnKuaishouCloudFulfillment: boolean canViewSensitiveTaskData: boolean @@ -169,6 +170,35 @@ export interface AdminTaskDetail { consumedAt: string | null errorMessage: string } + rebind: { + currentAttempt: number + history: Array<{ + attempt: number + source: string + requestedAt: string | null + requestedBy: Record | null + status: string + errorMessage: string + oldBinding: { + vnKey: string + vnId: number + vnPhone: string + bindUrl: string + bindPreparedAt: string | null + bindExpiresAt: string | null + roleName: string + roleId: string + } + newBinding: null | { + vnKey: string + vnId: number + vnPhone: string + bindUrl: string + bindPreparedAt: string | null + bindExpiresAt: string | null + } + }> + } notes: string } manualDispatch: null | { diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 3d0e6005..db14b384 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -119,6 +119,10 @@ export interface ClaimKuaishouCloudFlowInfo { consumedAt: string | null errorMessage: string } + rebind?: { + currentAttempt: number + history: Array> + } } export interface ClaimDetailData { diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index f0b5524b..c579d923 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -1,134 +1,135 @@ -export function formatStatusWithRaw( - status: string, - labelMap: Record = {} -) { - const normalized = String(status || "").trim(); +export function formatStatusWithRaw(status: string, labelMap: Record = {}) { + const normalized = String(status || '').trim() if (!normalized) { - return "-"; + return '-' } - const key = normalized.toLowerCase(); - const label = labelMap[key]; + const key = normalized.toLowerCase() + const label = labelMap[key] if (!label || label === normalized) { - return normalized; + return normalized } - return `${label} (${normalized})`; + return `${label} (${normalized})` } export function formatAuditAction(action: string) { const labelMap: Record = { - admin_user_created: "创建后台用户", - admin_user_role_updated: "修改用户角色", - admin_user_status_updated: "修改用户状态", - admin_user_password_reset: "重置用户密码", - task_regenerate_claim_link: "重发领取链接", - task_closed: "关闭任务", - task_mark_manual_review: "转人工处理", - task_complete_manual_dispatch: "完成人工履约", - platform_shop_config_updated: "更新店铺配置", - platform_fulfillment_config_updated: "更新履约配置", - }; + admin_user_created: '创建后台用户', + admin_user_role_updated: '修改用户角色', + admin_user_status_updated: '修改用户状态', + admin_user_password_reset: '重置用户密码', + task_regenerate_claim_link: '重发领取链接', + task_kuaishou_cloud_rebind_role: '换绑角色', + task_closed: '关闭任务', + task_mark_manual_review: '转人工处理', + task_complete_manual_dispatch: '完成人工履约', + platform_shop_config_updated: '更新店铺配置', + platform_fulfillment_config_updated: '更新履约配置', + } - return formatStatusWithRaw(action, labelMap); + return formatStatusWithRaw(action, labelMap) } export function formatAuditTargetType(targetType: string) { const labelMap: Record = { - admin_user: "后台用户", - task: "交付任务", - platform_config: "平台配置", - }; + admin_user: '后台用户', + task: '交付任务', + platform_config: '平台配置', + } - return formatStatusWithRaw(targetType, labelMap); + return formatStatusWithRaw(targetType, labelMap) } export function formatTaskEventType(eventType: string) { const labelMap: Record = { - manual_dispatch_completed: "人工履约已回写", - claim_redeem_code_used: "兑换码已使用", - claim_redeem_code_invalid: "兑换码错误", - claim_redeem_completed: "兑换链路完成", - }; + manual_dispatch_completed: '人工履约已回写', + claim_redeem_code_used: '兑换码已使用', + claim_redeem_code_invalid: '兑换码错误', + claim_redeem_completed: '兑换链路完成', + kuaishou_cloud_rebind_requested: '发起角色换绑', + kuaishou_cloud_rebind_old_number_returned: '换绑旧号已退还', + kuaishou_cloud_rebind_prepared: '换绑资源已准备', + kuaishou_cloud_rebind_failed: '换绑失败', + } - return formatStatusWithRaw(eventType, labelMap); + return formatStatusWithRaw(eventType, labelMap) } export function formatRedeemOutcomeLabel(value: string) { - const normalized = String(value || "").trim(); + const normalized = String(value || '').trim() switch (normalized) { - case "success": - return "兑换成功"; - case "code_used": - return "兑换码已使用"; - case "code_invalid": - return "兑换码错误"; - case "captcha_rejected": - return "验证码错误"; - case "failed": - return "兑换失败"; + case 'success': + return '兑换成功' + case 'code_used': + return '兑换码已使用' + case 'code_invalid': + return '兑换码错误' + case 'captcha_rejected': + return '验证码错误' + case 'failed': + return '兑换失败' default: - return normalized || "-"; + return normalized || '-' } } export function formatRedeemResolutionStatus(value: string) { - return value === "success" - ? "已完成" - : value === "failed" - ? "失败收口" - : value || "-"; + return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-' } export function formatKuaishouRoleInfoLabel(value: string) { - const normalized = String(value || "").trim(); + const normalized = String(value || '').trim() switch (normalized) { - case "name": - return "角色名称"; - case "rid": - return "角色 ID"; + case 'name': + return '角色名称' + case 'rid': + return '角色 ID' default: - return normalized || "-"; + return normalized || '-' } } export function formatTaskEventPayload(payload: Record) { const parts = [ - payload.outcome ? `结果 ${payload.outcome}` : "", - payload.resultCode ? `代码 ${payload.resultCode}` : "", - payload.resultMessage ? `说明 ${payload.resultMessage}` : "", - payload.codeMasked ? `凭据 ${payload.codeMasked}` : "", - payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "", - payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "", - payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "", - payload.vnId ? `虚拟号 #${payload.vnId}` : "", - payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "", - payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : "", - payload.sendType ? `发送类型 ${payload.sendType}` : "", - payload.note ? `备注 ${payload.note}` : "", + payload.outcome ? `结果 ${payload.outcome}` : '', + payload.resultCode ? `代码 ${payload.resultCode}` : '', + payload.resultMessage ? `说明 ${payload.resultMessage}` : '', + payload.codeMasked ? `凭据 ${payload.codeMasked}` : '', + payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '', + payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '', + payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '', + payload.vnId ? `虚拟号 #${payload.vnId}` : '', + payload.oldVnId ? `旧虚拟号 #${payload.oldVnId}` : '', + payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '', + payload.oldVnPhoneMasked ? `旧号码 ${payload.oldVnPhoneMasked}` : '', + payload.oldRoleName ? `旧角色 ${payload.oldRoleName}` : '', + payload.oldRoleId ? `旧角色ID ${payload.oldRoleId}` : '', + payload.attempt ? `第 ${payload.attempt} 次` : '', + payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '', + payload.sendType ? `发送类型 ${payload.sendType}` : '', + payload.note ? `备注 ${payload.note}` : '', payload.previousOutcome - ? `切换原因 ${formatRedeemOutcomeLabel( - String(payload.previousOutcome || "") - )}` - : "", - payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "", - payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "", - payload.trigger ? `触发 ${payload.trigger}` : "", - payload.reason ? `原因 ${payload.reason}` : "", - payload.purchaseTriggered === true ? "已触发购买" : "", - payload.usedKnapsack === true ? "使用背包库存" : "", - payload.responseStatus ? `HTTP ${payload.responseStatus}` : "", - payload.requestId ? `请求 ${payload.requestId}` : "", - payload.errorMessage ? `错误 ${payload.errorMessage}` : "", - ].filter(Boolean); + ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` + : '', + payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '', + payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '', + payload.trigger ? `触发 ${payload.trigger}` : '', + payload.reason ? `原因 ${payload.reason}` : '', + payload.purchaseTriggered === true ? '已触发购买' : '', + payload.usedKnapsack === true ? '使用背包库存' : '', + payload.responseStatus ? `HTTP ${payload.responseStatus}` : '', + payload.requestId ? `请求 ${payload.requestId}` : '', + payload.errorMessage ? `错误 ${payload.errorMessage}` : '', + ].filter(Boolean) if (parts.length > 0) { - return parts.join(" · "); + return parts.join(' · ') } - return JSON.stringify(payload || {}); + return JSON.stringify(payload || {}) } diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 39dc4c38..5abb1c10 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -40,7 +40,7 @@ export const adminAuditActionOptions = [ { label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' }, { label: '重置用户密码', value: 'admin_user_password_reset' }, - { label: '重发领取链接', value: 'task_regenerate_claim_link' }, + { label: '换绑角色', value: 'task_kuaishou_cloud_rebind_role' }, { label: '关闭任务', value: 'task_closed' }, { label: '转人工处理', value: 'task_mark_manual_review' }, ] diff --git a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue index 3bffec92..69fa3bdd 100644 --- a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue @@ -4,7 +4,7 @@ import { onMounted } from 'vue' import { closeAdminTask, markAdminTaskManualReview, - regenerateAdminTaskClaimLink, + rebindAdminTaskKuaishouCloudRole, retryAdminTask, } from '@/services/admin' @@ -83,11 +83,11 @@ onMounted(loadDetail) `确认重试任务 ${detail!.task.taskNo} 吗?`, ) " - @regenerate-claim-link=" + @rebind-role=" runAction( - () => regenerateAdminTaskClaimLink(detail!.task.taskId), - '领取链接已重新生成', - `确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`, + () => rebindAdminTaskKuaishouCloudRole(detail!.task.taskId), + '角色换绑资源已准备完成', + `确认为任务 ${detail!.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`, ) " @prepare-kuaishou-cloud="submitKuaishouCloudPrepare" diff --git a/apps/frontend/src/views/admin/tasks/AdminTasksView.vue b/apps/frontend/src/views/admin/tasks/AdminTasksView.vue index cb952bc3..592330bc 100644 --- a/apps/frontend/src/views/admin/tasks/AdminTasksView.vue +++ b/apps/frontend/src/views/admin/tasks/AdminTasksView.vue @@ -1,38 +1,34 @@ @@ -346,12 +329,8 @@ onMounted(() => {