diff --git a/apps/backend/src/routes/admin/dashboard.js b/apps/backend/src/routes/admin/dashboard.js index 5924be92..5a85aecd 100644 --- a/apps/backend/src/routes/admin/dashboard.js +++ b/apps/backend/src/routes/admin/dashboard.js @@ -1,3 +1,5 @@ +// @ts-check + import { Router } from 'express' import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js' diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js index 921567f0..ffa9c3cc 100644 --- a/apps/backend/src/routes/admin/inventory.js +++ b/apps/backend/src/routes/admin/inventory.js @@ -1,3 +1,5 @@ +// @ts-check + import { Router } from 'express' import { @@ -12,12 +14,20 @@ import { } from '../../services/admin/admin-read-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventoryCreateRouteBody} AdminInventoryCreateRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventoryImportRouteBody} AdminInventoryImportRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventoryInvalidateRouteBody} AdminInventoryInvalidateRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventoryRouteParams} AdminInventoryRouteParams */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventoryRouteQuery} AdminInventoryRouteQuery */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventorySkuSuggestionRouteQuery} AdminInventorySkuSuggestionRouteQuery */ +/** @typedef {import('../../types/admin-write-models.js').AdminInventoryMutationResponse} AdminInventoryMutationResponse */ + const router = Router() router.use('/inventory', requireAdminRoles(['admin', 'operator'])) router.get('/inventory', createJsonHandler( - (req) => getAdminInventoryItems(req.query), + (req) => getAdminInventoryItems(/** @type {AdminInventoryRouteQuery} */ (req.query)), { successMessage: 'ok', errorMessage: '读取库存列表失败', @@ -26,7 +36,7 @@ router.get('/inventory', createJsonHandler( )) router.get('/inventory/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions(req.query), + (req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), { successMessage: 'ok', errorMessage: '读取内部 SKU 建议失败', @@ -35,7 +45,7 @@ router.get('/inventory/sku-suggestions', createJsonHandler( )) router.post('/inventory', createJsonHandler( - (req) => createAdminInventoryItem(req.body), + (req) => createAdminInventoryItem(/** @type {AdminInventoryCreateRouteBody} */ (req.body)), { successMessage: '库存项已新增', errorMessage: '新增库存项失败', @@ -44,7 +54,7 @@ router.post('/inventory', createJsonHandler( )) router.post('/inventory/import', createJsonHandler( - (req) => importAdminInventoryItems(req.body), + (req) => importAdminInventoryItems(/** @type {AdminInventoryImportRouteBody} */ (req.body)), { successMessage: '导入完成', errorMessage: '导入库存凭据失败', @@ -53,40 +63,49 @@ router.post('/inventory/import', createJsonHandler( )) router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminInventoryItem(req.params.inventoryItemId), + (req) => releaseAdminInventoryItem((/** @type {AdminInventoryRouteParams} */ (req.params)).inventoryItemId), { successMessage: '库存项已释放', errorMessage: '释放库存项失败', scope: '[admin/inventory/:inventoryItemId/release]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminInventoryMutationResponse} */ (data) + return { action: 'inventory_item_released', targetType: 'inventory_item', - targetId: String(req.params.inventoryItemId), + targetId: String((/** @type {AdminInventoryRouteParams} */ (req.params)).inventoryItemId), data: { - inventoryItemId: data.inventoryItem?.inventoryItemId, - skuCode: data.inventoryItem?.skuCode, - displayValue: data.inventoryItem?.displayValue, + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + displayValue: result.inventoryItem?.displayValue, }, - }), + } + }, }, )) router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( - (req) => invalidateAdminInventoryItem(req.params.inventoryItemId, req.body), + (req) => invalidateAdminInventoryItem( + (/** @type {AdminInventoryRouteParams} */ (req.params)).inventoryItemId, + /** @type {AdminInventoryInvalidateRouteBody} */ (req.body), + ), { successMessage: '库存项已作废', errorMessage: '作废库存项失败', scope: '[admin/inventory/:inventoryItemId/invalidate]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminInventoryMutationResponse} */ (data) + return { action: 'inventory_item_invalidated', targetType: 'inventory_item', - targetId: String(req.params.inventoryItemId), + targetId: String((/** @type {AdminInventoryRouteParams} */ (req.params)).inventoryItemId), data: { - inventoryItemId: data.inventoryItem?.inventoryItemId, - skuCode: data.inventoryItem?.skuCode, - invalidReason: data.inventoryItem?.invalidReason, + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + invalidReason: result.inventoryItem?.invalidReason, }, - }), + } + }, }, )) diff --git a/apps/backend/src/routes/admin/message-deliveries.js b/apps/backend/src/routes/admin/message-deliveries.js index 9c3061be..a9c031d7 100644 --- a/apps/backend/src/routes/admin/message-deliveries.js +++ b/apps/backend/src/routes/admin/message-deliveries.js @@ -1,12 +1,16 @@ +// @ts-check + import { Router } from 'express' import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js' import { createJsonHandler } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminMessageDeliveryRouteQuery} AdminMessageDeliveryRouteQuery */ + const router = Router() router.get('/message-deliveries', createJsonHandler( - (req) => getAdminMessageDeliveries(req.query), + (req) => getAdminMessageDeliveries(/** @type {AdminMessageDeliveryRouteQuery} */ (req.query)), { successMessage: 'ok', errorMessage: '读取消息发送记录失败', diff --git a/apps/backend/src/routes/admin/orders.js b/apps/backend/src/routes/admin/orders.js index b192d475..9447ebb1 100644 --- a/apps/backend/src/routes/admin/orders.js +++ b/apps/backend/src/routes/admin/orders.js @@ -1,12 +1,17 @@ +// @ts-check + import { Router } from 'express' import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js' import { createJsonHandler } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminOrderRouteParams} AdminOrderRouteParams */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminOrderRouteQuery} AdminOrderRouteQuery */ + const router = Router() router.get('/orders', createJsonHandler( - (req) => getAdminOrders(req.query), + (req) => getAdminOrders(/** @type {AdminOrderRouteQuery} */ (req.query)), { successMessage: 'ok', errorMessage: '读取订单列表失败', @@ -15,7 +20,7 @@ router.get('/orders', createJsonHandler( )) router.get('/orders/:orderId', createJsonHandler( - (req) => getAdminOrderDetail(req.params.orderId), + (req) => getAdminOrderDetail((/** @type {AdminOrderRouteParams} */ (req.params)).orderId), { successMessage: 'ok', errorMessage: '读取订单详情失败', diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 67758908..02d9236c 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -1,3 +1,5 @@ +// @ts-check + import { Router } from 'express' import { @@ -9,6 +11,11 @@ import { } from '../../services/admin/admin-platform-config-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminAgisoShopConfigRouteBody} AdminAgisoShopConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminFulfillmentBindingConfigRouteBody} AdminFulfillmentBindingConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminFulfillmentBindingLookupRouteBody} AdminFulfillmentBindingLookupRouteBody */ +/** @typedef {import('../../types/admin-write-models.js').AdminAgisoShopConfigSaveResponse} AdminAgisoShopConfigSaveResponse */ + const router = Router() router.use('/platform-config', requireAdminRoles(['admin'])) @@ -23,20 +30,23 @@ router.get('/platform-config/agiso-shops', createJsonHandler( )) router.post('/platform-config/agiso-shops', createJsonHandler( - (req) => updateAdminAgisoShopConfigs(req.body), + (req) => updateAdminAgisoShopConfigs(/** @type {AdminAgisoShopConfigRouteBody} */ (req.body)), { successMessage: 'Agiso 店铺配置已保存', errorMessage: '保存 Agiso 店铺配置失败', scope: '[admin/platform-config/agiso-shops]', - audit: (_req, data) => ({ - action: 'platform_shop_config_updated', - targetType: 'platform_config', - targetId: 'agiso_shops', - data: { - shopCount: data.shops.length, - filePath: data.filePath, - }, - }), + audit: (_req, data) => { + const result = /** @type {AdminAgisoShopConfigSaveResponse} */ (data) + return { + action: 'platform_shop_config_updated', + targetType: 'platform_config', + targetId: 'agiso_shops', + data: { + shopCount: result.shops.length, + filePath: result.filePath, + }, + } + }, }, )) @@ -50,44 +60,50 @@ router.get('/platform-config/fulfillment-bindings', createJsonHandler( )) router.post('/platform-config/fulfillment-bindings/lookup-order', createJsonHandler( - (req) => lookupAdminFulfillmentBindingOrder(req.body), + (req) => lookupAdminFulfillmentBindingOrder(/** @type {AdminFulfillmentBindingLookupRouteBody} */ (req.body)), { successMessage: '订单商品查询成功', errorMessage: '手动查询订单商品失败', scope: '[admin/platform-config/fulfillment-bindings/lookup-order]', - audit: (req, data) => ({ - action: 'platform_fulfillment_order_lookup', - targetType: 'platform_config', - targetId: [ - data.order?.provider || 'unknown', - data.order?.platform || 'unknown', - data.order?.shopId || 'unknown', - data.order?.platformOrderId || 'unknown', - ].join(':'), - data: { - itemCount: Array.isArray(data.items) ? data.items.length : 0, - shopId: data.order?.shopId || String(req.body?.shopId || '').trim(), - platformOrderId: data.order?.platformOrderId || String(req.body?.platformOrderId || '').trim(), - }, - }), + audit: (req, data) => { + const result = /** @type {{ order?: { provider?: string, platform?: string, shopId?: string, platformOrderId?: string }, items?: unknown[] }} */ (data) + return { + action: 'platform_fulfillment_order_lookup', + targetType: 'platform_config', + targetId: [ + result.order?.provider || 'unknown', + result.order?.platform || 'unknown', + result.order?.shopId || 'unknown', + result.order?.platformOrderId || 'unknown', + ].join(':'), + data: { + itemCount: Array.isArray(result.items) ? result.items.length : 0, + shopId: result.order?.shopId || String((/** @type {AdminFulfillmentBindingLookupRouteBody} */ (req.body))?.shopId || '').trim(), + platformOrderId: result.order?.platformOrderId || String((/** @type {AdminFulfillmentBindingLookupRouteBody} */ (req.body))?.platformOrderId || '').trim(), + }, + } + }, }, )) router.post('/platform-config/fulfillment-bindings', createJsonHandler( - (req) => updateAdminFulfillmentBindingConfigs(req.body), + (req) => updateAdminFulfillmentBindingConfigs(/** @type {AdminFulfillmentBindingConfigRouteBody} */ (req.body)), { successMessage: '履约配置已保存', errorMessage: '保存履约配置失败', scope: '[admin/platform-config/fulfillment-bindings]', - audit: (_req, data) => ({ - action: 'platform_fulfillment_config_updated', - targetType: 'platform_config', - targetId: 'fulfillment_bindings', - data: { - bindingCount: data.bindings.length, - filePath: data.filePath, - }, - }), + audit: (_req, data) => { + const result = /** @type {{ bindings?: unknown[], filePath?: string }} */ (data) + return { + action: 'platform_fulfillment_config_updated', + targetType: 'platform_config', + targetId: 'fulfillment_bindings', + data: { + bindingCount: Array.isArray(result.bindings) ? result.bindings.length : 0, + filePath: result.filePath || '', + }, + } + }, }, )) diff --git a/apps/backend/src/routes/admin/shared.js b/apps/backend/src/routes/admin/shared.js index 8d139d48..8ac0b066 100644 --- a/apps/backend/src/routes/admin/shared.js +++ b/apps/backend/src/routes/admin/shared.js @@ -1,8 +1,45 @@ +// @ts-check + import { requireAdminRole, verifyAdminSessionToken } from '../../services/admin/admin-auth-service.js' import { writeAdminAuditLog } from '../../services/admin/admin-audit-service.js' import { buildSuccessPayload, createHttpError, sendRouteError } from '../../utils/http.js' import { logWarn } from '../../utils/logger.js' +/** @typedef {import('express').NextFunction} NextFunction */ +/** @typedef {import('express').Request} ExpressRequest */ +/** @typedef {import('express').Response} ExpressResponse */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminRouteAdminSession} AdminRouteAdminSession */ +/** @typedef {ExpressRequest & { adminSession?: AdminRouteAdminSession | null }} AdminExpressRequest */ + +/** + * @typedef {{ + * action?: string + * targetType?: string + * targetId?: string + * data?: Record + * }} AdminAuditPayload + */ + +/** + * @typedef {{ + * successMessage?: string + * errorMessage?: string + * scope?: string + * audit?: (req: AdminExpressRequest, data: unknown) => (AdminAuditPayload | null | undefined) + * }} AdminJsonHandlerOptions + */ + +/** + * @typedef {{ + * errorMessage?: string + * scope?: string + * }} AdminFileHandlerOptions + */ + +/** + * @param {(req: AdminExpressRequest, res: ExpressResponse) => unknown | Promise} action + * @param {AdminJsonHandlerOptions} [options] + */ export function createJsonHandler(action, { successMessage = 'ok', errorMessage, scope, audit } = {}) { return async (req, res) => { try { @@ -15,6 +52,10 @@ export function createJsonHandler(action, { successMessage = 'ok', errorMessage, } } +/** + * @param {(req: AdminExpressRequest, res: ExpressResponse) => string | Promise} action + * @param {AdminFileHandlerOptions} [options] + */ export function createFileHandler(action, { errorMessage, scope } = {}) { return async (req, res) => { try { @@ -26,6 +67,9 @@ export function createFileHandler(action, { errorMessage, scope } = {}) { } } +/** @param {AdminExpressRequest} req */ +/** @param {ExpressResponse} res */ +/** @param {NextFunction} next */ export async function requireAdminSession(req, res, next) { try { req.adminSession = await verifyAdminSessionToken(extractBearerToken(req)) @@ -35,6 +79,7 @@ export async function requireAdminSession(req, res, next) { } } +/** @param {string[]} allowedRoles */ export function requireAdminRoles(allowedRoles) { return (req, res, next) => { try { diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js index 785041af..72ea5101 100644 --- a/apps/backend/src/routes/admin/tasks.js +++ b/apps/backend/src/routes/admin/tasks.js @@ -1,3 +1,5 @@ +// @ts-check + import { Router } from 'express' import { @@ -18,10 +20,21 @@ import { } from '../../services/admin/admin-read-service.js' import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminRouteAdminSession} AdminRouteAdminSession */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminTaskManualDispatchRouteBody} AdminTaskManualDispatchRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminTaskRouteParams} AdminTaskRouteParams */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminTaskRouteQuery} AdminTaskRouteQuery */ +/** @typedef {import('../../types/admin-write-models.js').AdminTaskActionResponse} AdminTaskActionResponse */ +/** @typedef {import('../../types/admin-write-models.js').AdminTaskBindingReleaseResponse} AdminTaskBindingReleaseResponse */ +/** @typedef {import('../../types/admin-write-models.js').AdminTaskManualDispatchResponse} AdminTaskManualDispatchResponse */ + const router = Router() router.get('/tasks', createJsonHandler( - (req) => getAdminTasks(req.query, req.adminSession), + (req) => getAdminTasks( + /** @type {AdminTaskRouteQuery} */ (req.query), + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: 'ok', errorMessage: '读取任务列表失败', @@ -30,7 +43,10 @@ router.get('/tasks', createJsonHandler( )) router.get('/tasks/:taskId', createJsonHandler( - (req) => getAdminTaskDetail(req.params.taskId, req.adminSession), + (req) => getAdminTaskDetail( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: 'ok', errorMessage: '读取任务详情失败', @@ -39,7 +55,10 @@ router.get('/tasks/:taskId', createJsonHandler( )) router.get('/tasks/:taskId/screenshot', createFileHandler( - (req) => getAdminTaskScreenshotPath(req.params.taskId, req.adminSession), + (req) => getAdminTaskScreenshotPath( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { errorMessage: '读取任务截图失败', scope: '[admin/tasks/:taskId/screenshot]', @@ -47,7 +66,7 @@ router.get('/tasks/:taskId/screenshot', createFileHandler( )) router.post('/tasks/:taskId/retry', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => retryAdminTask(req.params.taskId), + (req) => retryAdminTask((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), { successMessage: '任务已重试', errorMessage: '重试任务失败', @@ -56,158 +75,198 @@ router.post('/tasks/:taskId/retry', requireAdminRoles(['admin', 'operator']), cr )) router.post('/tasks/:taskId/release-inventory', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminTaskInventory(req.params.taskId), + (req) => releaseAdminTaskInventory((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), { successMessage: '库存凭据已释放', errorMessage: '释放库存凭据失败', scope: '[admin/tasks/:taskId/release-inventory]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_release_inventory', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - inventoryItemId: data.task.inventoryItemId, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + inventoryItemId: result.task.inventoryItemId, }, - }), + } + }, }, )) router.post('/tasks/:taskId/inventory-bindings/:bindingId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminTaskInventoryBinding(req.params.taskId, req.params.bindingId), + (req) => releaseAdminTaskInventoryBinding( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + (/** @type {AdminTaskRouteParams} */ (req.params)).bindingId, + ), { successMessage: '库存绑定已释放', errorMessage: '释放库存绑定失败', scope: '[admin/tasks/:taskId/inventory-bindings/:bindingId/release]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskBindingReleaseResponse} */ (data) + return { action: 'task_release_inventory_binding', targetType: 'task_inventory_binding', - targetId: String(req.params.bindingId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).bindingId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - bindingId: data.bindingId, - inventoryItemId: data.inventoryItemId, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + bindingId: result.bindingId, + inventoryItemId: result.inventoryItemId, }, - }), + } + }, }, )) router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => regenerateAdminTaskClaimLink(req.params.taskId, req.adminSession), + (req) => regenerateAdminTaskClaimLink( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: '领取链接已重新生成', errorMessage: '重新生成领取链接失败', scope: '[admin/tasks/:taskId/regenerate-claim-link]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_regenerate_claim_link', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - primaryClaimTokenId: data.task.primaryClaimTokenId, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + primaryClaimTokenId: result.task.primaryClaimTokenId, }, - }), + } + }, }, )) router.post('/tasks/:taskId/support-confirm-role', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => confirmAdminTaskAssistedRole(req.params.taskId, req.adminSession), + (req) => confirmAdminTaskAssistedRole( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: '角色已确认', errorMessage: '客服确认角色失败', scope: '[admin/tasks/:taskId/support-confirm-role]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_support_confirm_role', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, }, - }), + } + }, }, )) router.post('/tasks/:taskId/support-redeem', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => redeemAdminTaskAssisted(req.params.taskId, req.adminSession), + (req) => redeemAdminTaskAssisted( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: '兑换任务已启动', errorMessage: '客服发起兑换失败', scope: '[admin/tasks/:taskId/support-redeem]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_support_redeem', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, - deliveryStatus: data.task.deliveryStatus, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + deliveryStatus: result.task.deliveryStatus, }, - }), + } + }, }, )) router.post('/tasks/:taskId/close', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => closeAdminTask(req.params.taskId), + (req) => closeAdminTask((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), { successMessage: '任务已关闭', errorMessage: '关闭任务失败', scope: '[admin/tasks/:taskId/close]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_closed', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, }, - }), + } + }, }, )) router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => markAdminTaskManualReview(req.params.taskId), + (req) => markAdminTaskManualReview((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), { successMessage: '任务已转人工处理', errorMessage: '标记人工处理失败', scope: '[admin/tasks/:taskId/mark-manual-review]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { action: 'task_mark_manual_review', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, }, - }), + } + }, }, )) router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => completeAdminTaskManualDispatch(req.params.taskId, req.body, req.adminSession), + (req) => completeAdminTaskManualDispatch( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminTaskManualDispatchRouteBody} */ (req.body), + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), { successMessage: '人工履约结果已回写', errorMessage: '回写人工履约结果失败', scope: '[admin/tasks/:taskId/complete-manual-dispatch]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminTaskManualDispatchResponse} */ (data) + return { action: 'task_complete_manual_dispatch', targetType: 'task', - targetId: String(req.params.taskId), + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - outcome: data.outcome, - deliveryStatus: data.task.deliveryStatus, - resultCode: data.task.resultCode, + taskId: result.task.taskId, + taskNo: result.task.taskNo, + outcome: result.outcome, + deliveryStatus: result.task.deliveryStatus, + resultCode: result.task.resultCode, }, - }), + } + }, }, )) diff --git a/apps/backend/src/routes/admin/webhook-events.js b/apps/backend/src/routes/admin/webhook-events.js index 72a0e528..b609d933 100644 --- a/apps/backend/src/routes/admin/webhook-events.js +++ b/apps/backend/src/routes/admin/webhook-events.js @@ -1,3 +1,5 @@ +// @ts-check + import { Router } from 'express' import { @@ -9,12 +11,16 @@ import { } from '../../services/admin/admin-read-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' +/** @typedef {import('../../types/admin-route-inputs.js').AdminWebhookEventRouteParams} AdminWebhookEventRouteParams */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminWebhookEventRouteQuery} AdminWebhookEventRouteQuery */ +/** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */ + const router = Router() router.use('/webhook-events', requireAdminRoles(['admin', 'operator'])) router.get('/webhook-events', createJsonHandler( - (req) => getAdminWebhookEvents(req.query), + (req) => getAdminWebhookEvents(/** @type {AdminWebhookEventRouteQuery} */ (req.query)), { successMessage: 'ok', errorMessage: '读取 webhook 列表失败', @@ -23,7 +29,7 @@ router.get('/webhook-events', createJsonHandler( )) router.get('/webhook-events/:eventId', createJsonHandler( - (req) => getAdminWebhookEventDetail(req.params.eventId), + (req) => getAdminWebhookEventDetail((/** @type {AdminWebhookEventRouteParams} */ (req.params)).eventId), { successMessage: 'ok', errorMessage: '读取 webhook 详情失败', @@ -32,20 +38,23 @@ router.get('/webhook-events/:eventId', createJsonHandler( )) router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( - (req) => replayAdminWebhookEvent(req.params.eventId), + (req) => replayAdminWebhookEvent((/** @type {AdminWebhookEventRouteParams} */ (req.params)).eventId), { successMessage: 'Webhook 已重放', errorMessage: '重放 webhook 失败', scope: '[admin/webhook-events/:eventId/replay]', - audit: (req, data) => ({ + audit: (req, data) => { + const result = /** @type {AdminWebhookReplayResponse} */ (data) + return { action: 'webhook_replayed', targetType: 'webhook_event', - targetId: String(req.params.eventId), + targetId: String((/** @type {AdminWebhookEventRouteParams} */ (req.params)).eventId), data: { - eventId: data.eventId, - replayed: data.replayed, + eventId: result.eventId, + replayed: result.replayed, }, - }), + } + }, }, )) diff --git a/apps/backend/src/services/admin/admin-message-delivery-service.js b/apps/backend/src/services/admin/admin-message-delivery-service.js index c6ab1428..eccdca2a 100644 --- a/apps/backend/src/services/admin/admin-message-delivery-service.js +++ b/apps/backend/src/services/admin/admin-message-delivery-service.js @@ -3,7 +3,10 @@ import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js' import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' -export async function getAdminMessageDeliveries(query = {}) { +/** @typedef {import('../../types/admin-read-inputs.js').AdminMessageDeliveryListQueryInput} AdminMessageDeliveryListQueryInput */ + +/** @param {AdminMessageDeliveryListQueryInput} [query] */ +export async function getAdminMessageDeliveries(query = /** @type {AdminMessageDeliveryListQueryInput} */ ({})) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listMessageDeliveries({ diff --git a/apps/backend/src/services/admin/admin-platform-config-service.js b/apps/backend/src/services/admin/admin-platform-config-service.js index d05b5606..29352d08 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -18,7 +18,11 @@ import { createHttpError } from '../../utils/http.js' import { formatFenToAmount } from '../../utils/money.js' import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js' import { normalizeProductName } from '../order/product-match-service.js' -import { resolveDisplayShopName } from './admin-read-helpers.js' +import { resolveDisplayShopName } from './admin-read-shared-helpers.js' + +/** @typedef {import('../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */ export async function getAdminAgisoShopConfigs() { const configMap = getAgisoShopConfigMap() @@ -63,7 +67,8 @@ export async function getAdminAgisoShopConfigs() { } } -export function updateAdminAgisoShopConfigs(payload = {}) { +/** @param {AdminAgisoShopConfigSaveInput} [payload] */ +export function updateAdminAgisoShopConfigs(payload = /** @type {AdminAgisoShopConfigSaveInput} */ ({})) { const rawItems = Array.isArray(payload.shops) ? payload.shops : [] const nextMap = {} @@ -179,7 +184,8 @@ export async function getAdminFulfillmentBindingConfigs() { } } -export async function lookupAdminFulfillmentBindingOrder(payload = {}) { +/** @param {AdminFulfillmentBindingLookupInput} [payload] */ +export async function lookupAdminFulfillmentBindingOrder(payload = /** @type {AdminFulfillmentBindingLookupInput} */ ({})) { const provider = String(payload.provider || 'agiso').trim() || 'agiso' const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' const shopId = String(payload.shopId || '').trim() @@ -302,7 +308,10 @@ export async function lookupAdminFulfillmentBindingOrder(payload = {}) { } } -export async function updateAdminFulfillmentBindingConfigs(payload = {}) { +/** @param {AdminFulfillmentBindingConfigSaveInput} [payload] */ +export async function updateAdminFulfillmentBindingConfigs( + payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}), +) { const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] await validateAdminFulfillmentBindingConfigs(bindingsInput) const saved = saveOrderFulfillmentBindingConfigs(bindingsInput) diff --git a/apps/backend/src/services/admin/admin-read-helpers.js b/apps/backend/src/services/admin/admin-read-helpers.js index e01571f8..4ca03941 100644 --- a/apps/backend/src/services/admin/admin-read-helpers.js +++ b/apps/backend/src/services/admin/admin-read-helpers.js @@ -1,54 +1,23 @@ // @ts-check -import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js' import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js' import { getInventoryItemById } from '../../repositories/inventory-repo.js' import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' import { getOrderById } from '../../repositories/order-repo.js' import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js' -import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js' import { safeParseJson } from './admin-query-utils.js' -import { normalizeAdminRole } from './admin-auth-service.js' +import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js' +import { buildOrderBindingSummary, getTaskBindingSummaryMap, mapAdminTaskSummary } from './admin-task-read-helpers.js' /** @typedef {import('../../types/admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem */ /** @typedef {import('../../types/admin-read-models.js').AdminOrderListItem} AdminOrderListItem */ -/** @typedef {import('../../types/admin-read-models.js').AdminTaskBindingSummary} AdminTaskBindingSummary */ -/** @typedef {import('../../types/admin-read-models.js').AdminTaskListItem} AdminTaskListItem */ /** @typedef {import('../../types/admin-read-models.js').AdminWebhookEventListItem} AdminWebhookEventListItem */ /** @typedef {import('../../types/repository-rows.js').InventoryItemRow} InventoryItemRow */ /** @typedef {import('../../types/repository-rows.js').OrderListRow} OrderListRow */ -/** @typedef {import('../../types/repository-rows.js').TaskEventRow} TaskEventRow */ -/** @typedef {import('../../types/repository-rows.js').TaskInventoryBindingRow} TaskInventoryBindingRow */ -/** @typedef {import('../../types/repository-rows.js').TaskRow} TaskRow */ /** @typedef {import('../../types/repository-rows.js').WebhookEventRow} WebhookEventRow */ -export function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) { - const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) - - return { - taskId: task.id, - taskNo: task.task_no, - deliveryStatus: task.delivery_status || '', - status: task.task_status, - systemBindingStatus: binding.systemBindingStatus, - userBindingStatus: binding.userBindingStatus, - loginType: task.login_type, - browserSessionId: task.browser_session_id, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - lastError: task.last_error, - retryCount: getTaskRetryCount(task), - bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), - createdAt: task.created_at, - updatedAt: task.updated_at, - } -} - /** * @param {WebhookEventRow} item * @returns {Promise} @@ -186,7 +155,7 @@ export async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { export async function mapAdminInventoryListItem(item) { const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null const order = task?.order_id ? await getOrderById(task.order_id) : null - const binding = buildTaskBindingState(task) + const taskSummary = task ? mapAdminTaskSummary(task) : null return { inventoryItemId: item.id, @@ -198,8 +167,8 @@ export async function mapAdminInventoryListItem(item) { reservedByTaskId: item.reserved_by_task_id, reservedByTaskNo: task?.task_no || '', platformOrderId: order?.platform_order_id || '', - systemBindingStatus: item.status === 'consumed' ? 'system_bound' : binding.systemBindingStatus, - userBindingStatus: item.status === 'consumed' ? 'binding_completed' : binding.userBindingStatus, + systemBindingStatus: item.status === 'consumed' ? 'system_bound' : (taskSummary?.systemBindingStatus || 'pending_binding'), + userBindingStatus: item.status === 'consumed' ? 'binding_completed' : (taskSummary?.userBindingStatus || 'not_started'), invalidReason: item.invalid_reason || '', deliveredAt: item.delivered_at, createdAt: item.created_at, @@ -207,37 +176,6 @@ export async function mapAdminInventoryListItem(item) { } } -export function mapTaskActionPayload(task) { - const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - - return { - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - deliveryStatus: task.delivery_status, - resultCode: task.result_code, - resultMessage: task.result_message, - inventoryItemId, - primaryClaimTokenId, - lastError: task.last_error, - updatedAt: task.updated_at, - } -} - -export async function getRequiredTask(taskId) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('任务不存在', { - statusCode: 404, - errorCode: 'admin_task_not_found', - }) - } - - return task -} - export async function getRequiredInventoryItem(inventoryItemId) { const inventoryItem = await getInventoryItemById(Number(inventoryItemId)) @@ -251,133 +189,6 @@ export async function getRequiredInventoryItem(inventoryItemId) { return inventoryItem } -/** - * @param {TaskRow} task - * @returns {AdminTaskListItem} - */ -export function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) { - const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) - - return { - taskId: task.id, - taskNo: task.task_no, - platformOrderId: task.platform_order_id, - skuCode: task.sku_code || '', - skuName: task.sku_name || '', - status: task.task_status, - executorKey: task.executor_key || '', - deliveryStatus: task.delivery_status || '', - resultCode: task.result_code || '', - resultMessage: task.result_message || '', - systemBindingStatus: binding.systemBindingStatus, - userBindingStatus: binding.userBindingStatus, - loginType: task.login_type, - roleName: task.role_name, - roleId: task.role_id, - browserSessionId: task.browser_session_id, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - retryCount: getTaskRetryCount(task), - bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), - lastError: task.last_error, - createdAt: task.created_at, - updatedAt: task.updated_at, - inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '', - inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(), - claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '', - screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''), - } -} - -/** @param {TaskEventRow} event */ -export function mapAdminTaskEvent(event) { - const payload = safeParseJson(event.payload_json) - - return { - eventId: event.id, - eventType: String(event.event_type || '').trim(), - payload: normalizeRecord(payload), - createdAt: event.created_at, - } -} - -/** @returns {AdminTaskBindingSummary} */ -export function createEmptyTaskBindingSummary() { - return { - totalBindingCount: 0, - reservedBindingCount: 0, - consumedBindingCount: 0, - releasedBindingCount: 0, - roleKeys: [], - } -} - -export async function getTaskBindingSummaryMap(taskIds = []) { - const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds) - const output = new Map() - - for (const row of rows) { - output.set(Number(row.task_id), { - totalBindingCount: Number(row.total_binding_count || 0), - reservedBindingCount: Number(row.reserved_binding_count || 0), - consumedBindingCount: Number(row.consumed_binding_count || 0), - releasedBindingCount: Number(row.released_binding_count || 0), - roleKeys: Array.isArray(row.role_keys) - ? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean) - : [], - }) - } - - return output -} - -export function getTaskBindingSummary(summaryMap, taskId) { - return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary() -} - -export function createTaskBindingSummaryFromBindings(bindings = []) { - const normalizedBindings = Array.isArray(bindings) ? bindings : [] - const roleKeys = Array.from(new Set(normalizedBindings - .map((binding) => String(binding?.role_key || '').trim()) - .filter(Boolean))) - - return { - totalBindingCount: normalizedBindings.length, - reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length, - consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length, - releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length, - roleKeys, - } -} - -export function mapAdminTaskInventoryBinding(binding, task, viewerContext = createAdminViewerContext()) { - const metadata = safeParseJson(binding.metadata_json) - - return { - bindingId: binding.id, - inventoryItemId: binding.inventory_item_id, - roleKey: String(binding.role_key || '').trim(), - quantity: Math.max(1, Number(binding.quantity || 1)), - bindingStatus: String(binding.binding_status || '').trim(), - inventoryStatus: String(binding.inventory_item_status || '').trim(), - skuCode: String(binding.sku_code || '').trim(), - batchNo: String(binding.batch_no || '').trim(), - credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code', - displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '', - invalidReason: String(binding.invalid_reason || '').trim(), - consumedAt: binding.consumed_at || null, - releasedAt: binding.released_at || null, - createdAt: binding.created_at, - updatedAt: binding.updated_at, - metadata: normalizeRecord(metadata), - isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task), - canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding), - } -} - /** * @param {OrderListRow} item * @returns {Promise} @@ -442,451 +253,6 @@ export function summarizeOrderItems(items) { return `${firstLabel} 等 ${normalizedItems.length} 项` } -export function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) { - const normalizedTasks = Array.isArray(tasks) ? tasks : [] - const totalTaskCount = normalizedTasks.length - const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task)) - const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length - const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length - const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id)) - const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0) - const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0) - const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0) - const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0) - - let systemBindingStatus = 'pending_binding' - let userBindingStatus = 'not_started' - - if (totalTaskCount === 0) { - return { - totalTaskCount, - systemBoundTaskCount, - completedBindingTaskCount, - totalBindingCount, - reservedBindingCount, - consumedBindingCount, - releasedBindingCount, - systemBindingStatus, - userBindingStatus, - } - } - - if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) { - systemBindingStatus = 'system_bound' - userBindingStatus = 'binding_completed' - } else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'user_binding' - } else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'waiting_user_claim' - } else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'binding_exception' - } else if (systemBoundTaskCount > 0) { - systemBindingStatus = 'system_bound' - } else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) { - systemBindingStatus = 'binding_exception' - } - - return { - totalTaskCount, - systemBoundTaskCount, - completedBindingTaskCount, - totalBindingCount, - reservedBindingCount, - consumedBindingCount, - releasedBindingCount, - systemBindingStatus, - userBindingStatus, - } -} - -export function buildOrderAgisoAutoDeliverySummary(order, tasks = []) { - if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { - return null - } - - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - const totalTaskCount = normalizedTasks.length - const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length - const latest = normalizedTasks.reduce((best, task) => { - const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) - - if (!autoDelivery) { - return best - } - - const candidate = { - ...autoDelivery, - sourceTaskId: Number(task.id || 0) || null, - sourceTaskNo: String(task.task_no || '').trim(), - } - const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) - const bestTime = Date.parse(String(best?.updatedAt || '')) - - if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { - return candidate - } - - return best - }, null) - - if (latest) { - return { - ...latest, - totalTaskCount, - deliveredTaskCount, - } - } - - return { - status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', - trigger: '', - reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: 0, - errorMessage: '', - requestId: '', - aldsType: null, - updatedAt: null, - sourceTaskId: null, - sourceTaskNo: '', - totalTaskCount, - deliveredTaskCount, - } -} - -export function mapManualDispatchContext(value, viewerContext = createAdminViewerContext()) { - if (!value || typeof value !== 'object') { - return null - } - - return { - outcome: String(value.outcome || '').trim(), - deliveryReference: String(value.deliveryReference || '').trim(), - deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(value.deliveredCredential || '').trim() : '', - resultMessage: String(value.resultMessage || '').trim(), - completedAt: value.completedAt || null, - completedBy: value.completedBy && typeof value.completedBy === 'object' - ? { - userId: Number(value.completedBy.userId || 0) || 0, - username: String(value.completedBy.username || '').trim(), - role: String(value.completedBy.role || '').trim(), - } - : null, - } -} - -export function mapRedeemResolutionContext(value) { - if (!value || typeof value !== 'object') { - return null - } - - const attempts = Array.isArray(value.attempts) - ? value.attempts - .map((attempt) => mapRedeemResolutionAttempt(attempt)) - .filter(Boolean) - : [] - - return { - status: String(value.status || '').trim(), - taskStatus: String(value.taskStatus || '').trim(), - replacementCount: Math.max(0, Number(value.replacementCount || 0)), - finishedAt: value.finishedAt || null, - attempts, - } -} - -export function getTaskPrimaryInventoryItemId(task) { - const value = Number(task?.primary_inventory_item_id || 0) - return Number.isFinite(value) && value > 0 ? value : null -} - -export function getTaskPrimaryClaimTokenId(task) { - const value = Number(task?.primary_claim_token_id || 0) - return Number.isFinite(value) && value > 0 ? value : null -} - -export function createAdminViewerContext(session = null) { - const role = normalizeAdminRole(session?.role) - - return { - role, - canViewSensitiveTaskData: role === 'admin' || role === 'operator', - canManageSensitiveInventory: role === 'admin', - canManageTaskLifecycle: role === 'admin' || role === 'operator', - canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support', - } -} - -export async function resolveAdminTaskScreenshotUrl(task, viewerContext) { - if (viewerContext.role === 'support') { - return task.browser_session_id ? `/api/v1/admin/tasks/${task.id}/screenshot` : '' - } - - if (task.screenshot_path || task.browser_session_id) { - return `/api/v1/admin/tasks/${task.id}/screenshot` - } - - return '' -} - -export function resolveDisplayShopName(provider, shopId, shopName) { - const normalizedShopName = String(shopName || '').trim() - if (normalizedShopName) { - return normalizedShopName - } - - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId) { - return '' - } - - if (String(provider || '').trim().toLowerCase() === 'agiso') { - const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim() - if (configuredName) { - return configuredName - } - } - - return normalizedShopId -} - -export function parseTaskContext(task) { - const value = task?.context_json - - if (!value) { - return {} - } - - if (typeof value === 'object') { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -export function parseTaskState(task) { - const value = task?.state_json - - if (!value) { - return {} - } - - if (typeof value === 'object') { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -export function resolveOrderItemTitle(item) { - if (!item) { - return '' - } - - const spec = safeParseJson(item.spec_json) - - return pickFirstNonEmpty([ - spec.title, - spec.Title, - spec.itemTitle, - spec.item_title, - spec.goods_name, - spec.goodsName, - item.sku_name, - item.sku_code, - ]) -} - -export function resolveOrderItemDeliveryMode(tasks, orderItemId) { - const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId) - - if (!task) { - return '' - } - - if (String(task.executor_key || '').trim() === 'manual_dispatch') { - return 'manual_dispatch' - } - - if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) { - return 'claim_link' - } - - return String(task.executor_key || '').trim() -} - -export function isManualDispatchTask(task) { - return String(task?.executor_key || '').trim() === 'manual_dispatch' -} - -export function isAssistedClaimTask(task) { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -export function canRegenerateClaimLinkForViewer(task, viewerContext) { - if (isManualDispatchTask(task)) { - return false - } - - const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status) - - if (!baseAllowed) { - return false - } - - if (viewerContext.canManageTaskLifecycle) { - return true - } - - return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task) -} - -export function canViewerConfirmAssistedRole(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - return false - } - - return String(task?.task_status || '').trim() === 'claimed' -} - -export function canViewerRedeemAssistedTask(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - return false - } - - return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) -} - -function canReleaseTaskInventoryBinding(task, binding) { - if (!task || !binding) { - return false - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - return false - } - - return !['redeemed', 'expired'].includes(String(task.task_status || '').trim()) -} - -function buildTaskBindingState(task) { - const normalizedStatus = String(task?.task_status || '').trim() - - if (!normalizedStatus) { - return { - systemBindingStatus: 'pending_binding', - userBindingStatus: 'not_started', - } - } - - if (normalizedStatus === 'pending_payment') { - return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' } - } - - if (normalizedStatus === 'paid') { - return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } - } - - if (normalizedStatus === 'waiting_inventory') { - return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' } - } - - if (normalizedStatus === 'manual_review') { - return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' } - } - - if (normalizedStatus === 'retry_pending') { - return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' } - } - - if (normalizedStatus === 'closed') { - return { systemBindingStatus: 'closed', userBindingStatus: 'closed' } - } - - if (normalizedStatus === 'expired') { - return { systemBindingStatus: 'expired', userBindingStatus: 'expired' } - } - - if (normalizedStatus === 'link_generated') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } - } - - if (normalizedStatus === 'claimed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' } - } - - if (normalizedStatus === 'role_confirmed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' } - } - - if (normalizedStatus === 'redeeming') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' } - } - - if (normalizedStatus === 'redeemed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } - } - - if (isTaskSystemBound(task)) { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } - } - - return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } -} - -function isTaskSystemBound(task) { - return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) -} - -function mapAgisoAutoDeliveryContext(value) { - if (!value || typeof value !== 'object') { - return null - } - - return { - status: String(value.status || '').trim(), - trigger: String(value.trigger || '').trim(), - reason: String(value.reason || '').trim(), - platformOrderId: String(value.platformOrderId || '').trim(), - responseStatus: Number(value.responseStatus || 0), - errorMessage: String(value.errorMessage || '').trim(), - requestId: String(value.requestId || '').trim(), - aldsType: Number(value.aldsType || 0) || null, - updatedAt: value.updatedAt || null, - } -} - -function mapRedeemResolutionAttempt(value) { - if (!value || typeof value !== 'object') { - return null - } - - return { - attempt: Math.max(1, Number(value.attempt || 1)), - inventoryItemId: Number(value.inventoryItemId || 0) || null, - codeMasked: String(value.codeMasked || '').trim(), - credentialType: String(value.credentialType || '').trim(), - outcome: String(value.outcome || '').trim(), - resultCode: String(value.resultCode || '').trim(), - resultMessage: String(value.resultMessage || '').trim(), - } -} - -function normalizeRecord(value) { - return value && typeof value === 'object' && !Array.isArray(value) ? value : {} -} - function extractWebhookPayload(body) { return extractAgisoTradePayload(body) } @@ -923,6 +289,10 @@ function extractWebhookItemSources(payload) { return [] } +function normalizeRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + function pickFirstNonEmpty(values) { for (const value of values) { if (typeof value === 'string' && value.trim()) { @@ -936,18 +306,3 @@ function pickFirstNonEmpty(values) { return '' } - -function maskCode(value) { - const text = String(value || '').trim() - if (!text) { - return '' - } - if (text.length <= 8) { - return `${text.slice(0, 2)}****${text.slice(-2)}` - } - return `${text.slice(0, 4)}****${text.slice(-4)}` -} - -function getTaskRetryCount(task) { - return Number(task?.attempt_count || 0) -} diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js index 13d16970..6e1ae77e 100644 --- a/apps/backend/src/services/admin/admin-read-service.js +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -14,37 +14,41 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' import { - buildOrderAgisoAutoDeliverySummary, - buildOrderBindingSummary, canRegenerateClaimLinkForViewer, canViewerConfirmAssistedRole, canViewerRedeemAssistedTask, createAdminViewerContext, - createTaskBindingSummaryFromBindings, - getRequiredTask, - getTaskBindingSummary, - getTaskBindingSummaryMap, getTaskPrimaryClaimTokenId, getTaskPrimaryInventoryItemId, isAssistedClaimTask, isManualDispatchTask, + mapManualDispatchContext, + mapRedeemResolutionContext, + parseTaskContext, + parseTaskState, + resolveAdminTaskScreenshotUrl, + resolveDisplayShopName, + resolveOrderItemDeliveryMode, + resolveOrderItemTitle, +} from './admin-read-shared-helpers.js' +import { mapAdminInventoryListItem, mapAdminOrderListItem, + mapAdminWebhookEvent, + summarizeOrderItems, +} from './admin-read-helpers.js' +import { + buildOrderAgisoAutoDeliverySummary, + buildOrderBindingSummary, + createTaskBindingSummaryFromBindings, + getRequiredTask, + getTaskBindingSummary, + getTaskBindingSummaryMap, mapAdminTaskEvent, mapAdminTaskInventoryBinding, mapAdminTaskListItem, mapAdminTaskSummary, - mapAdminWebhookEvent, - mapManualDispatchContext, - parseTaskContext, - parseTaskState, - mapRedeemResolutionContext, - resolveOrderItemDeliveryMode, - resolveOrderItemTitle, - resolveAdminTaskScreenshotUrl, - resolveDisplayShopName, - summarizeOrderItems, -} from './admin-read-helpers.js' +} from './admin-task-read-helpers.js' /** @typedef {import('../../types/admin-read-models.js').AdminInventoryListResponse} AdminInventoryListResponse */ /** @typedef {import('../../types/admin-read-models.js').AdminInventorySkuSuggestionResponse} AdminInventorySkuSuggestionResponse */ diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js new file mode 100644 index 00000000..28b01ce9 --- /dev/null +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js @@ -0,0 +1,246 @@ +// @ts-check + +import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js' +import { safeParseJson } from './admin-query-utils.js' +import { normalizeAdminRole } from './admin-auth-service.js' + +export function mapManualDispatchContext(value, viewerContext = createAdminViewerContext()) { + if (!value || typeof value !== 'object') { + return null + } + + return { + outcome: String(value.outcome || '').trim(), + deliveryReference: String(value.deliveryReference || '').trim(), + deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(value.deliveredCredential || '').trim() : '', + resultMessage: String(value.resultMessage || '').trim(), + completedAt: value.completedAt || null, + completedBy: value.completedBy && typeof value.completedBy === 'object' + ? { + userId: Number(value.completedBy.userId || 0) || 0, + username: String(value.completedBy.username || '').trim(), + role: String(value.completedBy.role || '').trim(), + } + : null, + } +} + +export function mapRedeemResolutionContext(value) { + if (!value || typeof value !== 'object') { + return null + } + + const attempts = Array.isArray(value.attempts) + ? value.attempts + .map((attempt) => mapRedeemResolutionAttempt(attempt)) + .filter(Boolean) + : [] + + return { + status: String(value.status || '').trim(), + taskStatus: String(value.taskStatus || '').trim(), + replacementCount: Math.max(0, Number(value.replacementCount || 0)), + finishedAt: value.finishedAt || null, + attempts, + } +} + +export function getTaskPrimaryInventoryItemId(task) { + const value = Number(task?.primary_inventory_item_id || 0) + return Number.isFinite(value) && value > 0 ? value : null +} + +export function getTaskPrimaryClaimTokenId(task) { + const value = Number(task?.primary_claim_token_id || 0) + return Number.isFinite(value) && value > 0 ? value : null +} + +export function createAdminViewerContext(session = null) { + const role = normalizeAdminRole(session?.role) + + return { + role, + canViewSensitiveTaskData: role === 'admin' || role === 'operator', + canManageSensitiveInventory: role === 'admin', + canManageTaskLifecycle: role === 'admin' || role === 'operator', + canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support', + } +} + +export async function resolveAdminTaskScreenshotUrl(task, viewerContext) { + if (viewerContext.role === 'support') { + return task.browser_session_id ? `/api/v1/admin/tasks/${task.id}/screenshot` : '' + } + + if (task.screenshot_path || task.browser_session_id) { + return `/api/v1/admin/tasks/${task.id}/screenshot` + } + + return '' +} + +export function resolveDisplayShopName(provider, shopId, shopName) { + const normalizedShopName = String(shopName || '').trim() + if (normalizedShopName) { + return normalizedShopName + } + + const normalizedShopId = String(shopId || '').trim() + if (!normalizedShopId) { + return '' + } + + if (String(provider || '').trim().toLowerCase() === 'agiso') { + const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim() + if (configuredName) { + return configuredName + } + } + + return normalizedShopId +} + +export function parseTaskContext(task) { + const value = task?.context_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + + try { + return JSON.parse(String(value || '{}')) + } catch { + return {} + } +} + +export function parseTaskState(task) { + const value = task?.state_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + + try { + return JSON.parse(String(value || '{}')) + } catch { + return {} + } +} + +export function resolveOrderItemTitle(item) { + if (!item) { + return '' + } + + const spec = safeParseJson(item.spec_json) + + return pickFirstNonEmpty([ + spec.title, + spec.Title, + spec.itemTitle, + spec.item_title, + spec.goods_name, + spec.goodsName, + item.sku_name, + item.sku_code, + ]) +} + +export function resolveOrderItemDeliveryMode(tasks, orderItemId) { + const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId) + + if (!task) { + return '' + } + + if (String(task.executor_key || '').trim() === 'manual_dispatch') { + return 'manual_dispatch' + } + + if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) { + return 'claim_link' + } + + return String(task.executor_key || '').trim() +} + +export function isManualDispatchTask(task) { + return String(task?.executor_key || '').trim() === 'manual_dispatch' +} + +export function isAssistedClaimTask(task) { + return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' +} + +export function canRegenerateClaimLinkForViewer(task, viewerContext) { + if (isManualDispatchTask(task)) { + return false + } + + const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status) + + if (!baseAllowed) { + return false + } + + if (viewerContext.canManageTaskLifecycle) { + return true + } + + return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task) +} + +export function canViewerConfirmAssistedRole(task, viewerContext) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + return false + } + + return String(task?.task_status || '').trim() === 'claimed' +} + +export function canViewerRedeemAssistedTask(task, viewerContext) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + return false + } + + return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) +} + +function mapRedeemResolutionAttempt(value) { + if (!value || typeof value !== 'object') { + return null + } + + return { + attempt: Math.max(1, Number(value.attempt || 1)), + inventoryItemId: Number(value.inventoryItemId || 0) || null, + codeMasked: String(value.codeMasked || '').trim(), + credentialType: String(value.credentialType || '').trim(), + outcome: String(value.outcome || '').trim(), + resultCode: String(value.resultCode || '').trim(), + resultMessage: String(value.resultMessage || '').trim(), + } +} + +function pickFirstNonEmpty(values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + } + + return '' +} diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index a5310774..2512483f 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -1,37 +1,45 @@ // @ts-check +export { + getRequiredInventoryItem, + mapAdminInventoryListItem, + mapAdminOrderListItem, + mapAdminWebhookEvent, +} from './admin-read-helpers.js' + export { buildOrderAgisoAutoDeliverySummary, buildOrderBindingSummary, - canRegenerateClaimLinkForViewer, - canViewerConfirmAssistedRole, - canViewerRedeemAssistedTask, - createAdminViewerContext, createEmptyTaskBindingSummary, createTaskBindingSummaryFromBindings, - getRequiredInventoryItem, getRequiredTask, getTaskBindingSummary, getTaskBindingSummaryMap, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - mapAdminInventoryListItem, - mapAdminOrderListItem, mapAdminTaskEvent, mapAdminTaskInventoryBinding, mapAdminTaskListItem, mapAdminTaskSummary, - mapAdminWebhookEvent, + mapTaskActionPayload, +} from './admin-task-read-helpers.js' + +export { + canRegenerateClaimLinkForViewer, + canViewerConfirmAssistedRole, + canViewerRedeemAssistedTask, + createAdminViewerContext, + getTaskPrimaryClaimTokenId, + getTaskPrimaryInventoryItemId, + isAssistedClaimTask, + isManualDispatchTask, mapManualDispatchContext, mapRedeemResolutionContext, - mapTaskActionPayload, parseTaskContext, parseTaskState, resolveOrderItemDeliveryMode, resolveOrderItemTitle, resolveAdminTaskScreenshotUrl, resolveDisplayShopName, -} from './admin-read-helpers.js' +} from './admin-read-shared-helpers.js' export { getAdminInventoryItems, diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.js b/apps/backend/src/services/admin/admin-task-read-helpers.js new file mode 100644 index 00000000..f89847da --- /dev/null +++ b/apps/backend/src/services/admin/admin-task-read-helpers.js @@ -0,0 +1,434 @@ +// @ts-check + +import { getTaskById } from '../../repositories/task-repo.js' +import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js' +import { createHttpError } from '../../utils/http.js' +import { safeParseJson } from './admin-query-utils.js' +import { + createAdminViewerContext, + getTaskPrimaryClaimTokenId, + getTaskPrimaryInventoryItemId, + parseTaskContext, +} from './admin-read-shared-helpers.js' + +/** @typedef {import('../../types/admin-read-models.js').AdminTaskBindingSummary} AdminTaskBindingSummary */ +/** @typedef {import('../../types/admin-read-models.js').AdminTaskListItem} AdminTaskListItem */ +/** @typedef {import('../../types/repository-rows.js').TaskEventRow} TaskEventRow */ +/** @typedef {import('../../types/repository-rows.js').TaskInventoryBindingRow} TaskInventoryBindingRow */ +/** @typedef {import('../../types/repository-rows.js').TaskRow} TaskRow */ + +export function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) { + const binding = buildTaskBindingState(task) + const taskContext = parseTaskContext(task) + + return { + taskId: task.id, + taskNo: task.task_no, + deliveryStatus: task.delivery_status || '', + status: task.task_status, + systemBindingStatus: binding.systemBindingStatus, + userBindingStatus: binding.userBindingStatus, + loginType: task.login_type, + browserSessionId: task.browser_session_id, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + lastError: task.last_error, + retryCount: getTaskRetryCount(task), + bindingSummary, + agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), + createdAt: task.created_at, + updatedAt: task.updated_at, + } +} + +export function mapTaskActionPayload(task) { + const inventoryItemId = getTaskPrimaryInventoryItemId(task) + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + + return { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + deliveryStatus: task.delivery_status, + resultCode: task.result_code, + resultMessage: task.result_message, + inventoryItemId, + primaryClaimTokenId, + lastError: task.last_error, + updatedAt: task.updated_at, + } +} + +export async function getRequiredTask(taskId) { + const task = await getTaskById(Number(taskId)) + + if (!task) { + throw createHttpError('任务不存在', { + statusCode: 404, + errorCode: 'admin_task_not_found', + }) + } + + return task +} + +/** + * @param {TaskRow} task + * @returns {AdminTaskListItem} + */ +export function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) { + const binding = buildTaskBindingState(task) + const taskContext = parseTaskContext(task) + + return { + taskId: task.id, + taskNo: task.task_no, + platformOrderId: task.platform_order_id, + skuCode: task.sku_code || '', + skuName: task.sku_name || '', + status: task.task_status, + executorKey: task.executor_key || '', + deliveryStatus: task.delivery_status || '', + resultCode: task.result_code || '', + resultMessage: task.result_message || '', + systemBindingStatus: binding.systemBindingStatus, + userBindingStatus: binding.userBindingStatus, + loginType: task.login_type, + roleName: task.role_name, + roleId: task.role_id, + browserSessionId: task.browser_session_id, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + retryCount: getTaskRetryCount(task), + bindingSummary, + agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), + lastError: task.last_error, + createdAt: task.created_at, + updatedAt: task.updated_at, + inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '', + inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(), + claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '', + screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''), + } +} + +/** @param {TaskEventRow} event */ +export function mapAdminTaskEvent(event) { + const payload = safeParseJson(event.payload_json) + + return { + eventId: event.id, + eventType: String(event.event_type || '').trim(), + payload: normalizeRecord(payload), + createdAt: event.created_at, + } +} + +/** @returns {AdminTaskBindingSummary} */ +export function createEmptyTaskBindingSummary() { + return { + totalBindingCount: 0, + reservedBindingCount: 0, + consumedBindingCount: 0, + releasedBindingCount: 0, + roleKeys: [], + } +} + +export async function getTaskBindingSummaryMap(taskIds = []) { + const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds) + const output = new Map() + + for (const row of rows) { + output.set(Number(row.task_id), { + totalBindingCount: Number(row.total_binding_count || 0), + reservedBindingCount: Number(row.reserved_binding_count || 0), + consumedBindingCount: Number(row.consumed_binding_count || 0), + releasedBindingCount: Number(row.released_binding_count || 0), + roleKeys: Array.isArray(row.role_keys) + ? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean) + : [], + }) + } + + return output +} + +export function getTaskBindingSummary(summaryMap, taskId) { + return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary() +} + +export function createTaskBindingSummaryFromBindings(bindings = []) { + const normalizedBindings = Array.isArray(bindings) ? bindings : [] + const roleKeys = Array.from(new Set(normalizedBindings + .map((binding) => String(binding?.role_key || '').trim()) + .filter(Boolean))) + + return { + totalBindingCount: normalizedBindings.length, + reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length, + consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length, + releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length, + roleKeys, + } +} + +export function mapAdminTaskInventoryBinding(binding, task, viewerContext = createAdminViewerContext()) { + const metadata = safeParseJson(binding.metadata_json) + + return { + bindingId: binding.id, + inventoryItemId: binding.inventory_item_id, + roleKey: String(binding.role_key || '').trim(), + quantity: Math.max(1, Number(binding.quantity || 1)), + bindingStatus: String(binding.binding_status || '').trim(), + inventoryStatus: String(binding.inventory_item_status || '').trim(), + skuCode: String(binding.sku_code || '').trim(), + batchNo: String(binding.batch_no || '').trim(), + credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code', + displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '', + invalidReason: String(binding.invalid_reason || '').trim(), + consumedAt: binding.consumed_at || null, + releasedAt: binding.released_at || null, + createdAt: binding.created_at, + updatedAt: binding.updated_at, + metadata: normalizeRecord(metadata), + isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task), + canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding), + } +} + +export function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) { + const normalizedTasks = Array.isArray(tasks) ? tasks : [] + const totalTaskCount = normalizedTasks.length + const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task)) + const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length + const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length + const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id)) + const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0) + const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0) + const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0) + const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0) + + let systemBindingStatus = 'pending_binding' + let userBindingStatus = 'not_started' + + if (totalTaskCount === 0) { + return { + totalTaskCount, + systemBoundTaskCount, + completedBindingTaskCount, + totalBindingCount, + reservedBindingCount, + consumedBindingCount, + releasedBindingCount, + systemBindingStatus, + userBindingStatus, + } + } + + if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) { + systemBindingStatus = 'system_bound' + userBindingStatus = 'binding_completed' + } else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'user_binding' + } else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'waiting_user_claim' + } else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'binding_exception' + } else if (systemBoundTaskCount > 0) { + systemBindingStatus = 'system_bound' + } else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) { + systemBindingStatus = 'binding_exception' + } + + return { + totalTaskCount, + systemBoundTaskCount, + completedBindingTaskCount, + totalBindingCount, + reservedBindingCount, + consumedBindingCount, + releasedBindingCount, + systemBindingStatus, + userBindingStatus, + } +} + +export function buildOrderAgisoAutoDeliverySummary(order, tasks = []) { + if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { + return null + } + + const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] + const totalTaskCount = normalizedTasks.length + const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length + const latest = normalizedTasks.reduce((best, task) => { + const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) + + if (!autoDelivery) { + return best + } + + const candidate = { + ...autoDelivery, + sourceTaskId: Number(task.id || 0) || null, + sourceTaskNo: String(task.task_no || '').trim(), + } + const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) + const bestTime = Date.parse(String(best?.updatedAt || '')) + + if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { + return candidate + } + + return best + }, null) + + if (latest) { + return { + ...latest, + totalTaskCount, + deliveredTaskCount, + } + } + + return { + status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', + trigger: '', + reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', + platformOrderId: String(order?.platform_order_id || '').trim(), + responseStatus: 0, + errorMessage: '', + requestId: '', + aldsType: null, + updatedAt: null, + sourceTaskId: null, + sourceTaskNo: '', + totalTaskCount, + deliveredTaskCount, + } +} + +function canReleaseTaskInventoryBinding(task, binding) { + if (!task || !binding) { + return false + } + + if (String(binding.binding_status || '').trim() !== 'reserved') { + return false + } + + return !['redeemed', 'expired'].includes(String(task.task_status || '').trim()) +} + +function buildTaskBindingState(task) { + const normalizedStatus = String(task?.task_status || '').trim() + + if (!normalizedStatus) { + return { + systemBindingStatus: 'pending_binding', + userBindingStatus: 'not_started', + } + } + + if (normalizedStatus === 'pending_payment') { + return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'paid') { + return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'waiting_inventory') { + return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'manual_review') { + return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' } + } + + if (normalizedStatus === 'retry_pending') { + return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' } + } + + if (normalizedStatus === 'closed') { + return { systemBindingStatus: 'closed', userBindingStatus: 'closed' } + } + + if (normalizedStatus === 'expired') { + return { systemBindingStatus: 'expired', userBindingStatus: 'expired' } + } + + if (normalizedStatus === 'link_generated') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + } + + if (normalizedStatus === 'claimed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' } + } + + if (normalizedStatus === 'role_confirmed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' } + } + + if (normalizedStatus === 'redeeming') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' } + } + + if (normalizedStatus === 'redeemed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + } + + if (isTaskSystemBound(task)) { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + } + + return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } +} + +function isTaskSystemBound(task) { + return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) +} + +function mapAgisoAutoDeliveryContext(value) { + if (!value || typeof value !== 'object') { + return null + } + + return { + status: String(value.status || '').trim(), + trigger: String(value.trigger || '').trim(), + reason: String(value.reason || '').trim(), + platformOrderId: String(value.platformOrderId || '').trim(), + responseStatus: Number(value.responseStatus || 0), + errorMessage: String(value.errorMessage || '').trim(), + requestId: String(value.requestId || '').trim(), + aldsType: Number(value.aldsType || 0) || null, + updatedAt: value.updatedAt || null, + } +} + +function normalizeRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + +function maskCode(value) { + const text = String(value || '').trim() + if (!text) { + return '' + } + if (text.length <= 8) { + return `${text.slice(0, 2)}****${text.slice(-2)}` + } + return `${text.slice(0, 4)}****${text.slice(-4)}` +} + +function getTaskRetryCount(task) { + return Number(task?.attempt_count || 0) +} diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index cfe1a1ee..619a8358 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -28,16 +28,20 @@ import { canViewerConfirmAssistedRole, canViewerRedeemAssistedTask, createAdminViewerContext, - getRequiredInventoryItem, - getRequiredTask, getTaskPrimaryClaimTokenId, getTaskPrimaryInventoryItemId, isAssistedClaimTask, isManualDispatchTask, - mapAdminInventoryListItem, - mapTaskActionPayload, parseTaskContext, +} from './admin-read-shared-helpers.js' +import { + getRequiredInventoryItem, + mapAdminInventoryListItem, } from './admin-read-helpers.js' +import { + getRequiredTask, + mapTaskActionPayload, +} from './admin-task-read-helpers.js' /** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */ /** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */ diff --git a/apps/backend/src/types/admin-read-inputs.js b/apps/backend/src/types/admin-read-inputs.js index 39d3564d..67ad8221 100644 --- a/apps/backend/src/types/admin-read-inputs.js +++ b/apps/backend/src/types/admin-read-inputs.js @@ -69,6 +69,21 @@ export {} * }} AdminWebhookEventListQueryInput */ +/** + * @typedef {{ + * page?: number | string + * pageSize?: number | string + * provider?: string + * platform?: string + * status?: string + * shopId?: string + * platformOrderId?: string + * taskNo?: string + * dateFrom?: string + * dateTo?: string + * }} AdminMessageDeliveryListQueryInput + */ + /** * @typedef {number | string} AdminEntityIdInput */ diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js new file mode 100644 index 00000000..77320725 --- /dev/null +++ b/apps/backend/src/types/admin-route-inputs.js @@ -0,0 +1,75 @@ +// @ts-check + +export {} + +/** + * @typedef {import('./admin-read-inputs.js').AdminInventoryListQueryInput} AdminInventoryRouteQuery + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminInventorySkuSuggestionQueryInput} AdminInventorySkuSuggestionRouteQuery + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminMessageDeliveryListQueryInput} AdminMessageDeliveryRouteQuery + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminOrderListQueryInput} AdminOrderRouteQuery + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminTaskListQueryInput} AdminTaskRouteQuery + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminViewerSessionInput} AdminRouteAdminSession + */ + +/** + * @typedef {import('./admin-read-inputs.js').AdminWebhookEventListQueryInput} AdminWebhookEventRouteQuery + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminInventoryCreateInput} AdminInventoryCreateRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminInventoryImportInput} AdminInventoryImportRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody + */ + +/** + * @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams + */ + +/** + * @typedef {{ orderId?: string }} AdminOrderRouteParams + */ + +/** + * @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams + */ + +/** + * @typedef {{ eventId?: string }} AdminWebhookEventRouteParams + */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index f0a04e08..1384f7b3 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -49,8 +49,52 @@ export {} * }} AdminAgisoShopConfigSaveInput */ +/** + * @typedef {{ + * provider?: string + * platform?: string + * shopId?: string + * platformOrderId?: string + * }} AdminFulfillmentBindingLookupInput + */ + +/** + * @typedef {{ + * provider?: string + * platform?: string + * shopId?: string + * skuCode?: string + * skuName?: string + * profileKey?: string + * enabled?: boolean + * priority?: number | string + * config?: Record + * match?: { + * externalSkuCode?: string + * externalItemId?: string + * externalSkuName?: string + * config?: Record + * } + * }} AdminFulfillmentBindingConfigWriteItemInput + */ + +/** + * @typedef {{ + * bindings?: AdminFulfillmentBindingConfigWriteItemInput[] + * }} AdminFulfillmentBindingConfigSaveInput + */ + /** * @typedef {{ * reason?: string * }} AdminInventoryInvalidateInput */ + +/** + * @typedef {{ + * outcome?: string + * resultMessage?: string + * deliveryReference?: string + * deliveredCredential?: string + * }} AdminTaskManualDispatchInput + */ diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index dca4ba2d..6748b249 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -20,12 +20,22 @@ "src/repositories/task-repo.js", "src/repositories/webhook-event-repo.js", "src/services/admin/admin-read-helpers.js", + "src/services/admin/admin-read-shared-helpers.js", + "src/services/admin/admin-task-read-helpers.js", "src/services/admin/admin-dashboard-service.js", "src/services/admin/admin-message-delivery-service.js", "src/services/admin/admin-platform-config-service.js", "src/services/admin/admin-read-service.js", "src/services/admin/admin-service.js", "src/services/admin/admin-write-service.js", + "src/routes/admin/dashboard.js", + "src/routes/admin/inventory.js", + "src/routes/admin/message-deliveries.js", + "src/routes/admin/orders.js", + "src/routes/admin/platform-config.js", + "src/routes/admin/shared.js", + "src/routes/admin/tasks.js", + "src/routes/admin/webhook-events.js", "src/types/**/*.js" ], "exclude": [ diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index b1741cec..20677e3e 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -173,6 +173,17 @@ 47. `dashboard / message-deliveries` 路由已直接依赖独立 service 48. `admin-service.js` 已收敛为兼容导出层,不再直接承载后台业务实现 49. 后台读 / 写 / 平台配置 / 概览 / 消息记录已分散到独立 service 模块 +50. 新增 `src/types/admin-route-inputs.js`,开始承接 `admin` 路由层的 query / body / params 共享输入边界 +51. `dashboard / inventory / orders / tasks / platform-config / message-deliveries / webhook-events` 路由已显式接入 `@ts-check` 与共享路由输入类型 +52. `src/routes/admin/shared.js` 已补齐通用 handler 的请求 / 审计签名,`adminSession` 扩展请求字段进入路由层类型检查 +53. 后台 `admin` 路由层首批已纳入 backend `typecheck` 范围,并验证通过 +54. 新增 `src/services/admin/admin-read-shared-helpers.js`,开始承接 viewer / task context / 权限判定 / 订单展示等跨读写共享 helper +55. `admin-read-service / admin-write-service / admin-platform-config-service / admin-service` 已改为优先依赖共享 helper 模块,`admin-read-helpers.js` 职责继续收敛 +56. `npm --prefix apps/backend run typecheck` 已在共享 helper 拆分后继续通过 +57. 新增 `src/services/admin/admin-task-read-helpers.js`,开始承接任务映射、binding summary、任务动作 payload 与订单绑定汇总 +58. `admin-read-service / admin-write-service / admin-service` 已将 task 相关 helper 改为直接依赖独立 task helper 模块 +59. `admin-read-helpers.js` 已进一步收敛到 webhook / inventory / order-list 三类能力 +60. `npm --prefix apps/backend run typecheck` 已在 task helper 拆分后继续通过 这一步的设计原则是“只加静态约束,不动运行链路”,所以不会影响: @@ -185,9 +196,10 @@ 第一批继续推进时,建议按这个顺序: 1. 为 `admin` 路由层补请求体 / 查询参数的共享输入类型 -2. 评估是否把部分 `admin-read-helpers.js` 再细拆成按领域 helper 模块 -3. 继续补 repository / service 的边界约束,但不急着改 `.ts` 扩展名 -4. 为 webhook、库存换码、自动发货补测试 +2. 继续把 `admin-read-helpers.js` 细拆成按领域 helper 模块,下一步优先拆 `webhook` 或 `order-list` helper +3. 为 `admin` 路由层继续补响应模型与审计 payload 的共享类型,减少 `unknown` 到处就地断言 +4. 继续补 repository / service 的边界约束,但不急着改 `.ts` 扩展名 +5. 为 webhook、库存换码、自动发货补测试 ## 执行原则