添加快手 Cloud 换绑角色功能

This commit is contained in:
yml
2026-05-30 11:23:52 +08:00
parent 681ff44cbc
commit 1c50569ea9
22 changed files with 1537 additions and 953 deletions
+21 -8
View File
@@ -17,7 +17,7 @@ export const TASK_STATUS = {
CLOSED: 'closed', CLOSED: 'closed',
} as const } 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 & {}) export type TaskStatus = KnownTaskStatus | (string & {})
const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = { const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = {
@@ -66,6 +66,7 @@ const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = {
TASK_STATUS.CLOSED, TASK_STATUS.CLOSED,
], ],
[TASK_STATUS.ROLE_CONFIRMED]: [ [TASK_STATUS.ROLE_CONFIRMED]: [
TASK_STATUS.WAITING_BINDING,
TASK_STATUS.REDEEMING, TASK_STATUS.REDEEMING,
TASK_STATUS.RETRY_PENDING, TASK_STATUS.RETRY_PENDING,
TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.MANUAL_REVIEW,
@@ -102,11 +103,7 @@ const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = {
TASK_STATUS.REDEEMED, TASK_STATUS.REDEEMED,
TASK_STATUS.CLOSED, TASK_STATUS.CLOSED,
], ],
[TASK_STATUS.FAILED]: [ [TASK_STATUS.FAILED]: [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED],
TASK_STATUS.RETRY_PENDING,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.CLOSED,
],
[TASK_STATUS.COMPLETED]: [], [TASK_STATUS.COMPLETED]: [],
[TASK_STATUS.REDEEMED]: [], [TASK_STATUS.REDEEMED]: [],
[TASK_STATUS.EXPIRED]: [], [TASK_STATUS.EXPIRED]: [],
@@ -206,6 +203,13 @@ const KUAISHOU_CLOUD_PREPARABLE_STATUSES = new Set<TaskStatus>([
TASK_STATUS.FAILED, TASK_STATUS.FAILED,
]) ])
const KUAISHOU_CLOUD_REBINDABLE_STATUSES = new Set<TaskStatus>([
TASK_STATUS.WAITING_BINDING,
TASK_STATUS.ROLE_CONFIRMED,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.RETRY_PENDING,
])
export function normalizeTaskStatus(value: unknown): TaskStatus { export function normalizeTaskStatus(value: unknown): TaskStatus {
return String(value || '').trim() as 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)) 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 { export function canDispatchKuaishouCloudFulfillmentStatus(status: unknown): boolean {
return normalizeTaskStatus(status) === TASK_STATUS.WAITING_BINDING 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 return normalizeTaskStatus(status) === TASK_STATUS.DISPATCHED_PENDING_RETURN
} }
export function resolveInitialPaidTaskStatus(profile: { export function resolveInitialPaidTaskStatus(
profile:
| {
requires_claim?: boolean requires_claim?: boolean
executor_key?: unknown executor_key?: unknown
} | null | undefined): TaskStatus { }
| null
| undefined,
): TaskStatus {
if (Boolean(profile?.requires_claim)) { if (Boolean(profile?.requires_claim)) {
return TASK_STATUS.PAID return TASK_STATUS.PAID
} }
+98 -109
View File
@@ -1,177 +1,166 @@
import type { Request } from "express"; import type { Request } from 'express'
import { Router } from "express"; import { Router } from 'express'
import { import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js'
getAdminTaskDetail, import { closeAdminTask } from '../../services/admin/write/task-actions.js'
getAdminTasks,
} from "../../services/admin/admin-read-service.js";
import { closeAdminTask } from "../../services/admin/write/task-actions.js";
import { import {
dispatchAdminTaskKuaishouCloudFulfillment, dispatchAdminTaskKuaishouCloudFulfillment,
prepareAdminTaskKuaishouCloudFulfillment, prepareAdminTaskKuaishouCloudFulfillment,
rebindAdminTaskKuaishouCloudRole,
refreshAdminTaskKuaishouCloudRoleInfo, refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment, returnNumberAdminTaskKuaishouCloudFulfillment,
} from "../../services/admin/write/kuaishou-cloud-actions.js"; } from '../../services/admin/write/kuaishou-cloud-actions.js'
import { import { createJsonHandler, requireAdminRoles } from './session.js'
createJsonHandler,
requireAdminRoles,
} from "./session.js";
import type { import type {
AdminTaskKuaishouCloudDispatchRouteBody, AdminTaskKuaishouCloudDispatchRouteBody,
AdminTaskRouteParams, AdminTaskRouteParams,
AdminTaskRouteQuery, AdminTaskRouteQuery,
} from "../../types/admin/route-inputs.js"; } from '../../types/admin/route-inputs.js'
import type { AdminTaskActionResponse } from "../../types/admin/write-models.js"; import type { AdminTaskActionResponse } from '../../types/admin/write-models.js'
const router = Router(); const router = Router()
function getTaskId(req: Request): string { function getTaskId(req: Request): string {
return String((req.params as AdminTaskRouteParams).taskId || ""); return String((req.params as AdminTaskRouteParams).taskId || '')
} }
router.get( router.get(
"/tasks", '/tasks',
createJsonHandler( createJsonHandler(
(req) => (req) => getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
{ {
successMessage: "ok", successMessage: 'ok',
errorMessage: "读取任务列表失败", errorMessage: '读取任务列表失败',
scope: "[admin/tasks]", scope: '[admin/tasks]',
} },
) ),
); )
router.get( router.get(
"/tasks/:taskId", '/tasks/:taskId',
createJsonHandler( createJsonHandler((req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), {
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), successMessage: 'ok',
{ errorMessage: '读取任务详情失败',
successMessage: "ok", scope: '[admin/tasks/:taskId]',
errorMessage: "读取任务详情失败", }),
scope: "[admin/tasks/:taskId]", )
}
)
);
router.post( router.post(
"/tasks/:taskId/close", '/tasks/:taskId/close',
requireAdminRoles(["admin", "operator", "support"]), requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler( createJsonHandler((req) => closeAdminTask(getTaskId(req), req.adminSession || null), {
(req) => closeAdminTask(getTaskId(req), req.adminSession || null), successMessage: '任务已关闭',
{ errorMessage: '关闭任务失败',
successMessage: "任务已关闭", scope: '[admin/tasks/:taskId/close]',
errorMessage: "关闭任务失败", audit: (req, data) => buildTaskAudit('task_closed', req, data),
scope: "[admin/tasks/:taskId/close]", }),
audit: (req, data) => buildTaskAudit("task_closed", req, data), )
}
)
);
router.post( router.post(
"/tasks/:taskId/kuaishou-cloud/prepare", '/tasks/:taskId/kuaishou-cloud/prepare',
requireAdminRoles(["admin", "operator"]), requireAdminRoles(['admin', 'operator']),
createJsonHandler( createJsonHandler(
(req) => (req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
prepareAdminTaskKuaishouCloudFulfillment( {
getTaskId(req), successMessage: '绑定资源已准备完成',
req.adminSession || null 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( router.post(
"/tasks/:taskId/kuaishou-cloud/refresh-role-info", '/tasks/:taskId/kuaishou-cloud/refresh-role-info',
requireAdminRoles(["admin", "operator", "support"]), requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler( createJsonHandler(
(req) => (req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null),
refreshAdminTaskKuaishouCloudRoleInfo(
getTaskId(req),
req.adminSession || null
),
{ {
successMessage: "角色信息已刷新", successMessage: '角色信息已刷新',
errorMessage: "刷新角色信息失败", errorMessage: '刷新角色信息失败',
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]',
audit: (req, data) => audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data),
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), },
} ),
) )
);
router.post( router.post(
"/tasks/:taskId/kuaishou-cloud/dispatch", '/tasks/:taskId/kuaishou-cloud/rebind-role',
requireAdminRoles(["admin", "operator"]), 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( createJsonHandler(
(req) => (req) =>
dispatchAdminTaskKuaishouCloudFulfillment( dispatchAdminTaskKuaishouCloudFulfillment(
getTaskId(req), getTaskId(req),
req.body as AdminTaskKuaishouCloudDispatchRouteBody, req.body as AdminTaskKuaishouCloudDispatchRouteBody,
req.adminSession || null req.adminSession || null,
), ),
{ {
successMessage: "已完成绑定确认并发货", successMessage: '已完成绑定确认并发货',
errorMessage: "执行发货失败", errorMessage: '执行发货失败',
scope: "[admin/tasks/:taskId/kuaishou-cloud/dispatch]", scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]',
audit: (req, data) => { audit: (req, data) => {
const result = data as AdminTaskActionResponse; const result = data as AdminTaskActionResponse
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody
return { return {
action: "task_kuaishou_cloud_dispatch", action: 'task_kuaishou_cloud_dispatch',
targetType: "task", targetType: 'task',
targetId: String(getTaskId(req)), targetId: String(getTaskId(req)),
data: { data: {
taskId: result.task.taskId, taskId: result.task.taskId,
taskNo: result.task.taskNo, taskNo: result.task.taskNo,
status: result.task.status, status: result.task.status,
deliveryStatus: result.task.deliveryStatus, deliveryStatus: result.task.deliveryStatus,
ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()),
},
};
}, },
} }
) },
); },
),
)
router.post( router.post(
"/tasks/:taskId/kuaishou-cloud/return-number", '/tasks/:taskId/kuaishou-cloud/return-number',
requireAdminRoles(["admin", "operator"]), requireAdminRoles(['admin', 'operator']),
createJsonHandler( createJsonHandler(
(req) => (req) =>
returnNumberAdminTaskKuaishouCloudFulfillment( returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
getTaskId(req),
req.adminSession || null
),
{ {
successMessage: "号码已退还", successMessage: '号码已退还',
errorMessage: "退还号码失败", errorMessage: '退还号码失败',
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]',
audit: (req, data) => audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data),
buildTaskAudit("task_kuaishou_cloud_return_number", req, data), },
} ),
) )
);
function buildTaskAudit(action: string, req: Request, data: unknown) { function buildTaskAudit(action: string, req: Request, data: unknown) {
const result = data as AdminTaskActionResponse; const result = data as AdminTaskActionResponse
return { return {
action, action,
targetType: "task", targetType: 'task',
targetId: String(getTaskId(req)), targetId: String(getTaskId(req)),
data: { data: {
taskId: result.task.taskId, taskId: result.task.taskId,
taskNo: result.task.taskNo, taskNo: result.task.taskNo,
status: result.task.status, status: result.task.status,
}, },
}; }
} }
export default router; export default router
+55 -54
View File
@@ -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 { import {
confirmKuaishouCloudClaimRole, confirmKuaishouCloudClaimRole,
getKuaishouCloudClaimDetail, getKuaishouCloudClaimDetail,
getKuaishouCloudClaimGuideAssetPath, getKuaishouCloudClaimGuideAssetPath,
rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim, redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket, verifyKuaishouCloudClaimTicket,
} from "../services/claim/kuaishou-cloud-claim-service.js"; } from '../services/claim/kuaishou-cloud-claim-service.js'
import { import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js'
buildNotFoundPayload,
createRouteFileHandler,
createRouteHandler,
} from "../utils/http.js";
const router = Router(); const router = Router()
const claimReadRateLimit = createRateLimitMiddleware({ const claimReadRateLimit = createRateLimitMiddleware({
scope: "claim:read", scope: 'claim:read',
windowMs: 60_000, windowMs: 60_000,
max: 120, max: 120,
key: getParamRateLimitKey("token"), key: getParamRateLimitKey('token'),
}); })
const claimWriteRateLimit = createRateLimitMiddleware({ const claimWriteRateLimit = createRateLimitMiddleware({
scope: "claim:write", scope: 'claim:write',
windowMs: 60_000, windowMs: 60_000,
max: 30, max: 30,
key: getParamRateLimitKey("token"), key: getParamRateLimitKey('token'),
}); })
router.get( router.get(
"/:token", '/:token',
claimReadRateLimit, claimReadRateLimit,
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
errorMessage: "查询快手领取详情失败", errorMessage: '查询快手领取详情失败',
scope: "[claims/:token]", scope: '[claims/:token]',
}) }),
); )
router.post( router.post(
"/:token/kuaishou-cloud/verify-ticket", '/:token/kuaishou-cloud/verify-ticket',
claimWriteRateLimit, claimWriteRateLimit,
createRouteHandler( createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), {
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), successMessage: '核销码校验成功',
{ errorMessage: '校验核销码失败',
successMessage: "核销码校验成功", scope: '[claims/:token/kuaishou-cloud/verify-ticket]',
errorMessage: "校验核销码失败", }),
scope: "[claims/:token/kuaishou-cloud/verify-ticket]", )
}
)
);
router.post( router.post(
"/:token/kuaishou-cloud/confirm-role", '/:token/kuaishou-cloud/confirm-role',
claimWriteRateLimit, claimWriteRateLimit,
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
successMessage: "角色已确认", successMessage: '角色已确认',
errorMessage: "确认角色失败", errorMessage: '确认角色失败',
scope: "[claims/:token/kuaishou-cloud/confirm-role]", scope: '[claims/:token/kuaishou-cloud/confirm-role]',
}) }),
); )
router.post( 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, claimWriteRateLimit,
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
successMessage: "兑换请求已提交", successMessage: '兑换请求已提交',
errorMessage: "兑换失败", errorMessage: '兑换失败',
scope: "[claims/:token/kuaishou-cloud/redeem]", scope: '[claims/:token/kuaishou-cloud/redeem]',
}) }),
); )
router.get( router.get(
"/assets/kuaishou-cloud/:filename", '/assets/kuaishou-cloud/:filename',
createRouteFileHandler( createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), {
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), errorMessage: '读取指引图片失败',
{ scope: '[claims/assets/kuaishou-cloud/:filename]',
errorMessage: "读取指引图片失败", }),
scope: "[claims/assets/kuaishou-cloud/:filename]", )
}
)
);
router.use((req, res) => { router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req)); res.status(404).json(buildNotFoundPayload(req))
}); })
export default router; export default router
@@ -7,12 +7,18 @@ import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js'
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.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 { import {
canCompleteManualDispatchTaskStatus, canCompleteManualDispatchTaskStatus,
canDispatchKuaishouCloudFulfillmentStatus, canDispatchKuaishouCloudFulfillmentStatus,
canMarkManualReviewTaskStatus, canMarkManualReviewTaskStatus,
canPrepareKuaishouCloudFulfillmentStatus, canPrepareKuaishouCloudFulfillmentStatus,
canRebindKuaishouCloudRoleStatus,
canReturnKuaishouCloudFulfillmentStatus, canReturnKuaishouCloudFulfillmentStatus,
canRetryTaskStatus, canRetryTaskStatus,
} from '../../domain/task-status.js' } from '../../domain/task-status.js'
@@ -55,7 +61,9 @@ import type {
type JsonRecord = Record<string, any> type JsonRecord = Record<string, any>
export async function getAdminOrders(query: AdminOrderListQueryInput = {}): Promise<AdminOrderListResponse> { export async function getAdminOrders(
query: AdminOrderListQueryInput = {},
): Promise<AdminOrderListResponse> {
const page = normalizePage(query.page) const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const { items, total } = await listOrders({ const { items, total } = await listOrders({
@@ -110,7 +118,8 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
createdAt: order.created_at, createdAt: order.created_at,
updatedAt: order.updated_at, updatedAt: order.updated_at,
itemSummary, itemSummary,
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' rawPayload:
order.raw_payload_json && typeof order.raw_payload_json === 'object'
? order.raw_payload_json ? order.raw_payload_json
: JSON.parse(String(order.raw_payload_json || '{}')), : JSON.parse(String(order.raw_payload_json || '{}')),
fulfillmentProgress: buildOrderFulfillmentProgress(tasks), fulfillmentProgress: buildOrderFulfillmentProgress(tasks),
@@ -122,7 +131,8 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
itemTitle: resolveOrderItemTitle(item), itemTitle: resolveOrderItemTitle(item),
quantity: item.quantity, quantity: item.quantity,
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
spec: item.spec_json && typeof item.spec_json === 'object' spec:
item.spec_json && typeof item.spec_json === 'object'
? item.spec_json ? item.spec_json
: JSON.parse(String(item.spec_json || '{}')), : JSON.parse(String(item.spec_json || '{}')),
})), })),
@@ -182,18 +192,21 @@ export async function getAdminTaskDetail(
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
const cloudSourceLabelMap = buildCloudSourceLabelMap() const cloudSourceLabelMap = buildCloudSourceLabelMap()
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext( const kuaishouCloudFulfillment =
taskContext.kuaishouCloudFulfillment, mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
{ cloudSourceLabelMap }, cloudSourceLabelMap,
) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap }) }) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
return { return {
task: mapAdminTaskListItem({ task: mapAdminTaskListItem(
{
...task, ...task,
sku_code: orderItem?.sku_code || '', sku_code: orderItem?.sku_code || '',
sku_name: orderItem?.sku_name || '', sku_name: orderItem?.sku_name || '',
claim_token: claimToken?.token || '', claim_token: claimToken?.token || '',
}, viewerContext), },
viewerContext,
),
order: order order: order
? { ? {
orderId: order.id, orderId: order.id,
@@ -236,27 +249,42 @@ export async function getAdminTaskDetail(
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
events: taskEvents.map(mapAdminTaskEvent), events: taskEvents.map(mapAdminTaskEvent),
operations: { operations: {
canRetry: viewerContext.canManageTaskLifecycle canRetry:
&& !isManualDispatchTask(task) viewerContext.canManageTaskLifecycle &&
&& canRetryTaskStatus(task.task_status), !isManualDispatchTask(task) &&
canRetryTaskStatus(task.task_status),
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
canClose: canViewerCloseTask(task, viewerContext), canClose: canViewerCloseTask(task, viewerContext),
canMarkManualReview: viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), canMarkManualReview:
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && canCompleteManualDispatchTaskStatus(task.task_status), viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status),
canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle canCompleteManualDispatch:
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' viewerContext.canManageTaskLifecycle &&
&& String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' isManualDispatchTask(task) &&
&& String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' canCompleteManualDispatchTaskStatus(task.task_status),
&& canPrepareKuaishouCloudFulfillmentStatus(task.task_status), canPrepareKuaishouCloudFulfillment:
canRefreshKuaishouCloudRoleInfo: viewerContext.canOperateAssistedTask viewerContext.canManageTaskLifecycle &&
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
&& Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' &&
canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' &&
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' canPrepareKuaishouCloudFulfillmentStatus(task.task_status),
&& canDispatchKuaishouCloudFulfillmentStatus(task.task_status), canRefreshKuaishouCloudRoleInfo:
canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle viewerContext.canOperateAssistedTask &&
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
&& canReturnKuaishouCloudFulfillmentStatus(task.task_status), 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, canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData,
}, },
} }
@@ -4,10 +4,7 @@ import {
parseTaskContext as parseTaskContextValue, parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue, parseTaskState as parseTaskStateValue,
} from '../../utils/task-json.js' } from '../../utils/task-json.js'
import { import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js'
canRegenerateClaimLinkStatus,
isTaskFinalStatus,
} from '../../domain/task-status.js'
import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js' import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js'
import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js' import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js'
@@ -51,10 +48,13 @@ export function mapManualDispatchContext(
return { return {
outcome: String(record.outcome || '').trim(), outcome: String(record.outcome || '').trim(),
deliveryReference: String(record.deliveryReference || '').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(), resultMessage: String(record.resultMessage || '').trim(),
completedAt: record.completedAt || null, completedAt: record.completedAt || null,
completedBy: record.completedBy && typeof record.completedBy === 'object' completedBy:
record.completedBy && typeof record.completedBy === 'object'
? { ? {
userId: Number(record.completedBy.userId || 0) || 0, userId: Number(record.completedBy.userId || 0) || 0,
username: String(record.completedBy.username || '').trim(), username: String(record.completedBy.username || '').trim(),
@@ -78,7 +78,8 @@ export function mapKuaishouCloudFulfillmentContext(
const role = record.role && typeof record.role === 'object' ? record.role : {} const role = record.role && typeof record.role === 'object' ? record.role : {}
const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {} const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {}
const dispatch = record.dispatch && typeof record.dispatch === 'object' ? record.dispatch : {} 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 consume = record.consume && typeof record.consume === 'object' ? record.consume : {}
const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys) const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys)
? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean) ? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean)
@@ -156,6 +157,7 @@ export function mapKuaishouCloudFulfillmentContext(
consumedAt: consume.consumedAt || null, consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || '').trim(), errorMessage: String(consume.errorMessage || '').trim(),
}, },
rebind: mapKuaishouCloudRebindContext(record.rebind),
notes: String(record.notes || '').trim(), notes: String(record.notes || '').trim(),
} }
} }
@@ -204,9 +206,13 @@ export function mapKuaishouCloudTaskStateProjection(
binding: { binding: {
prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending', prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending',
cloudSourceKeys: sourceKey ? [sourceKey] : [], cloudSourceKeys: sourceKey ? [sourceKey] : [],
cloudSourceLabels: sourceKey ? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)] : [], cloudSourceLabels: sourceKey
? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)]
: [],
resolvedSourceKey: sourceKey, resolvedSourceKey: sourceKey,
resolvedSourceLabel: sourceKey ? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap) : '', resolvedSourceLabel: sourceKey
? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)
: '',
skuId: 0, skuId: 0,
skuName: '', skuName: '',
vnKey: '', vnKey: '',
@@ -255,6 +261,10 @@ export function mapKuaishouCloudTaskStateProjection(
consumedAt: null, consumedAt: null,
errorMessage: '', errorMessage: '',
}, },
rebind: {
currentAttempt: 0,
history: [],
},
notes: '', notes: '',
} }
} }
@@ -263,13 +273,67 @@ function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMa
const key = String(sourceKey || '').trim() const key = String(sourceKey || '').trim()
if (!key) return '' if (!key) return ''
const label = labelMap instanceof Map const label = labelMap instanceof Map ? labelMap.get(key) : labelMap?.[key]
? labelMap.get(key)
: labelMap?.[key]
return String(label || key).trim() || 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 { export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object') { if (!value || typeof value !== 'object') {
return null return null
@@ -296,7 +360,9 @@ export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): n
return Number.isFinite(value) && value > 0 ? value : null 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) const role = normalizeAdminRole(session?.role)
return { return {
@@ -326,7 +392,11 @@ export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerCon
return !isTaskFinalStatus(task?.task_status) 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() const normalizedShopName = String(shopName || '').trim()
if (normalizedShopName) { if (normalizedShopName) {
return normalizedShopName return normalizedShopName
@@ -367,8 +437,13 @@ export function resolveOrderItemTitle(item: Partial<OrderItemRow> | null | undef
]) ])
} }
export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefined, orderItemId: number | string): string { export function resolveOrderItemDeliveryMode(
const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId) tasks: TaskLike[] | null | undefined,
orderItemId: number | string,
): string {
const task = (Array.isArray(tasks) ? tasks : []).find(
(item) => item.order_item_id === orderItemId,
)
if (!task) { if (!task) {
return '' return ''
@@ -382,7 +457,12 @@ export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefine
return 'kuaishou_cloud' 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' return 'claim_link'
} }
@@ -393,7 +473,10 @@ export function isManualDispatchTask(task: TaskLike | null | undefined): boolean
return String(task?.executor_key || '').trim() === 'manual_dispatch' 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)) { if (isManualDispatchTask(task)) {
return false return false
} }
@@ -21,20 +21,15 @@ import {
import { createHttpError } from '../../../utils/http.js' import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js' import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js' import { TASK_STATUS } from '../../../domain/task-status.js'
import { import { createAdminViewerContext, parseTaskContext } from '../admin-read-shared-helpers.js'
createAdminViewerContext, import { getRequiredTask, mapTaskActionPayload } from '../admin-task-read-helpers.js'
parseTaskContext,
} from '../admin-read-shared-helpers.js'
import {
getRequiredTask,
mapTaskActionPayload,
} from '../admin-task-read-helpers.js'
import { import {
ensureTaskClaimLink, ensureTaskClaimLink,
getTaskClaimExpiresAt, getTaskClaimExpiresAt,
maskCode, maskCode,
maskPhone, maskPhone,
} from '../write-helpers.js' } from '../write-helpers.js'
import { rebindKuaishouCloudTaskRole } from '../../fulfillment/kuaishou-cloud/index.js'
import { import {
isKuaishouCloudTask, isKuaishouCloudTask,
normalizeKuaishouCloudFlow, normalizeKuaishouCloudFlow,
@@ -210,7 +205,10 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
updated_at: now, updated_at: now,
}) })
await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', { await createTaskEvent(
task.id,
'kuaishou_cloud_binding_prepared',
{
skuId: flowWithResolvedBinding.binding.skuId, skuId: flowWithResolvedBinding.binding.skuId,
skuName: flowWithResolvedBinding.binding.skuName, skuName: flowWithResolvedBinding.binding.skuName,
vnKey: preparedBinding.vnKey, vnKey: preparedBinding.vnKey,
@@ -220,7 +218,9 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
purchaseTriggered, purchaseTriggered,
usedKnapsack, usedKnapsack,
resolvedByName: resolvedBinding.resolvedByName, resolvedByName: resolvedBinding.resolvedByName,
}, now) },
now,
)
return { return {
task: mapTaskActionPayload(updatedTask), task: mapTaskActionPayload(updatedTask),
@@ -309,7 +309,8 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
...syncedFlow.ticket, ...syncedFlow.ticket,
code: ticketCode || persistedTicketCode, code: ticketCode || persistedTicketCode,
capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt,
capturedBy: ticketCode && session capturedBy:
ticketCode && session
? { ? {
userId: Number(session.userId || 0) || 0, userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(), username: String(session.username || '').trim(),
@@ -338,20 +339,27 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN, task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN,
delivery_status: 'delivered', delivery_status: 'delivered',
result_code: 'kuaishou_cloud_dispatched', 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: '', last_error: '',
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
updated_at: now, updated_at: now,
}) })
await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', { await createTaskEvent(
task.id,
'kuaishou_cloud_dispatched',
{
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
skuId: syncedFlow.binding.skuId, skuId: syncedFlow.binding.skuId,
vnId: syncedFlow.binding.vnId, vnId: syncedFlow.binding.vnId,
vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone),
sendType: dispatchResult.sendType, sendType: dispatchResult.sendType,
note: dispatchResult.note, note: dispatchResult.note,
}, now) },
now,
)
return { return {
task: mapTaskActionPayload(updatedTask), task: mapTaskActionPayload(updatedTask),
@@ -414,7 +422,8 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
name: bindInfo.name, name: bindInfo.name,
rid: bindInfo.rid, rid: bindInfo.rid,
refreshedAt: now, refreshedAt: now,
errorMessage: bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', errorMessage:
bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新',
rawInfo: bindInfo.rawInfo, rawInfo: bindInfo.rawInfo,
}, },
}, },
@@ -427,7 +436,10 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
updated_at: now, updated_at: now,
}) })
await createTaskEvent(task.id, 'kuaishou_cloud_role_info_refreshed', { await createTaskEvent(
task.id,
'kuaishou_cloud_role_info_refreshed',
{
roleName: bindInfo.name, roleName: bindInfo.name,
roleId: bindInfo.rid, roleId: bindInfo.rid,
vnId: flow.binding.vnId, vnId: flow.binding.vnId,
@@ -438,13 +450,47 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
role: String(session.role || '').trim(), role: String(session.role || '').trim(),
} }
: null, : null,
}, now) },
now,
)
return { return {
task: mapTaskActionPayload(updatedTask), task: mapTaskActionPayload(updatedTask),
} }
} }
export async function rebindAdminTaskKuaishouCloudRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
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( export async function returnNumberAdminTaskKuaishouCloudFulfillment(
taskId: AdminEntityIdInput, taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null, session: AdminViewerSessionInput | null = null,
@@ -519,7 +565,11 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
consumeStatus = consumeResult.consumed ? 'success' : 'failed' consumeStatus = consumeResult.consumed ? 'success' : 'failed'
consumedAt = consumeResult.consumed ? now : null consumedAt = consumeResult.consumed ? now : null
consumeErrorMessage = String(consumeResult.errorMessage || '').trim() 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' consumeStatus = 'failed'
consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销' consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销'
} else { } else {
@@ -590,10 +640,15 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
updated_at: now, updated_at: now,
}) })
await createTaskEvent(task.id, 'kuaishou_cloud_number_returned', { await createTaskEvent(
task.id,
'kuaishou_cloud_number_returned',
{
vnId: flow.binding.vnId, vnId: flow.binding.vnId,
vnPhoneMasked: maskPhone(flow.binding.vnPhone), vnPhoneMasked: maskPhone(flow.binding.vnPhone),
}, now) },
now,
)
await createTaskEvent( await createTaskEvent(
task.id, task.id,
@@ -26,6 +26,7 @@ import {
maskCode, maskCode,
normalizeKuaishouCloudFlow, normalizeKuaishouCloudFlow,
prepareKuaishouCloudFulfillmentTask, prepareKuaishouCloudFulfillmentTask,
rebindKuaishouCloudTaskRole,
refreshKuaishouCloudTaskRoleInfo, refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud/index.js' } from '../fulfillment/kuaishou-cloud/index.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.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) 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 shopName = String(flow.consume.shopName || context.order.shop_name || '').trim()
let detailResult let detailResult
@@ -134,13 +137,16 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
} else { } else {
const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) { if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
await prepareKuaishouCloudFulfillmentTask({ await prepareKuaishouCloudFulfillmentTask(
{
...context.task, ...context.task,
context_json: JSON.stringify(nextContext), context_json: JSON.stringify(nextContext),
}, { },
{
source: 'claim_page_ticket_verified', source: 'claim_page_ticket_verified',
actor: { source: 'claim_page' }, actor: { source: 'claim_page' },
}) },
)
} else { } else {
await updateTask(context.task.id, { await updateTask(context.task.id, {
claim_token: context.claimToken.token, 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', { await createTaskEvent(
context.task.id,
'kuaishou_cloud_ticket_verified',
{
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
shopId, shopId,
shopName, shopName,
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
mock: mockTicketCode, mock: mockTicketCode,
}, now) },
now,
)
return getKuaishouCloudClaimDetail(token) return getKuaishouCloudClaimDetail(token)
} }
@@ -195,10 +206,10 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
let task = context.task let task = context.task
if ( if (
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
&& !isKuaishouCloudMockTask(task) !isKuaishouCloudMockTask(task)
) { ) {
task = await syncKuaishouCloudRoleInfo(task) || task task = (await syncKuaishouCloudRoleInfo(task)) || task
} }
return buildClaimDetailPayload({ 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<TaskRow> | null | undefined): JsonObject { function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject {
const rawValue = task?.context_json const rawValue = task?.context_json
if (!rawValue) { if (!rawValue) {
@@ -289,10 +318,13 @@ async function queryKuaishouEticketConsumeDetailWithShopFallback({
} }
} }
throw createHttpError(lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', { throw createHttpError(
lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整',
{
statusCode: 409, statusCode: 409,
errorCode: 'claim_kuaishou_cloud_ticket_invalid', errorCode: 'claim_kuaishou_cloud_ticket_invalid',
}) },
)
} }
function resolveKuaishouEticketDetailCandidateShops({ function resolveKuaishouEticketDetailCandidateShops({
@@ -333,9 +365,7 @@ function isUsableKuaishouEticketShopConfig(
shopConfig: KuaishouEticketShopConfig | null | undefined, shopConfig: KuaishouEticketShopConfig | null | undefined,
): shopConfig is KuaishouEticketShopConfig { ): shopConfig is KuaishouEticketShopConfig {
return Boolean( return Boolean(
shopConfig && shopConfig && shopConfig.enabled !== false && String(shopConfig.cookie || '').trim(),
shopConfig.enabled !== false &&
String(shopConfig.cookie || '').trim(),
) )
} }
@@ -404,11 +434,16 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
updated_at: now, updated_at: now,
}) })
await createTaskEvent(context.task.id, 'kuaishou_cloud_role_confirmed', { await createTaskEvent(
context.task.id,
'kuaishou_cloud_role_confirmed',
{
vnPhone: flow.binding.vnPhone, vnPhone: flow.binding.vnPhone,
roleName: flow.binding.roleName, roleName: flow.binding.roleName,
roleId: flow.binding.roleId, roleId: flow.binding.roleId,
}, now) },
now,
)
return getKuaishouCloudClaimDetail(token) return getKuaishouCloudClaimDetail(token)
} }
@@ -474,10 +509,15 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
updated_at: nowIso(), updated_at: nowIso(),
}) })
await createTaskEvent(lockedTask.id, 'kuaishou_cloud_redeem_failed', { await createTaskEvent(
lockedTask.id,
'kuaishou_cloud_redeem_failed',
{
source: 'claim_page_redeem', source: 'claim_page_redeem',
errorMessage: message, errorMessage: message,
}, nowIso()) },
nowIso(),
)
throw error throw error
} }
@@ -490,8 +530,8 @@ function isKuaishouCloudMockTask(task: Partial<TaskRow> | null | undefined) {
} }
function isKuaishouCloudMockContext(context: JsonObject = {}) { function isKuaishouCloudMockContext(context: JsonObject = {}) {
const flow = context.kuaishouCloudFulfillment const flow =
&& typeof context.kuaishouCloudFulfillment === 'object' context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object'
? context.kuaishouCloudFulfillment ? context.kuaishouCloudFulfillment
: {} : {}
const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock
@@ -502,8 +542,9 @@ function isKuaishouCloudMockContext(context: JsonObject = {}) {
function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) { function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) {
const flow = normalizeKuaishouCloudFlow(value) const flow = normalizeKuaishouCloudFlow(value)
const source = flow as JsonObject const source = flow as JsonObject
const bindUrl = flow.binding.bindUrl const bindUrl =
|| `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}` flow.binding.bindUrl ||
`https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}`
const vnPhone = flow.binding.vnPhone || '13800000000' const vnPhone = flow.binding.vnPhone || '13800000000'
const roleName = flow.binding.roleName || flow.role.name || '测试角色' const roleName = flow.binding.roleName || flow.role.name || '测试角色'
const roleId = flow.binding.roleId || flow.role.rid || '10001' const roleId = flow.binding.roleId || flow.role.rid || '10001'
@@ -590,8 +631,13 @@ async function completeMockKuaishouCloudClaimTask(task: TaskRow, timestamp: stri
updated_at: timestamp, updated_at: timestamp,
}) })
await createTaskEvent(task.id, 'kuaishou_cloud_mock_redeemed', { await createTaskEvent(
task.id,
'kuaishou_cloud_mock_redeemed',
{
source: 'claim_page_mock', source: 'claim_page_mock',
deliveryItems: flow.deliveryItems, deliveryItems: flow.deliveryItems,
}, timestamp) },
timestamp,
)
} }
@@ -1,98 +1,77 @@
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js"; import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js'
import { import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js'
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<string, any>; export type JsonObject = Record<string, any>
export function isKuaishouCloudTask(task: unknown) { export function isKuaishouCloudTask(task: unknown) {
const source = task && typeof task === "object" ? task as JsonObject : {}; const source = task && typeof task === 'object' ? (task as JsonObject) : {}
return String(source.executor_key || "").trim() === "kuaishou_ct_assisted"; return String(source.executor_key || '').trim() === 'kuaishou_ct_assisted'
} }
export function normalizeKuaishouCloudFlow(value: unknown) { export function normalizeKuaishouCloudFlow(value: unknown) {
const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {}
const binding = const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
source.binding && typeof source.binding === "object" ? source.binding : {}; const role = source.role && typeof source.role === 'object' ? source.role : {}
const role = const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {}
source.role && typeof source.role === "object" ? source.role : {}; const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {}
const purchase =
source.purchase && typeof source.purchase === "object"
? source.purchase
: {};
const dispatch =
source.dispatch && typeof source.dispatch === "object"
? source.dispatch
: {};
const returnNumber = const returnNumber =
source.returnNumber && typeof source.returnNumber === "object" source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {}
? source.returnNumber const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
: {}; const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
const consume = const rebind = source.rebind && typeof source.rebind === 'object' ? source.rebind : {}
source.consume && typeof source.consume === "object" ? source.consume : {}; const deliveryItems = normalizeDeliveryItems(source, binding)
const ticket =
source.ticket && typeof source.ticket === "object" ? source.ticket : {};
const deliveryItems = normalizeDeliveryItems(source, binding);
const roleName = String(role.name || binding.roleName || "").trim(); const roleName = String(role.name || binding.roleName || '').trim()
const roleId = String(role.rid || binding.roleId || "").trim(); const roleId = String(role.rid || binding.roleId || '').trim()
const bindPreparedAt = binding.bindPreparedAt || null; const bindPreparedAt = binding.bindPreparedAt || null
const bindExpiresAt = const bindExpiresAt =
binding.bindExpiresAt || binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt)
resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt);
return { return {
...source, ...source,
configId: String(source.configId || "").trim(), configId: String(source.configId || '').trim(),
internalSkuCode: String(source.internalSkuCode || "").trim(), internalSkuCode: String(source.internalSkuCode || '').trim(),
internalSkuName: String(source.internalSkuName || "").trim(), internalSkuName: String(source.internalSkuName || '').trim(),
deliveryItems, deliveryItems,
ticket: { ticket: {
code: String(ticket.code || "").trim(), code: String(ticket.code || '').trim(),
status: String(ticket.status || "pending").trim() || "pending", status: String(ticket.status || 'pending').trim() || 'pending',
capturedAt: ticket.capturedAt || null, capturedAt: ticket.capturedAt || null,
capturedBy: ticket.capturedBy || null, capturedBy: ticket.capturedBy || null,
verifiedAt: ticket.verifiedAt || null, verifiedAt: ticket.verifiedAt || null,
oid: String(ticket.oid || "").trim(), oid: String(ticket.oid || '').trim(),
formToken: String(ticket.formToken || "").trim(), formToken: String(ticket.formToken || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0, leftCount: Number(ticket.leftCount || 0) || 0,
goodsTitle: String(ticket.goodsTitle || "").trim(), goodsTitle: String(ticket.goodsTitle || '').trim(),
}, },
binding: { binding: {
prepareStatus: prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
String(binding.prepareStatus || "pending").trim() || "pending",
cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys), cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys),
resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(), resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
skuId: Number(binding.skuId || 0) || 0, skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || "").trim(), skuName: String(binding.skuName || '').trim(),
vnKey: vnKey:
String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
KUAISHOU_CLOUD_FIXED_VN_KEY,
vnId: Number(binding.vnId || 0) || 0, vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || "").trim(), vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || "").trim(), bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt, bindPreparedAt,
bindExpiresAt, bindExpiresAt,
bindProbeAt: binding.bindProbeAt || null, bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || "").trim(), bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || "").trim(), bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName, roleName,
roleId, roleId,
}, },
role: { role: {
status: status: String(role.status || (roleName || roleId ? 'ready' : 'pending')).trim() || 'pending',
String(
role.status || (roleName || roleId ? "ready" : "pending")
).trim() || "pending",
name: roleName, name: roleName,
rid: roleId, rid: roleId,
refreshedAt: role.refreshedAt || null, refreshedAt: role.refreshedAt || null,
errorMessage: String(role.errorMessage || "").trim(), errorMessage: String(role.errorMessage || '').trim(),
rawInfo: rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
role.rawInfo && typeof role.rawInfo === "object" ? role.rawInfo : null,
}, },
purchase: { purchase: {
autoBuyEnabled: purchase.autoBuyEnabled !== false, autoBuyEnabled: purchase.autoBuyEnabled !== false,
@@ -105,171 +84,166 @@ export function normalizeKuaishouCloudFlow(value: unknown) {
items: Array.isArray(purchase.items) ? purchase.items : [], items: Array.isArray(purchase.items) ? purchase.items : [],
}, },
dispatch: { dispatch: {
status: String(dispatch.status || "pending").trim() || "pending", status: String(dispatch.status || 'pending').trim() || 'pending',
dispatchAt: dispatch.dispatchAt || null, dispatchAt: dispatch.dispatchAt || null,
dispatchBy: dispatch.dispatchBy || null, dispatchBy: dispatch.dispatchBy || null,
sendType: Number(dispatch.sendType || 0) || 0, sendType: Number(dispatch.sendType || 0) || 0,
note: String(dispatch.note || "").trim(), note: String(dispatch.note || '').trim(),
items: Array.isArray(dispatch.items) ? dispatch.items : [], items: Array.isArray(dispatch.items) ? dispatch.items : [],
}, },
returnNumber: { returnNumber: {
status: String(returnNumber.status || "pending").trim() || "pending", status: String(returnNumber.status || 'pending').trim() || 'pending',
returnedAt: returnNumber.returnedAt || null, returnedAt: returnNumber.returnedAt || null,
returnedBy: returnNumber.returnedBy || null, returnedBy: returnNumber.returnedBy || null,
autoReturnEnabled: returnNumber.autoReturnEnabled === true, autoReturnEnabled: returnNumber.autoReturnEnabled === true,
}, },
consume: { consume: {
status: String(consume.status || "pending").trim() || "pending", status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || "").trim(), shopId: String(consume.shopId || '').trim(),
shopName: String(consume.shopName || "").trim(), shopName: String(consume.shopName || '').trim(),
autoConsumeEnabled: consume.autoConsumeEnabled === true, autoConsumeEnabled: consume.autoConsumeEnabled === true,
consumedAt: consume.consumedAt || null, 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) { export function normalizeKuaishouCloudDeliveryItems(value: unknown) {
const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {}
const binding = const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
source.binding && typeof source.binding === "object" ? source.binding : {}; return normalizeDeliveryItems(source, binding)
return normalizeDeliveryItems(source, binding);
} }
export function normalizeKuaishouCloudRoleInfo(value: unknown) { 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 = const nestedBindInfo =
rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object" rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null
? rawInfo.sBindInfo const source = nestedBindInfo || rawInfo
: null;
const source = nestedBindInfo || rawInfo;
return { return {
name: String( name: String(
source?.name || source?.name || source?.roleName || source?.nickname || source?.sRoleName || '',
source?.roleName ||
source?.nickname ||
source?.sRoleName ||
""
).trim(), ).trim(),
rid: String( rid: String(
source?.rid || source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '',
source?.roleId ||
source?.uid ||
source?.sRoleId ||
source?.sUserId ||
""
).trim(), ).trim(),
rawInfo, rawInfo,
}; }
} }
export function maskPhone(value: unknown) { export function maskPhone(value: unknown) {
return maskPhoneValue(value); return maskPhoneValue(value)
} }
export function maskCode(value: unknown) { export function maskCode(value: unknown) {
return maskCodeValue(value); return maskCodeValue(value)
} }
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) {
const preparedTime = Date.parse(String(preparedAt || "")); const preparedTime = Date.parse(String(preparedAt || ''))
if (!Number.isFinite(preparedTime)) { if (!Number.isFinite(preparedTime)) {
return null; return null
} }
const config = resolveCloudtentaclesConfig(); const config = resolveCloudtentaclesConfig()
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600); const ttlSeconds = Number(config.bindUrlTtlSeconds || 600)
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString(); return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString()
} }
export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) {
const normalizedFlow = normalizeKuaishouCloudFlow(flow); const normalizedFlow = normalizeKuaishouCloudFlow(flow)
if (!normalizedFlow.binding.bindUrl) { if (!normalizedFlow.binding.bindUrl) {
return false; return false
} }
const expiresAt = normalizedFlow.binding.bindExpiresAt; const expiresAt = normalizedFlow.binding.bindExpiresAt
if (!expiresAt) { if (!expiresAt) {
return false; return false
} }
const expiresTime = Date.parse(String(expiresAt || "")); const expiresTime = Date.parse(String(expiresAt || ''))
if (!Number.isFinite(expiresTime)) { if (!Number.isFinite(expiresTime)) {
return false; return false
} }
return expiresTime > now.getTime(); return expiresTime > now.getTime()
} }
export function normalizeStringArray(value: unknown) { export function normalizeStringArray(value: unknown) {
if (Array.isArray(value)) { 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 return value
.split(",") .split(',')
.map((v) => v.trim()) .map((v) => v.trim())
.filter(Boolean); .filter(Boolean)
} }
return []; return []
} }
function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) { function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) {
const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : []; const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : []
const items = rawItems const items = rawItems.map((item) => normalizeDeliveryItem(item)).filter(Boolean) as Array<{
.map((item) => normalizeDeliveryItem(item)) cloudSkuId: number
.filter(Boolean) as Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>; cloudSkuName: string
quantity: number
}>
if (items.length > 0) { 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) { if (!skuId) {
return []; return []
} }
return [ return [
{ {
cloudSkuId: skuId, cloudSkuId: skuId,
cloudSkuName: String(binding.skuName || "").trim(), cloudSkuName: String(binding.skuName || '').trim(),
quantity: 1, quantity: 1,
}, },
]; ]
} }
function normalizeDeliveryItem(value: unknown) { function normalizeDeliveryItem(value: unknown) {
const source = value && typeof value === "object" ? value as JsonObject : {}; const source = value && typeof value === 'object' ? (value as JsonObject) : {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 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 { return {
cloudSkuId, cloudSkuId,
cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(),
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
}; }
} }
function mergeDeliveryItems( function mergeDeliveryItems(
items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }> items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>,
) { ) {
const merged = new Map<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>(); const merged = new Map<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>()
for (const item of items) { for (const item of items) {
const existing = merged.get(item.cloudSkuId); const existing = merged.get(item.cloudSkuId)
if (existing) { if (existing) {
existing.quantity += item.quantity; existing.quantity += item.quantity
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName; existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
continue; continue
} }
merged.set(item.cloudSkuId, { ...item }); merged.set(item.cloudSkuId, { ...item })
} }
return Array.from(merged.values()); return Array.from(merged.values())
} }
@@ -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 = {}) { export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) { if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', { throw createHttpError('当前任务不是快手 Cloud 履约任务', {
@@ -66,6 +66,13 @@ export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) {
) )
} }
export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`,
{},
)
}
export function dispatchAdminTaskKuaishouCloudFulfillment( export function dispatchAdminTaskKuaishouCloudFulfillment(
taskId: number | string, taskId: number | string,
payload: { payload: {
+4
View File
@@ -18,6 +18,10 @@ export function confirmKuaishouCloudClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {})
} }
export function rebindKuaishouCloudClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {})
}
export function redeemKuaishouCloudClaim(token: string) { export function redeemKuaishouCloudClaim(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {}) return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {})
} }
+30
View File
@@ -34,6 +34,7 @@ export interface AdminTaskOperations {
canCompleteManualDispatch: boolean canCompleteManualDispatch: boolean
canPrepareKuaishouCloudFulfillment: boolean canPrepareKuaishouCloudFulfillment: boolean
canRefreshKuaishouCloudRoleInfo: boolean canRefreshKuaishouCloudRoleInfo: boolean
canRebindKuaishouCloudRole: boolean
canDispatchKuaishouCloudFulfillment: boolean canDispatchKuaishouCloudFulfillment: boolean
canReturnKuaishouCloudFulfillment: boolean canReturnKuaishouCloudFulfillment: boolean
canViewSensitiveTaskData: boolean canViewSensitiveTaskData: boolean
@@ -169,6 +170,35 @@ export interface AdminTaskDetail {
consumedAt: string | null consumedAt: string | null
errorMessage: string errorMessage: string
} }
rebind: {
currentAttempt: number
history: Array<{
attempt: number
source: string
requestedAt: string | null
requestedBy: Record<string, unknown> | 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 notes: string
} }
manualDispatch: null | { manualDispatch: null | {
+4
View File
@@ -119,6 +119,10 @@ export interface ClaimKuaishouCloudFlowInfo {
consumedAt: string | null consumedAt: string | null
errorMessage: string errorMessage: string
} }
rebind?: {
currentAttempt: number
history: Array<Record<string, unknown>>
}
} }
export interface ClaimDetailData { export interface ClaimDetailData {
+85 -84
View File
@@ -1,134 +1,135 @@
export function formatStatusWithRaw( export function formatStatusWithRaw(status: string, labelMap: Record<string, string> = {}) {
status: string, const normalized = String(status || '').trim()
labelMap: Record<string, string> = {}
) {
const normalized = String(status || "").trim();
if (!normalized) { if (!normalized) {
return "-"; return '-'
} }
const key = normalized.toLowerCase(); const key = normalized.toLowerCase()
const label = labelMap[key]; const label = labelMap[key]
if (!label || label === normalized) { if (!label || label === normalized) {
return normalized; return normalized
} }
return `${label} (${normalized})`; return `${label} (${normalized})`
} }
export function formatAuditAction(action: string) { export function formatAuditAction(action: string) {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
admin_user_created: "创建后台用户", admin_user_created: '创建后台用户',
admin_user_role_updated: "修改用户角色", admin_user_role_updated: '修改用户角色',
admin_user_status_updated: "修改用户状态", admin_user_status_updated: '修改用户状态',
admin_user_password_reset: "重置用户密码", admin_user_password_reset: '重置用户密码',
task_regenerate_claim_link: "重发领取链接", task_regenerate_claim_link: '重发领取链接',
task_closed: "关闭任务", task_kuaishou_cloud_rebind_role: '换绑角色',
task_mark_manual_review: "转人工处理", task_closed: '关闭任务',
task_complete_manual_dispatch: "完成人工履约", task_mark_manual_review: '转人工处理',
platform_shop_config_updated: "更新店铺配置", task_complete_manual_dispatch: '完成人工履约',
platform_fulfillment_config_updated: "更新履约配置", platform_shop_config_updated: '更新店铺配置',
}; platform_fulfillment_config_updated: '更新履约配置',
}
return formatStatusWithRaw(action, labelMap); return formatStatusWithRaw(action, labelMap)
} }
export function formatAuditTargetType(targetType: string) { export function formatAuditTargetType(targetType: string) {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
admin_user: "后台用户", admin_user: '后台用户',
task: "交付任务", task: '交付任务',
platform_config: "平台配置", platform_config: '平台配置',
}; }
return formatStatusWithRaw(targetType, labelMap); return formatStatusWithRaw(targetType, labelMap)
} }
export function formatTaskEventType(eventType: string) { export function formatTaskEventType(eventType: string) {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
manual_dispatch_completed: "人工履约已回写", manual_dispatch_completed: '人工履约已回写',
claim_redeem_code_used: "兑换码已使用", claim_redeem_code_used: '兑换码已使用',
claim_redeem_code_invalid: "兑换码错误", claim_redeem_code_invalid: '兑换码错误',
claim_redeem_completed: "兑换链路完成", 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) { export function formatRedeemOutcomeLabel(value: string) {
const normalized = String(value || "").trim(); const normalized = String(value || '').trim()
switch (normalized) { switch (normalized) {
case "success": case 'success':
return "兑换成功"; return '兑换成功'
case "code_used": case 'code_used':
return "兑换码已使用"; return '兑换码已使用'
case "code_invalid": case 'code_invalid':
return "兑换码错误"; return '兑换码错误'
case "captcha_rejected": case 'captcha_rejected':
return "验证码错误"; return '验证码错误'
case "failed": case 'failed':
return "兑换失败"; return '兑换失败'
default: default:
return normalized || "-"; return normalized || '-'
} }
} }
export function formatRedeemResolutionStatus(value: string) { export function formatRedeemResolutionStatus(value: string) {
return value === "success" return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-'
? "已完成"
: value === "failed"
? "失败收口"
: value || "-";
} }
export function formatKuaishouRoleInfoLabel(value: string) { export function formatKuaishouRoleInfoLabel(value: string) {
const normalized = String(value || "").trim(); const normalized = String(value || '').trim()
switch (normalized) { switch (normalized) {
case "name": case 'name':
return "角色名称"; return '角色名称'
case "rid": case 'rid':
return "角色 ID"; return '角色 ID'
default: default:
return normalized || "-"; return normalized || '-'
} }
} }
export function formatTaskEventPayload(payload: Record<string, unknown>) { export function formatTaskEventPayload(payload: Record<string, unknown>) {
const parts = [ const parts = [
payload.outcome ? `结果 ${payload.outcome}` : "", payload.outcome ? `结果 ${payload.outcome}` : '',
payload.resultCode ? `代码 ${payload.resultCode}` : "", payload.resultCode ? `代码 ${payload.resultCode}` : '',
payload.resultMessage ? `说明 ${payload.resultMessage}` : "", payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
payload.codeMasked ? `凭据 ${payload.codeMasked}` : "", payload.codeMasked ? `凭据 ${payload.codeMasked}` : '',
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "", payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '',
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "", payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '',
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "", payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '',
payload.vnId ? `虚拟号 #${payload.vnId}` : "", payload.vnId ? `虚拟号 #${payload.vnId}` : '',
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "", payload.oldVnId ? `旧虚拟号 #${payload.oldVnId}` : '',
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : "", payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '',
payload.sendType ? `发送类型 ${payload.sendType}` : "", payload.oldVnPhoneMasked ? `旧号码 ${payload.oldVnPhoneMasked}` : '',
payload.note ? `备注 ${payload.note}` : "", 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 payload.previousOutcome
? `切换原因 ${formatRedeemOutcomeLabel( ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}`
String(payload.previousOutcome || "") : '',
)}` payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
: "", payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "", payload.trigger ? `触发 ${payload.trigger}` : '',
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "", payload.reason ? `原因 ${payload.reason}` : '',
payload.trigger ? `触发 ${payload.trigger}` : "", payload.purchaseTriggered === true ? '已触发购买' : '',
payload.reason ? `原因 ${payload.reason}` : "", payload.usedKnapsack === true ? '使用背包库存' : '',
payload.purchaseTriggered === true ? "已触发购买" : "", payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
payload.usedKnapsack === true ? "使用背包库存" : "", payload.requestId ? `请求 ${payload.requestId}` : '',
payload.responseStatus ? `HTTP ${payload.responseStatus}` : "", payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
payload.requestId ? `请求 ${payload.requestId}` : "", ].filter(Boolean)
payload.errorMessage ? `错误 ${payload.errorMessage}` : "",
].filter(Boolean);
if (parts.length > 0) { if (parts.length > 0) {
return parts.join(" · "); return parts.join(' · ')
} }
return JSON.stringify(payload || {}); return JSON.stringify(payload || {})
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export const adminAuditActionOptions = [
{ label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户角色', value: 'admin_user_role_updated' },
{ label: '修改用户状态', value: 'admin_user_status_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' },
{ label: '重置用户密码', value: 'admin_user_password_reset' }, { 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_closed' },
{ label: '转人工处理', value: 'task_mark_manual_review' }, { label: '转人工处理', value: 'task_mark_manual_review' },
] ]
@@ -4,7 +4,7 @@ import { onMounted } from 'vue'
import { import {
closeAdminTask, closeAdminTask,
markAdminTaskManualReview, markAdminTaskManualReview,
regenerateAdminTaskClaimLink, rebindAdminTaskKuaishouCloudRole,
retryAdminTask, retryAdminTask,
} from '@/services/admin' } from '@/services/admin'
@@ -83,11 +83,11 @@ onMounted(loadDetail)
`确认重试任务 ${detail!.task.taskNo} 吗?`, `确认重试任务 ${detail!.task.taskNo} 吗?`,
) )
" "
@regenerate-claim-link=" @rebind-role="
runAction( runAction(
() => regenerateAdminTaskClaimLink(detail!.task.taskId), () => rebindAdminTaskKuaishouCloudRole(detail!.task.taskId),
'领取链接已重新生成', '角色换绑资源已准备完成',
`确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效`, `确认任务 ${detail!.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码`,
) )
" "
@prepare-kuaishou-cloud="submitKuaishouCloudPrepare" @prepare-kuaishou-cloud="submitKuaishouCloudPrepare"
@@ -1,38 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from 'vue'
import { useAdminAction } from "@/composables/useAdminAction"; import { useAdminAction } from '@/composables/useAdminAction'
import { useAdminListPage } from "@/composables/useAdminListPage"; import { useAdminListPage } from '@/composables/useAdminListPage'
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue"; import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import { import {
closeAdminTask, closeAdminTask,
fetchAdminTasks, fetchAdminTasks,
markAdminTaskManualReview, markAdminTaskManualReview,
regenerateAdminTaskClaimLink, rebindAdminTaskKuaishouCloudRole,
retryAdminTask, retryAdminTask,
} from "@/services/admin"; } from '@/services/admin'
import type { AdminTaskListItem } from "@/types/admin"; import type { AdminTaskListItem } from '@/types/admin'
import { hasAdminRole } from "@/utils/admin-auth"; import { hasAdminRole } from '@/utils/admin-auth'
import { adminTaskStatusOptions } from "@/utils/admin-options"; import { adminTaskStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from "@/utils/admin-time"; import { formatAdminDateTime } from '@/utils/admin-time'
const status = ref(""); const status = ref('')
const taskNo = ref(""); const taskNo = ref('')
const platformOrderId = ref(""); const platformOrderId = ref('')
const roleId = ref(""); const roleId = ref('')
const skuCode = ref(""); const skuCode = ref('')
const dateFrom = ref(""); const dateFrom = ref('')
const dateTo = ref(""); const dateTo = ref('')
const lastClaimUrl = ref(""); const lastClaimUrl = ref('')
const canManageTaskLifecycle = hasAdminRole("operator"); const canManageTaskLifecycle = hasAdminRole('operator')
const canCloseTasks = hasAdminRole("support"); const canCloseTasks = hasAdminRole('support')
type AdminTaskActionKey = type AdminTaskActionKey = 'retry' | 'rebind_role' | 'manual_review' | 'close'
| "retry"
| "regenerate_claim_link"
| "manual_review"
| "close";
const { const {
loading, loading,
@@ -41,7 +37,7 @@ const {
pagination, pagination,
loadPage: loadTasks, loadPage: loadTasks,
} = useAdminListPage<AdminTaskListItem>({ } = useAdminListPage<AdminTaskListItem>({
defaultErrorMessage: "读取任务列表失败", defaultErrorMessage: '读取任务列表失败',
fetchPage: (page, pageSize) => fetchPage: (page, pageSize) =>
fetchAdminTasks({ fetchAdminTasks({
page, page,
@@ -54,141 +50,144 @@ const {
dateFrom: dateFrom.value.trim(), dateFrom: dateFrom.value.trim(),
dateTo: dateTo.value.trim(), dateTo: dateTo.value.trim(),
}), }),
}); })
const { actionLoadingId, runAction } = useAdminAction(); const { actionLoadingId, runAction } = useAdminAction()
function handleActionCommand( function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
actionKey: AdminTaskActionKey, if (actionKey === 'retry') {
item: AdminTaskListItem
) {
if (actionKey === "retry") {
void runAction({ void runAction({
id: item.taskId, id: item.taskId,
action: async () => { action: async () => {
const response = await retryAdminTask(item.taskId); const response = await retryAdminTask(item.taskId)
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl; if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
}, },
successMessage: "任务已重试", successMessage: '任务已重试',
confirmText: `确认重试任务 ${item.taskNo} 吗?`, confirmText: `确认重试任务 ${item.taskNo} 吗?`,
onAfterAction: async () => { onAfterAction: async () => {
await loadTasks(); await loadTasks()
}, },
}); })
return; return
} }
if (actionKey === "regenerate_claim_link") { if (actionKey === 'rebind_role') {
void runAction({ void runAction({
id: item.taskId, id: item.taskId,
action: async () => { action: async () => {
const response = await regenerateAdminTaskClaimLink(item.taskId); const response = await rebindAdminTaskKuaishouCloudRole(item.taskId)
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl; if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
}, },
successMessage: "领取链接已重新生成", successMessage: '角色换绑资源已准备完成',
confirmText: `确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效`, confirmText: `确认任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码`,
onAfterAction: async () => { onAfterAction: async () => {
await loadTasks(); await loadTasks()
}, },
}); })
return; return
} }
if (actionKey === "manual_review") { if (actionKey === 'manual_review') {
void runAction({ void runAction({
id: item.taskId, id: item.taskId,
action: () => markAdminTaskManualReview(item.taskId), action: () => markAdminTaskManualReview(item.taskId),
successMessage: "任务已转人工处理", successMessage: '任务已转人工处理',
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`, confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
onAfterAction: async () => { onAfterAction: async () => {
await loadTasks(); await loadTasks()
}, },
}); })
return; return
} }
void runAction({ void runAction({
id: item.taskId, id: item.taskId,
action: () => closeAdminTask(item.taskId), action: () => closeAdminTask(item.taskId),
successMessage: "任务已关闭", successMessage: '任务已关闭',
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`, confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
onAfterAction: async () => { onAfterAction: async () => {
await loadTasks(); await loadTasks()
}, },
}); })
} }
function resetFilters() { function resetFilters() {
status.value = ""; status.value = ''
taskNo.value = ""; taskNo.value = ''
platformOrderId.value = ""; platformOrderId.value = ''
roleId.value = ""; roleId.value = ''
skuCode.value = ""; skuCode.value = ''
dateFrom.value = ""; dateFrom.value = ''
dateTo.value = ""; dateTo.value = ''
void loadTasks(1); void loadTasks(1)
} }
function resolveResourceStageLabel(status: string) { function resolveResourceStageLabel(status: string) {
const normalized = String(status || "").trim(); const normalized = String(status || '').trim()
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
resource_ready: "资源已准备", resource_ready: '资源已准备',
pending_prepare: "待准备资源", pending_prepare: '待准备资源',
pending_payment: "待支付", pending_payment: '待支付',
pending_binding_prepare: "待准备资源", pending_binding_prepare: '待准备资源',
retry_pending: "待重试", retry_pending: '待重试',
manual_review: "人工处理", manual_review: '人工处理',
resource_exception: "资源异常", resource_exception: '资源异常',
closed: "已关闭", closed: '已关闭',
expired: "已过期", expired: '已过期',
completed: "已完成", completed: '已完成',
not_started: "未开始", not_started: '未开始',
}; }
return labelMap[normalized] || normalized || "-"; return labelMap[normalized] || normalized || '-'
} }
function resolveCustomerStageLabel(status: string) { function resolveCustomerStageLabel(status: string) {
const normalized = String(status || "").trim(); const normalized = String(status || '').trim()
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
not_started: "待打开链接", not_started: '待打开链接',
waiting_customer: "待客户绑定", waiting_customer: '待客户绑定',
link_opened: "已打开链接", link_opened: '已打开链接',
customer_processing: "处理中", customer_processing: '处理中',
customer_confirmed: "已提交", customer_confirmed: '已提交',
customer_completed: "客户已完成", customer_completed: '客户已完成',
customer_exception: "客户步骤异常", customer_exception: '客户步骤异常',
closed: "已关闭", closed: '已关闭',
expired: "已过期", expired: '已过期',
}; }
return labelMap[normalized] || normalized || "-"; return labelMap[normalized] || normalized || '-'
} }
function resolveTaskMainText(item: AdminTaskListItem) { function resolveTaskMainText(item: AdminTaskListItem) {
if (item.lastError) return "需要关注"; if (item.lastError) return '需要关注'
if (["failed", "manual_review", "retry_pending"].includes(item.status)) return "待处理"; if (['failed', 'manual_review', 'retry_pending'].includes(item.status)) return '待处理'
if (["redeemed", "completed", "closed"].includes(item.status)) return "已完成"; if (['redeemed', 'completed', 'closed'].includes(item.status)) return '已完成'
if (item.customerStatus === "waiting_customer") return "等待客户绑定"; if (item.customerStatus === 'waiting_customer') return '等待客户绑定'
if (item.resourceStatus === "resource_ready") return "资源已准备"; if (item.resourceStatus === 'resource_ready') return '资源已准备'
if (item.status === "pending_binding_prepare") return "待准备资源"; if (item.status === 'pending_binding_prepare') return '待准备资源'
return item.status || "处理中"; return item.status || '处理中'
} }
function resolveTaskTone(item: AdminTaskListItem) { function resolveTaskTone(item: AdminTaskListItem) {
if (item.lastError || ["failed", "manual_review", "retry_pending"].includes(item.status)) return "danger"; if (item.lastError || ['failed', 'manual_review', 'retry_pending'].includes(item.status))
if (["redeemed", "completed", "closed"].includes(item.status)) return "success"; return 'danger'
return "warning"; if (['redeemed', 'completed', 'closed'].includes(item.status)) return 'success'
return 'warning'
}
function canRebindListTask(item: AdminTaskListItem) {
return (
canManageTaskLifecycle &&
item.executorKey === 'kuaishou_ct_assisted' &&
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
)
} }
onMounted(() => { onMounted(() => {
void loadTasks(1); void loadTasks(1)
}); })
</script> </script>
<template> <template>
<div class="tasks-page list-page"> <div class="tasks-page list-page">
<AdminPageHeader <AdminPageHeader title="交付任务" description="查看自动履约任务、客户步骤和异常处理。">
title="交付任务"
description="查看自动履约任务、客户步骤和异常处理。"
>
<template #extra> <template #extra>
<span class="total-badge"> {{ pagination.total }} 条任务</span> <span class="total-badge"> {{ pagination.total }} 条任务</span>
</template> </template>
@@ -198,9 +197,7 @@ onMounted(() => {
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span class="card-title">查询任务</span> <span class="card-title">查询任务</span>
<span class="card-desc" <span class="card-desc">按状态任务号订单号商品和日期筛选</span>
>按状态任务号订单号商品和日期筛选</span
>
</div> </div>
</template> </template>
<div class="filter-grid"> <div class="filter-grid">
@@ -224,12 +221,7 @@ onMounted(() => {
clearable clearable
@keyup.enter="loadTasks(1)" @keyup.enter="loadTasks(1)"
/> />
<el-input <el-input v-model="roleId" placeholder="角色 ID" clearable @keyup.enter="loadTasks(1)" />
v-model="roleId"
placeholder="角色 ID"
clearable
@keyup.enter="loadTasks(1)"
/>
<el-input <el-input
v-model="skuCode" v-model="skuCode"
placeholder="商品名 / SKU" placeholder="商品名 / SKU"
@@ -265,7 +257,7 @@ onMounted(() => {
/> />
<el-alert <el-alert
v-if="lastClaimUrl" v-if="lastClaimUrl"
:title="`最近生成的领取链接:${lastClaimUrl}`" :title="`最近可用领取链接:${lastClaimUrl}`"
type="info" type="info"
show-icon show-icon
:closable="false" :closable="false"
@@ -285,17 +277,10 @@ onMounted(() => {
<template v-else> <template v-else>
<div class="tasks-toolbar table-toolbar"> <div class="tasks-toolbar table-toolbar">
<strong>任务列表</strong> <strong>任务列表</strong>
<span <span> {{ pagination.page }} 当前展示 {{ items.length }} </span>
> {{ pagination.page }} 当前展示 {{ items.length }} </span
>
</div> </div>
<el-table <el-table :data="items" stripe size="small" class="tasks-table data-table">
:data="items"
stripe
size="small"
class="tasks-table data-table"
>
<el-table-column label="任务 / 订单" min-width="230"> <el-table-column label="任务 / 订单" min-width="230">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-stack"> <div class="cell-stack">
@@ -310,9 +295,7 @@ onMounted(() => {
<span class="compact-chip" :title="row.platformOrderId" <span class="compact-chip" :title="row.platformOrderId"
>订单 {{ row.platformOrderId }}</span >订单 {{ row.platformOrderId }}</span
> >
<span class="compact-chip compact-chip--muted" <span class="compact-chip compact-chip--muted">重试 {{ row.retryCount }} </span>
>重试 {{ row.retryCount }} </span
>
</div> </div>
</div> </div>
</template> </template>
@@ -323,7 +306,7 @@ onMounted(() => {
<span class="cell-title" :title="row.skuName || row.skuCode">{{ <span class="cell-title" :title="row.skuName || row.skuCode">{{
row.skuName || row.skuCode row.skuName || row.skuCode
}}</span> }}</span>
<span class="cell-subline">SKU {{ row.skuCode || "-" }}</span> <span class="cell-subline">SKU {{ row.skuCode || '-' }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -334,11 +317,11 @@ onMounted(() => {
{{ resolveTaskMainText(row) }} {{ resolveTaskMainText(row) }}
</span> </span>
<p class="task-status-note"> <p class="task-status-note">
客户 {{ resolveCustomerStageLabel(row.customerStatus) }} 客户 {{ resolveCustomerStageLabel(row.customerStatus) }} · 资源
· 资源 {{ resolveResourceStageLabel(row.resourceStatus) }} {{ resolveResourceStageLabel(row.resourceStatus) }}
</p> </p>
<p v-if="row.roleName || row.roleId" class="task-status-note"> <p v-if="row.roleName || row.roleId" class="task-status-note">
角色 {{ row.roleName || "-" }} / {{ row.roleId || "-" }} 角色 {{ row.roleName || '-' }} / {{ row.roleId || '-' }}
</p> </p>
</div> </div>
</template> </template>
@@ -346,12 +329,8 @@ onMounted(() => {
<el-table-column label="时间" min-width="180"> <el-table-column label="时间" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-stack"> <div class="cell-stack">
<span class="cell-subline" <span class="cell-subline">创建 {{ formatAdminDateTime(row.createdAt) }}</span>
>创建 {{ formatAdminDateTime(row.createdAt) }}</span <span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
>
<span class="cell-subline"
>更新 {{ formatAdminDateTime(row.updatedAt) }}</span
>
<span v-if="row.claimedAt" class="cell-subline" <span v-if="row.claimedAt" class="cell-subline"
>领取 {{ formatAdminDateTime(row.claimedAt) }}</span >领取 {{ formatAdminDateTime(row.claimedAt) }}</span
> >
@@ -368,7 +347,7 @@ onMounted(() => {
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-stack"> <div class="cell-stack">
<span class="cell-error" :title="row.lastError || '-'">{{ <span class="cell-error" :title="row.lastError || '-'">{{
row.lastError || "当前无异常" row.lastError || '当前无异常'
}}</span> }}</span>
<div class="action-stack"> <div class="action-stack">
<el-button <el-button
@@ -386,14 +365,11 @@ onMounted(() => {
重试 重试
</el-button> </el-button>
<el-button <el-button
v-if=" v-if="canRebindListTask(row)"
canManageTaskLifecycle &&
row.executorKey !== 'manual_dispatch'
"
size="small" size="small"
@click="handleActionCommand('regenerate_claim_link', row)" @click="handleActionCommand('rebind_role', row)"
> >
重发链接 换绑角色
</el-button> </el-button>
<el-button <el-button
v-if="canManageTaskLifecycle" v-if="canManageTaskLifecycle"
@@ -403,10 +379,7 @@ onMounted(() => {
转人工 转人工
</el-button> </el-button>
<el-button <el-button
v-if=" v-if="canCloseTasks && !['redeemed', 'closed'].includes(row.status)"
canCloseTasks &&
!['redeemed', 'closed'].includes(row.status)
"
size="small" size="small"
type="danger" type="danger"
@click="handleActionCommand('close', row)" @click="handleActionCommand('close', row)"
@@ -434,7 +407,7 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
@import "../../../styles/admin-list-pages.css"; @import '../../../styles/admin-list-pages.css';
.filter-grid { .filter-grid {
grid-template-columns: grid-template-columns:
@@ -465,19 +438,19 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.task-main-chip[data-tone="success"] { .task-main-chip[data-tone='success'] {
color: #0f766e; color: #0f766e;
background: var(--color-success-light); background: var(--color-success-light);
border-color: #a6f4c5; border-color: #a6f4c5;
} }
.task-main-chip[data-tone="warning"] { .task-main-chip[data-tone='warning'] {
color: #b54708; color: #b54708;
background: var(--color-warning-light); background: var(--color-warning-light);
border-color: #fedf89; border-color: #fedf89;
} }
.task-main-chip[data-tone="danger"] { .task-main-chip[data-tone='danger'] {
color: var(--color-danger-dark); color: var(--color-danger-dark);
background: var(--color-danger-light); background: var(--color-danger-light);
border-color: #fecaca; border-color: #fecaca;
@@ -12,7 +12,7 @@ defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
retry: [] retry: []
regenerateClaimLink: [] rebindRole: []
prepareKuaishouCloud: [] prepareKuaishouCloud: []
dispatchKuaishouCloud: [] dispatchKuaishouCloud: []
returnKuaishouCloud: [] returnKuaishouCloud: []
@@ -35,13 +35,13 @@ const emit = defineEmits<{
重试任务 重试任务
</el-button> </el-button>
<el-button <el-button
v-if="detail.operations.canRegenerateClaimLink" v-if="detail.operations.canRebindKuaishouCloudRole"
:disabled="!detail.operations.canRegenerateClaimLink" :disabled="!detail.operations.canRebindKuaishouCloudRole"
:loading="actionLoading" :loading="actionLoading"
round round
@click="emit('regenerateClaimLink')" @click="emit('rebindRole')"
> >
重发链接 换绑角色
</el-button> </el-button>
<el-button <el-button
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment" v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
@@ -69,11 +69,12 @@ function resolveTimelineItemClass(status: string, isCurrent: boolean) {
return '' return ''
} }
/** 计算 checklist 中第一个「非完成」的步骤索引,即当前卡点 */ /** 计算 checklist 中第一个「非完成」的步骤索引,即当前卡点 */
function findCurrentStepIndex(checklist: ChecklistItem[]) { function findCurrentStepIndex(checklist: ChecklistItem[]) {
const doneStatuses = ['verified', 'ready', 'completed', 'done'] const doneStatuses = ['verified', 'ready', 'completed', 'done']
const idx = checklist.findIndex((item) => !doneStatuses.includes(String(item.status || '').trim())) const idx = checklist.findIndex(
(item) => !doneStatuses.includes(String(item.status || '').trim()),
)
return idx return idx
} }
@@ -108,8 +109,29 @@ function badgeLabel(tone: string) {
return '待处理' return '待处理'
} }
const roleHasIdentity = (flow: KuaishouCloudFlow) => const roleHasIdentity = (flow: KuaishouCloudFlow) => !!(flow.role?.name || flow.role?.rid)
!!(flow.role?.name || flow.role?.rid)
function formatRebindSource(source: string) {
const normalized = String(source || '').trim()
if (normalized.includes('claim_page')) return '客户自助'
if (normalized.includes('admin')) return '后台操作'
return normalized || '-'
}
function formatRebindBinding(
binding: { vnId?: number; vnPhone?: string; roleName?: string; roleId?: string } | null,
) {
if (!binding) return '-'
const vn = binding.vnId ? `虚拟号 ${binding.vnId}` : '虚拟号 -'
const phone = binding.vnPhone ? ` / ${binding.vnPhone}` : ''
const role =
binding.roleName || binding.roleId
? `;角色 ${binding.roleName || '-'} / ${binding.roleId || '-'}`
: ''
return `${vn}${phone}${role}`
}
</script> </script>
<template> <template>
@@ -155,9 +177,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
<div class="spotlight-head"> <div class="spotlight-head">
<div> <div>
<h4>客户角色</h4> <h4>客户角色</h4>
<p class="flow-copy"> <p class="flow-copy">客户绑定后刷新角色信息确认无误后再执行发货</p>
客户绑定后刷新角色信息确认无误后再执行发货
</p>
</div> </div>
<div class="spotlight-actions"> <div class="spotlight-actions">
<AdminStatusTag :status="flow.role?.status || 'pending'" /> <AdminStatusTag :status="flow.role?.status || 'pending'" />
@@ -187,9 +207,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
</div> </div>
</div> </div>
<!-- 已识别徽章 --> <!-- 已识别徽章 -->
<div v-if="roleHasIdentity(flow)" class="role-verified-badge"> <div v-if="roleHasIdentity(flow)" class="role-verified-badge"> 角色已识别</div>
角色已识别
</div>
<!-- 等待识别引导 --> <!-- 等待识别引导 -->
<div v-else class="role-pending-hint"> <div v-else class="role-pending-hint">
客户完成绑定后点击右上角刷新按钮获取角色信息确认角色名与 ID 无误后再执行发货 客户完成绑定后点击右上角刷新按钮获取角色信息确认角色名与 ID 无误后再执行发货
@@ -202,9 +220,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
<!-- 流程进度 Timeline --> <!-- 流程进度 Timeline -->
<section class="flow-block flow-block--checklist"> <section class="flow-block flow-block--checklist">
<h4>流程进度</h4> <h4>流程进度</h4>
<p class="flow-copy"> <p class="flow-copy">当前任务只需要按这个顺序推进异常时再展开下方明细</p>
当前任务只需要按这个顺序推进异常时再展开下方明细
</p>
<div class="flow-timeline"> <div class="flow-timeline">
<div <div
v-for="(item, idx) in checklist" v-for="(item, idx) in checklist"
@@ -218,10 +234,9 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
<div class="timeline-body"> <div class="timeline-body">
<div class="timeline-head"> <div class="timeline-head">
<span class="timeline-label">{{ item.label }}</span> <span class="timeline-label">{{ item.label }}</span>
<span <span v-if="idx === findCurrentStepIndex(checklist)" class="timeline-current-badge"
v-if="idx === findCurrentStepIndex(checklist)" >当前</span
class="timeline-current-badge" >
>当前</span>
</div> </div>
<div class="timeline-detail">{{ item.detail }}</div> <div class="timeline-detail">{{ item.detail }}</div>
</div> </div>
@@ -404,6 +419,34 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
</div> </div>
</div> </div>
</el-collapse-item> </el-collapse-item>
<el-collapse-item v-if="flow.rebind?.history?.length" name="rebind" title="换绑记录">
<div class="event-timeline compact-event-timeline">
<div
v-for="item in flow.rebind.history"
:key="`rebind-${item.attempt}-${item.requestedAt}`"
class="event-tl-item"
>
<div class="event-tl-gutter">
<div class="event-tl-dot"></div>
</div>
<div class="event-tl-body">
<div class="event-tl-type">
{{ item.attempt }} 次换绑 · {{ formatRebindSource(item.source) }} ·
{{ item.status === 'success' ? '成功' : '失败' }}
</div>
<div class="event-tl-payload"> {{ formatRebindBinding(item.oldBinding) }}</div>
<div v-if="item.newBinding" class="event-tl-payload">
{{ formatRebindBinding(item.newBinding) }}
</div>
<div v-if="item.errorMessage" class="event-tl-payload">
{{ item.errorMessage }}
</div>
<div class="event-tl-time">{{ formatAdminDateTime(item.requestedAt) }}</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse> </el-collapse>
</el-card> </el-card>
</template> </template>
@@ -69,7 +69,9 @@ const claim = useKuaishouCloudClaim(() => props.token)
:role-id="claim.roleId.value" :role-id="claim.roleId.value"
:product="claim.product.value" :product="claim.product.value"
:redeeming="claim.redeeming.value" :redeeming="claim.redeeming.value"
:rebinding-role="claim.rebindingRole.value"
@confirm-redeem="claim.confirmRedeem()" @confirm-redeem="claim.confirmRedeem()"
@rebind-role="claim.rebindRole()"
/> />
<ClaimResultStep <ClaimResultStep
@@ -10,10 +10,12 @@ defineProps<{
roleId: string roleId: string
product: ClaimProductInfo | null product: ClaimProductInfo | null
redeeming: boolean redeeming: boolean
rebindingRole: boolean
}>() }>()
defineEmits<{ defineEmits<{
confirmRedeem: [] confirmRedeem: []
rebindRole: []
}>() }>()
</script> </script>
@@ -48,9 +50,26 @@ defineEmits<{
<span>兑换后不可取消也不可退货</span> <span>兑换后不可取消也不可退货</span>
</div> </div>
<el-button type="primary" size="large" :loading="redeeming" @click="$emit('confirmRedeem')"> <div class="action-row">
<el-button
size="large"
plain
:loading="rebindingRole"
:disabled="redeeming"
@click="$emit('rebindRole')"
>
换绑角色
</el-button>
<el-button
type="primary"
size="large"
:loading="redeeming"
:disabled="rebindingRole"
@click="$emit('confirmRedeem')"
>
确认兑换 确认兑换
</el-button> </el-button>
</div>
</section> </section>
</template> </template>
@@ -120,6 +139,17 @@ defineEmits<{
color: #c2410c; color: #c2410c;
} }
.action-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.action-row :deep(.el-button) {
margin-left: 0;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.content-card { .content-card {
max-width: 100%; max-width: 100%;
@@ -128,5 +158,14 @@ defineEmits<{
.info-grid { .info-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.action-row {
flex-direction: column;
align-items: stretch;
}
.action-row :deep(.el-button) {
width: 100%;
}
} }
</style> </style>
@@ -1,380 +1,390 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import QRCode from "qrcode"; import QRCode from 'qrcode'
import { ElMessageBox } from "element-plus"; import { ElMessageBox } from 'element-plus'
import { showError, showSuccess } from "@/lib/feedback"; import { showError, showSuccess } from '@/lib/feedback'
import { import {
hasKuaishouCloudRedeemResultStatus, hasKuaishouCloudRedeemResultStatus,
isClaimInactiveTaskStatus, isClaimInactiveTaskStatus,
isKuaishouCloudCompletedStatus, isKuaishouCloudCompletedStatus,
isKuaishouCloudRoleConfirmedStatus, isKuaishouCloudRoleConfirmedStatus,
} from "@/domain/task-status"; } from '@/domain/task-status'
import { formatAdminDateTime } from "@/utils/admin-time"; import { formatAdminDateTime } from '@/utils/admin-time'
import { import {
confirmKuaishouCloudClaimRole, confirmKuaishouCloudClaimRole,
fetchClaimDetail, fetchClaimDetail,
rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim, redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket, verifyKuaishouCloudClaimTicket,
} from "@/services/claim"; } from '@/services/claim'
import type { ClaimDetailData } from "@/types/claim"; import type { ClaimDetailData } from '@/types/claim'
const BINDING_PREPARE_POLL_MS = 5_000; const BINDING_PREPARE_POLL_MS = 5_000
const ROLE_FAST_POLL_MS = 10_000; const ROLE_FAST_POLL_MS = 10_000
const ROLE_SLOW_POLL_MS = 30_000; const ROLE_SLOW_POLL_MS = 30_000
const ROLE_IDLE_POLL_MS = 60_000; const ROLE_IDLE_POLL_MS = 60_000
const ROLE_FAST_WINDOW_MS = 3 * 60_000; const ROLE_FAST_WINDOW_MS = 3 * 60_000
const ROLE_IDLE_WINDOW_MS = 10 * 60_000; const ROLE_IDLE_WINDOW_MS = 10 * 60_000
export function useKuaishouCloudClaim(token: () => string) { export function useKuaishouCloudClaim(token: () => string) {
const loading = ref(true); const loading = ref(true)
const submitting = ref(false); const submitting = ref(false)
const refreshingRole = ref(false); const refreshingRole = ref(false)
const confirmingRole = ref(false); const confirmingRole = ref(false)
const redeeming = ref(false); const rebindingRole = ref(false)
const errorMessage = ref(""); const redeeming = ref(false)
const detail = ref<ClaimDetailData | null>(null); const errorMessage = ref('')
const ticketCode = ref(""); const detail = ref<ClaimDetailData | null>(null)
const qrCodeDataUrl = ref(""); const ticketCode = ref('')
let pollTimer = 0; const qrCodeDataUrl = ref('')
let rolePollBaselineAt = 0; let pollTimer = 0
let rolePollBaselineKey = ""; let rolePollBaselineAt = 0
let rolePollBaselineKey = ''
// ── derived data ──────────────────────────────────────── // ── derived data ────────────────────────────────────────
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null); const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
const order = computed(() => detail.value?.order || null); const order = computed(() => detail.value?.order || null)
const orderItem = computed(() => detail.value?.orderItem || null); const orderItem = computed(() => detail.value?.orderItem || null)
const product = computed(() => detail.value?.product || null); const product = computed(() => detail.value?.product || null)
const task = computed(() => detail.value?.task || null); const task = computed(() => detail.value?.task || null)
// ── computed status flags ─────────────────────────────── // ── computed status flags ───────────────────────────────
const roleName = computed( const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
() => flow.value?.role.name || flow.value?.binding.roleName || "" const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
); const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
const roleId = computed(
() => flow.value?.role.rid || flow.value?.binding.roleId || ""
);
const isTicketVerified = computed(
() => flow.value?.ticket.status === "verified"
);
const isBindUrlExpired = computed(() => { const isBindUrlExpired = computed(() => {
const expiresAt = String(flow.value?.binding.bindExpiresAt || "").trim(); const expiresAt = String(flow.value?.binding.bindExpiresAt || '').trim()
if (!expiresAt) { if (!expiresAt) {
return false; return false
} }
const expiresTime = Date.parse(expiresAt); const expiresTime = Date.parse(expiresAt)
return Number.isFinite(expiresTime) && expiresTime <= Date.now(); return Number.isFinite(expiresTime) && expiresTime <= Date.now()
}); })
const isBindingPrepared = computed( const isBindingPrepared = computed(
() => () =>
flow.value?.binding.prepareStatus === "ready" && flow.value?.binding.prepareStatus === 'ready' &&
Boolean(String(flow.value?.binding.bindUrl || "").trim()) && Boolean(String(flow.value?.binding.bindUrl || '').trim()) &&
!isBindUrlExpired.value !isBindUrlExpired.value,
); )
const isBindingPreparing = computed( const isBindingPreparing = computed(() => flow.value?.binding.prepareStatus === 'pending')
() => flow.value?.binding.prepareStatus === "pending" const canEnterBindingStep = computed(() => isTicketVerified.value)
); const isRoleReady = computed(() => Boolean(roleName.value || roleId.value))
const canEnterBindingStep = computed(() => isTicketVerified.value); const isRoleConfirmed = computed(() => isKuaishouCloudRoleConfirmedStatus(task.value?.status))
const isRoleReady = computed(() => Boolean(roleName.value || roleId.value));
const isRoleConfirmed = computed(
() => isKuaishouCloudRoleConfirmedStatus(task.value?.status)
);
const isDispatched = computed( const isDispatched = computed(
() => String(flow.value?.dispatch.status || "").trim() === "success" () => String(flow.value?.dispatch.status || '').trim() === 'success',
); )
const isCompleted = computed( const isCompleted = computed(
() => () =>
isKuaishouCloudCompletedStatus(task.value?.status) || isKuaishouCloudCompletedStatus(task.value?.status) ||
String(flow.value?.consume.status || "").trim() === "success" String(flow.value?.consume.status || '').trim() === 'success',
); )
const hasRedeemResult = computed(() => { const hasRedeemResult = computed(() => {
return isDispatched.value || hasKuaishouCloudRedeemResultStatus(task.value?.status); return isDispatched.value || hasKuaishouCloudRedeemResultStatus(task.value?.status)
}); })
const canSubmitTicket = computed( const canSubmitTicket = computed(
() => () => !isClaimInactiveTaskStatus(task.value?.status) && !submitting.value,
!isClaimInactiveTaskStatus(task.value?.status) && !submitting.value )
);
const currentStep = computed(() => { const currentStep = computed(() => {
if (hasRedeemResult.value) { if (hasRedeemResult.value) {
return 4; return 4
} }
if (isRoleConfirmed.value) { if (isRoleConfirmed.value) {
return 3; return 3
} }
if (canEnterBindingStep.value) { if (canEnterBindingStep.value) {
return 2; return 2
} }
return 1; return 1
}); })
const progressText = computed(() => { const progressText = computed(() => {
if (hasRedeemResult.value) { if (hasRedeemResult.value) {
return "兑换结果已生成"; return '兑换结果已生成'
} }
if (isRoleConfirmed.value) { if (isRoleConfirmed.value) {
return "角色已确认,等待兑换"; return '角色已确认,等待兑换'
} }
if (isTicketVerified.value) { if (isTicketVerified.value) {
return isBindingPrepared.value return isBindingPrepared.value ? '请完成扫码绑定' : '绑定链接刷新中,请稍候'
? "请完成扫码绑定"
: "绑定链接刷新中,请稍候";
} }
return "等待提交核销码"; return '等待提交核销码'
}); })
const resultTitle = computed(() => { const resultTitle = computed(() => {
if (isCompleted.value) { if (isCompleted.value) {
return "兑换成功"; return '兑换成功'
} }
if (isDispatched.value) { if (isDispatched.value) {
return "兑换请求已提交"; return '兑换请求已提交'
} }
return "结果已记录"; return '结果已记录'
}); })
const resultDescription = computed(() => { const resultDescription = computed(() => {
if (isCompleted.value) { if (isCompleted.value) {
return "当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。"; return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。'
} }
return "你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。"; return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。'
}); })
// ── actions ───────────────────────────────────────────── // ── actions ─────────────────────────────────────────────
async function generateQRCode(url: string) { async function generateQRCode(url: string) {
if (!url) { if (!url) {
qrCodeDataUrl.value = ""; qrCodeDataUrl.value = ''
return; return
} }
try { try {
qrCodeDataUrl.value = await QRCode.toDataURL(url, { qrCodeDataUrl.value = await QRCode.toDataURL(url, {
width: 280, width: 280,
margin: 2, margin: 2,
color: { color: {
dark: "#0f172a", dark: '#0f172a',
light: "#ffffff", light: '#ffffff',
}, },
}); })
} catch (error) { } catch (error) {
qrCodeDataUrl.value = ""; qrCodeDataUrl.value = ''
console.error("生成二维码失败:", error); console.error('生成二维码失败:', error)
} }
} }
async function applyDetail(nextDetail: ClaimDetailData) { async function applyDetail(nextDetail: ClaimDetailData) {
detail.value = nextDetail; detail.value = nextDetail
if (!ticketCode.value && nextDetail.kuaishouCloudFulfillment?.ticket.code) { if (!ticketCode.value && nextDetail.kuaishouCloudFulfillment?.ticket.code) {
ticketCode.value = nextDetail.kuaishouCloudFulfillment.ticket.code; ticketCode.value = nextDetail.kuaishouCloudFulfillment.ticket.code
} }
await generateQRCode( await generateQRCode(String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim())
String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || "").trim()
);
} }
async function loadDetail(options: { silent?: boolean } = {}) { async function loadDetail(options: { silent?: boolean } = {}) {
if (!options.silent) { if (!options.silent) {
loading.value = true; loading.value = true
} }
errorMessage.value = ""; errorMessage.value = ''
try { try {
const response = await fetchClaimDetail(token()); const response = await fetchClaimDetail(token())
await applyDetail(response.data); await applyDetail(response.data)
syncPolling(); syncPolling()
} catch (error) { } catch (error) {
errorMessage.value = errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
error instanceof Error ? error.message : "读取领取信息失败"; stopPolling()
stopPolling();
} finally { } finally {
if (!options.silent) { if (!options.silent) {
loading.value = false; loading.value = false
} }
} }
} }
async function submitTicket() { async function submitTicket() {
const normalizedTicketCode = ticketCode.value.trim(); const normalizedTicketCode = ticketCode.value.trim()
if (!normalizedTicketCode) { if (!normalizedTicketCode) {
showError("请输入核销码"); showError('请输入核销码')
return; return
} }
submitting.value = true; submitting.value = true
try { try {
const response = await verifyKuaishouCloudClaimTicket(token(), { const response = await verifyKuaishouCloudClaimTicket(token(), {
ticketCode: normalizedTicketCode, ticketCode: normalizedTicketCode,
}); })
await applyDetail(response.data); await applyDetail(response.data)
showSuccess("核销码验证通过,系统已开始准备绑定资源"); showSuccess('核销码验证通过,系统已开始准备绑定资源')
syncPolling(); syncPolling()
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : "核销码验证失败"); showError(error instanceof Error ? error.message : '核销码验证失败')
} finally { } finally {
submitting.value = false; submitting.value = false
} }
} }
async function refreshRole() { async function refreshRole() {
refreshingRole.value = true; refreshingRole.value = true
try { try {
await loadDetail({ silent: true }); await loadDetail({ silent: true })
if (isRoleReady.value) { if (isRoleReady.value) {
showSuccess("角色信息已刷新"); showSuccess('角色信息已刷新')
} else { } else {
showError( showError(
flow.value?.role.errorMessage || flow.value?.role.errorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
"暂时还没有识别到角色信息,请完成绑定后稍等片刻再试" )
);
} }
} finally { } finally {
refreshingRole.value = false; refreshingRole.value = false
} }
} }
async function confirmRole() { async function confirmRole() {
confirmingRole.value = true; confirmingRole.value = true
try { try {
const response = await confirmKuaishouCloudClaimRole(token()); const response = await confirmKuaishouCloudClaimRole(token())
await applyDetail(response.data); await applyDetail(response.data)
showSuccess("角色已确认,进入下一步"); showSuccess('角色已确认,进入下一步')
syncPolling(); syncPolling()
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : "确认角色失败"); showError(error instanceof Error ? error.message : '确认角色失败')
} finally { } finally {
confirmingRole.value = false; confirmingRole.value = false
} }
} }
async function confirmRedeem() { async function confirmRedeem() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。", '兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。',
"确认兑换", '确认兑换',
{ {
confirmButtonText: "确认兑换", confirmButtonText: '确认兑换',
cancelButtonText: "取消", cancelButtonText: '取消',
type: "warning", type: 'warning',
center: true, center: true,
} },
); )
} catch { } catch {
return; return
} }
redeeming.value = true; redeeming.value = true
try { try {
const response = await redeemKuaishouCloudClaim(token()); const response = await redeemKuaishouCloudClaim(token())
await applyDetail(response.data); await applyDetail(response.data)
showSuccess("兑换请求已提交"); showSuccess('兑换请求已提交')
syncPolling(); syncPolling()
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : "兑换失败"); showError(error instanceof Error ? error.message : '兑换失败')
} finally { } finally {
redeeming.value = false; redeeming.value = false
}
}
async function rebindRole() {
try {
await ElMessageBox.confirm(
'换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。核销码和领取商品不会改变,但需要重新扫码绑定角色。',
'确认换绑角色',
{
confirmButtonText: '确认换绑',
cancelButtonText: '取消',
type: 'warning',
center: true,
},
)
} catch {
return
}
rebindingRole.value = true
try {
const response = await rebindKuaishouCloudClaimRole(token())
await applyDetail(response.data)
showSuccess('新的绑定二维码已生成,请重新绑定角色')
syncPolling()
} catch (error) {
showError(error instanceof Error ? error.message : '换绑角色失败')
} finally {
rebindingRole.value = false
} }
} }
function openBindUrl(useCurrentPage = false) { function openBindUrl(useCurrentPage = false) {
const bindUrl = String(flow.value?.binding.bindUrl || "").trim(); const bindUrl = String(flow.value?.binding.bindUrl || '').trim()
if (!bindUrl) { if (!bindUrl) {
showError("绑定链接还没准备好,请稍后刷新"); showError('绑定链接还没准备好,请稍后刷新')
return; return
} }
if (useCurrentPage) { if (useCurrentPage) {
window.location.assign(bindUrl); window.location.assign(bindUrl)
return; return
} }
window.open(bindUrl, "_blank", "noopener,noreferrer"); window.open(bindUrl, '_blank', 'noopener,noreferrer')
} }
// ── polling ───────────────────────────────────────────── // ── polling ─────────────────────────────────────────────
function stopPolling() { function stopPolling() {
if (pollTimer) { if (pollTimer) {
window.clearTimeout(pollTimer); window.clearTimeout(pollTimer)
pollTimer = 0; pollTimer = 0
} }
} }
function syncPolling() { function syncPolling() {
const delay = resolveNextPollDelay(); const delay = resolveNextPollDelay()
if (!delay) { if (!delay) {
stopPolling(); stopPolling()
return; return
} }
stopPolling(); stopPolling()
pollTimer = window.setTimeout(() => { pollTimer = window.setTimeout(() => {
pollTimer = 0; pollTimer = 0
void loadDetail({ silent: true }); void loadDetail({ silent: true })
}, delay); }, delay)
} }
function resolveNextPollDelay() { function resolveNextPollDelay() {
if ( if (!flow.value || hasRedeemResult.value || isClaimInactiveTaskStatus(task.value?.status)) {
!flow.value || return 0
hasRedeemResult.value ||
isClaimInactiveTaskStatus(task.value?.status)
) {
return 0;
} }
if (currentStep.value === 2) { if (currentStep.value === 2) {
if (!isBindingPrepared.value) { if (!isBindingPrepared.value) {
return BINDING_PREPARE_POLL_MS; return BINDING_PREPARE_POLL_MS
} }
return resolveRolePollDelay(); return resolveRolePollDelay()
} }
if (currentStep.value === 3) { if (currentStep.value === 3) {
return 0; return 0
} }
return 0; return 0
} }
function resolveRolePollDelay() { function resolveRolePollDelay() {
const nextRoleKey = [roleName.value, roleId.value].join("::"); const nextRoleKey = [roleName.value, roleId.value].join('::')
const now = Date.now(); const now = Date.now()
if (nextRoleKey !== rolePollBaselineKey) { if (nextRoleKey !== rolePollBaselineKey) {
rolePollBaselineKey = nextRoleKey; rolePollBaselineKey = nextRoleKey
rolePollBaselineAt = now; rolePollBaselineAt = now
} }
if (!rolePollBaselineAt) { if (!rolePollBaselineAt) {
rolePollBaselineAt = now; rolePollBaselineAt = now
} }
const elapsedMs = now - rolePollBaselineAt; const elapsedMs = now - rolePollBaselineAt
if (elapsedMs < ROLE_FAST_WINDOW_MS) { if (elapsedMs < ROLE_FAST_WINDOW_MS) {
return ROLE_FAST_POLL_MS; return ROLE_FAST_POLL_MS
} }
if (elapsedMs < ROLE_IDLE_WINDOW_MS) { if (elapsedMs < ROLE_IDLE_WINDOW_MS) {
return ROLE_SLOW_POLL_MS; return ROLE_SLOW_POLL_MS
} }
return ROLE_IDLE_POLL_MS; return ROLE_IDLE_POLL_MS
} }
// ── lifecycle ─────────────────────────────────────────── // ── lifecycle ───────────────────────────────────────────
onMounted(() => { onMounted(() => {
void loadDetail(); void loadDetail()
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPolling(); stopPolling()
}); })
return { return {
// state // state
@@ -382,6 +392,7 @@ export function useKuaishouCloudClaim(token: () => string) {
submitting, submitting,
refreshingRole, refreshingRole,
confirmingRole, confirmingRole,
rebindingRole,
redeeming, redeeming,
errorMessage, errorMessage,
detail, detail,
@@ -416,9 +427,10 @@ export function useKuaishouCloudClaim(token: () => string) {
submitTicket, submitTicket,
refreshRole, refreshRole,
confirmRole, confirmRole,
rebindRole,
confirmRedeem, confirmRedeem,
openBindUrl, openBindUrl,
// utility // utility
formatAdminDateTime, formatAdminDateTime,
}; }
} }