diff --git a/apps/backend/README.md b/apps/backend/README.md index f9899abc..0d14b996 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -40,6 +40,19 @@ npm run browser:install:linux npm run start ``` +后端渐进 TypeScript 迁移已经开始,当前可执行: + +```bash +npm run typecheck +``` + +第一阶段目前先检查: + +- `src/config/runtime.js` +- `src/types/**/*.js` + +后续会逐步扩大到更多 routes / services / repositories,而不会先改成需要编译产物的运行模式。 + ## 配置 当前后端采用 `.env` 单一路径做环境配置: diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 8b3e41fe..49756c56 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -11,6 +11,20 @@ "express": "^5.1.0", "pg": "^8.16.3", "playwright": "1.42.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.2" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" } }, "node_modules/accepts": { @@ -981,6 +995,27 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 47b03ef4..132fcd00 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -10,11 +10,16 @@ "seed:dev-data": "node scripts/seed-dev-data.js", "db:migrate": "node src/db/migrate.js", "dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node src/index.js" }, "dependencies": { "express": "^5.1.0", "pg": "^8.16.3", "playwright": "1.42.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.2" } } diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 2bd3e7c6..4f2888ac 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -1,9 +1,14 @@ +// @ts-check + import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' +/** @typedef {import('../types/runtime-config.js').AgisoMessagingShopsConfig} AgisoMessagingShopsConfig */ +/** @typedef {import('../types/runtime-config.js').RuntimeConfig} RuntimeConfig */ + const require = createRequire(import.meta.url) const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..') @@ -15,9 +20,9 @@ loadEnvFiles([ path.join(PROJECT_ROOT, '.env'), ]) -const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs')) +const defaultConfig = /** @type {RuntimeConfig} */ (loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))) -export const runtimeConfig = applyEnvOverrides(defaultConfig) +export const runtimeConfig = /** @type {RuntimeConfig} */ (applyEnvOverrides(defaultConfig)) function loadConfig(configPath) { if (!fs.existsSync(configPath)) { @@ -381,6 +386,7 @@ function parseJsonArray(rawValue) { } function normalizeAgisoMessagingShops(rawValue) { + /** @type {AgisoMessagingShopsConfig} */ const output = {} for (const [shopId, config] of Object.entries(rawValue || {})) { @@ -389,6 +395,7 @@ function normalizeAgisoMessagingShops(rawValue) { continue } + /** @type {AgisoMessagingShopsConfig[string]} */ const next = {} const enabled = normalizeBooleanLike(config.enabled) if (enabled !== null) { diff --git a/apps/backend/src/repositories/inventory-repo.js b/apps/backend/src/repositories/inventory-repo.js index 5410c89f..25b83f30 100644 --- a/apps/backend/src/repositories/inventory-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -1,5 +1,14 @@ +// @ts-check + import { query, withTransaction } from '../db/client.js' +/** @typedef {import('../types/repository-inputs.js').InventoryCreateItemInput} InventoryCreateItemInput */ +/** @typedef {import('../types/repository-inputs.js').InventoryListQueryInput} InventoryListQueryInput */ +/** @typedef {import('../types/repository-inputs.js').InventorySkuSuggestionQueryInput} InventorySkuSuggestionQueryInput */ +/** @typedef {import('../types/repository-rows.js').InventoryItemRow} InventoryItemRow */ +/** @typedef {import('../types/repository-rows.js').InventoryListQueryResult} InventoryListQueryResult */ +/** @typedef {import('../types/repository-rows.js').InventorySkuSuggestionRow} InventorySkuSuggestionRow */ + const INVENTORY_ITEM_SELECT = ` SELECT ii.id, @@ -26,6 +35,7 @@ const INVENTORY_ITEM_SELECT = ` LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id ` +/** @returns {Promise} */ export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') { const result = await query( `${INVENTORY_ITEM_SELECT} @@ -35,9 +45,10 @@ export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credenti [skuCode, credentialType], ) - return result.rows[0] || null + return /** @type {InventoryItemRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ export async function assignReservedInventoryItem(inventoryItemId, taskId, updatedAt, roleKey = 'primary_code') { return withTransaction(async (client) => { const inventoryResult = await client.query( @@ -76,6 +87,7 @@ export async function assignReservedInventoryItem(inventoryItemId, taskId, updat }) } +/** @returns {Promise} */ export async function getInventoryItemById(inventoryItemId) { const result = await query( `${INVENTORY_ITEM_SELECT} @@ -84,9 +96,10 @@ export async function getInventoryItemById(inventoryItemId) { [Number(inventoryItemId)], ) - return result.rows[0] || null + return /** @type {InventoryItemRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) { return withTransaction(async (client) => { await client.query( @@ -111,6 +124,7 @@ export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) { }) } +/** @returns {Promise} */ export async function markInventoryItemConsumed(inventoryItemId, reason, consumedAt) { return withTransaction(async (client) => { await client.query( @@ -139,6 +153,8 @@ export async function markInventoryItemConsumed(inventoryItemId, reason, consume }) } +/** @returns {Promise} */ +/** @param {InventoryListQueryInput} [queryInput] */ export async function listInventoryItems({ page = 1, pageSize = 20, @@ -146,7 +162,7 @@ export async function listInventoryItems({ credentialType = '', status = '', batchNo = '', -} = {}) { +} = /** @type {InventoryListQueryInput} */ ({})) { const offset = (page - 1) * pageSize const filters = [] const params = [] @@ -188,16 +204,18 @@ export async function listInventoryItems({ ) return { - items: itemsResult.rows, + items: /** @type {InventoryItemRow[]} */ (itemsResult.rows), total: Number(totalResult.rows[0]?.total || 0), } } +/** @returns {Promise} */ +/** @param {InventorySkuSuggestionQueryInput} [queryInput] */ export async function listInventorySkuSuggestions({ credentialType = '', keyword = '', limit = 50, -} = {}) { +} = /** @type {InventorySkuSuggestionQueryInput} */ ({})) { const filters = [] const params = [] @@ -238,9 +256,10 @@ export async function listInventorySkuSuggestions({ params, ) - return result.rows + return /** @type {InventorySkuSuggestionRow[]} */ (result.rows) } +/** @param {InventoryCreateItemInput[]} rows */ export async function createInventoryItems(rows) { let created = 0 @@ -284,6 +303,7 @@ export async function createInventoryItems(rows) { return created } +/** @returns {Promise} */ export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) { return withTransaction(async (client) => { await client.query( diff --git a/apps/backend/src/repositories/order-item-repo.js b/apps/backend/src/repositories/order-item-repo.js index 8e50ff2a..e3e61cfa 100644 --- a/apps/backend/src/repositories/order-item-repo.js +++ b/apps/backend/src/repositories/order-item-repo.js @@ -1,5 +1,11 @@ +// @ts-check + import { query } from '../db/client.js' +/** @typedef {import('../types/repository-inputs.js').OrderItemReplaceInput} OrderItemReplaceInput */ +/** @typedef {import('../types/repository-rows.js').OrderItemRow} OrderItemRow */ + +/** @returns {Promise} */ export async function listOrderItemsByOrderId(orderId) { const result = await query( ` @@ -11,9 +17,11 @@ export async function listOrderItemsByOrderId(orderId) { [Number(orderId)], ) - return result.rows + return /** @type {OrderItemRow[]} */ (result.rows) } +/** @returns {Promise} */ +/** @param {OrderItemReplaceInput[]} items */ export async function replaceOrderItems(orderId, items) { await query('DELETE FROM order_items WHERE order_id = $1', [Number(orderId)]) @@ -47,7 +55,8 @@ export async function replaceOrderItems(orderId, items) { return listOrderItemsByOrderId(orderId) } +/** @returns {Promise} */ export async function getOrderItemById(orderItemId) { const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [Number(orderItemId)]) - return result.rows[0] || null + return /** @type {OrderItemRow | null} */ (result.rows[0] || null) } diff --git a/apps/backend/src/repositories/order-repo.js b/apps/backend/src/repositories/order-repo.js index ed73e8b6..41576856 100644 --- a/apps/backend/src/repositories/order-repo.js +++ b/apps/backend/src/repositories/order-repo.js @@ -1,5 +1,14 @@ +// @ts-check + import { query } from '../db/client.js' +/** @typedef {import('../types/repository-inputs.js').OrderUpsertInput} OrderUpsertInput */ +/** @typedef {import('../types/repository-inputs.js').OrderListQueryInput} OrderListQueryInput */ +/** @typedef {import('../types/repository-rows.js').OrderListQueryResult} OrderListQueryResult */ +/** @typedef {import('../types/repository-rows.js').OrderListRow} OrderListRow */ +/** @typedef {import('../types/repository-rows.js').OrderRow} OrderRow */ + +/** @returns {Promise} */ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform, shopId = '', platformOrderId }) { const result = await query( ` @@ -11,9 +20,11 @@ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform, [provider, platform, shopId, platformOrderId], ) - return result.rows[0] || null + return /** @type {OrderRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {OrderUpsertInput} input */ export async function createOrder(input) { const result = await query( ` @@ -57,9 +68,11 @@ export async function createOrder(input) { ], ) - return result.rows[0] || null + return /** @type {OrderRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {OrderUpsertInput} input */ export async function updateOrder(orderId, input) { const result = await query( ` @@ -101,14 +114,17 @@ export async function updateOrder(orderId, input) { ], ) - return result.rows[0] || null + return /** @type {OrderRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ export async function getOrderById(orderId) { const result = await query('SELECT * FROM orders WHERE id = $1 LIMIT 1', [Number(orderId)]) - return result.rows[0] || null + return /** @type {OrderRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {OrderListQueryInput} [queryInput] */ export async function listOrders({ page = 1, pageSize = 20, @@ -117,7 +133,7 @@ export async function listOrders({ skuCode = '', dateFrom = '', dateTo = '', -} = {}) { +} = /** @type {OrderListQueryInput} */ ({})) { const offset = (page - 1) * pageSize const filters = [] const params = [] @@ -166,7 +182,7 @@ export async function listOrders({ ) return { - items: itemsResult.rows, + items: /** @type {OrderListRow[]} */ (itemsResult.rows), total: Number(totalResult.rows[0]?.total || 0), } } diff --git a/apps/backend/src/repositories/task-inventory-binding-repo.js b/apps/backend/src/repositories/task-inventory-binding-repo.js index fe08237d..3989b0a2 100644 --- a/apps/backend/src/repositories/task-inventory-binding-repo.js +++ b/apps/backend/src/repositories/task-inventory-binding-repo.js @@ -1,5 +1,11 @@ +// @ts-check + import { query } from '../db/client.js' +/** @typedef {import('../types/repository-rows.js').TaskInventoryBindingRow} TaskInventoryBindingRow */ +/** @typedef {import('../types/repository-rows.js').TaskInventoryBindingSummaryRow} TaskInventoryBindingSummaryRow */ + +/** @returns {Promise} */ export async function listTaskInventoryBindingsByTaskId(taskId) { const result = await query( ` @@ -29,9 +35,10 @@ export async function listTaskInventoryBindingsByTaskId(taskId) { [Number(taskId)], ) - return result.rows + return /** @type {TaskInventoryBindingRow[]} */ (result.rows) } +/** @returns {Promise} */ export async function getTaskInventoryBindingById(bindingId) { const result = await query( ` @@ -61,9 +68,10 @@ export async function getTaskInventoryBindingById(bindingId) { [Number(bindingId)], ) - return result.rows[0] || null + return /** @type {TaskInventoryBindingRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) { const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : []) .map((value) => Number(value)) @@ -89,5 +97,5 @@ export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) { [normalizedTaskIds], ) - return result.rows + return /** @type {TaskInventoryBindingSummaryRow[]} */ (result.rows) } diff --git a/apps/backend/src/repositories/task-repo.js b/apps/backend/src/repositories/task-repo.js index 73694763..835cc355 100644 --- a/apps/backend/src/repositories/task-repo.js +++ b/apps/backend/src/repositories/task-repo.js @@ -1,5 +1,14 @@ +// @ts-check + import { query, withTransaction } from '../db/client.js' +/** @typedef {import('../types/repository-inputs.js').TaskCreateInput} TaskCreateInput */ +/** @typedef {import('../types/repository-inputs.js').TaskListQueryInput} TaskListQueryInput */ +/** @typedef {import('../types/repository-inputs.js').TaskTencentContextPatch} TaskTencentContextPatch */ +/** @typedef {import('../types/repository-inputs.js').TaskUpdatePatch} TaskUpdatePatch */ +/** @typedef {import('../types/repository-rows.js').TaskListQueryResult} TaskListQueryResult */ +/** @typedef {import('../types/repository-rows.js').TaskRow} TaskRow */ + const TASK_FIELDS = ` ft.*, ct.id AS primary_claim_token_id, @@ -52,6 +61,7 @@ function buildTaskSelect(extraFields = '') { return `SELECT ${fieldSql} ${TASK_JOINS}` } +/** @returns {Promise} */ export async function listTasksByOrderId(orderId) { const result = await query( `${buildTaskSelect()} @@ -59,9 +69,11 @@ export async function listTasksByOrderId(orderId) { ORDER BY ft.id ASC`, [Number(orderId)], ) - return result.rows + return /** @type {TaskRow[]} */ (result.rows) } +/** @returns {Promise} */ +/** @param {TaskCreateInput} input */ export async function createTask(input) { return withTransaction(async (client) => { const taskResult = await client.query( @@ -139,6 +151,8 @@ export async function createTask(input) { }) } +/** @returns {Promise} */ +/** @param {TaskUpdatePatch} patch */ export async function updateTask(taskId, patch) { const current = await getTaskById(taskId) if (!current) { @@ -212,10 +226,12 @@ export async function updateTask(taskId, patch) { }) } +/** @returns {Promise} */ export async function getTaskById(taskId) { return getTaskByIdWithExecutor(query, taskId) } +/** @returns {Promise} */ export async function findTaskByClaimTokenId(claimTokenId) { const result = await query( `${buildTaskSelect()} @@ -224,9 +240,11 @@ export async function findTaskByClaimTokenId(claimTokenId) { LIMIT 1`, [Number(claimTokenId)], ) - return result.rows[0] || null + return /** @type {TaskRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {TaskListQueryInput} [queryInput] */ export async function listTasks({ page = 1, pageSize = 20, @@ -237,7 +255,7 @@ export async function listTasks({ roleId = '', dateFrom = '', dateTo = '', -} = {}) { +} = /** @type {TaskListQueryInput} */ ({})) { const offset = (page - 1) * pageSize const filters = [] const params = [] @@ -301,11 +319,12 @@ export async function listTasks({ ) return { - items: itemsResult.rows, + items: /** @type {TaskRow[]} */ (itemsResult.rows), total: Number(totalResult.rows[0]?.total || 0), } } +/** @param {TaskTencentContextPatch} patch */ async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, timestamp) { const currentResult = await client.query( 'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1', @@ -399,6 +418,7 @@ async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, ) } +/** @returns {Promise} */ async function getTaskByIdWithExecutor(executor, taskId) { const result = await executor( `${buildTaskSelect()} @@ -406,7 +426,7 @@ async function getTaskByIdWithExecutor(executor, taskId) { LIMIT 1`, [Number(taskId)], ) - return result.rows[0] || null + return /** @type {TaskRow | null} */ (result.rows[0] || null) } function containsTencentPatch(patch = {}) { diff --git a/apps/backend/src/repositories/webhook-event-repo.js b/apps/backend/src/repositories/webhook-event-repo.js index 4aa698e2..27dec964 100644 --- a/apps/backend/src/repositories/webhook-event-repo.js +++ b/apps/backend/src/repositories/webhook-event-repo.js @@ -1,5 +1,15 @@ +// @ts-check + import { query } from '../db/client.js' +/** @typedef {import('../types/repository-inputs.js').WebhookEventCreateInput} WebhookEventCreateInput */ +/** @typedef {import('../types/repository-inputs.js').WebhookEventListQueryInput} WebhookEventListQueryInput */ +/** @typedef {import('../types/repository-inputs.js').WebhookEventUpdatePatch} WebhookEventUpdatePatch */ +/** @typedef {import('../types/repository-rows.js').WebhookEventListQueryResult} WebhookEventListQueryResult */ +/** @typedef {import('../types/repository-rows.js').WebhookEventRow} WebhookEventRow */ + +/** @returns {Promise} */ +/** @param {WebhookEventCreateInput} input */ export async function createWebhookEvent(input) { const result = await query( ` @@ -39,9 +49,11 @@ export async function createWebhookEvent(input) { ], ) - return result.rows[0] || null + return /** @type {WebhookEventRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {WebhookEventUpdatePatch} patch */ export async function updateWebhookEvent(eventId, patch) { const current = await getWebhookEventById(eventId) if (!current) { @@ -62,14 +74,17 @@ export async function updateWebhookEvent(eventId, patch) { [Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)], ) - return result.rows[0] || null + return /** @type {WebhookEventRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ export async function getWebhookEventById(eventId) { const result = await query('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [Number(eventId)]) - return result.rows[0] || null + return /** @type {WebhookEventRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +/** @param {WebhookEventListQueryInput} [queryInput] */ export async function listWebhookEvents({ page = 1, pageSize = 20, @@ -80,7 +95,7 @@ export async function listWebhookEvents({ relatedOrderId = '', dateFrom = '', dateTo = '', -} = {}) { +} = /** @type {WebhookEventListQueryInput} */ ({})) { const offset = (page - 1) * pageSize const filters = [] const params = [] @@ -137,11 +152,12 @@ export async function listWebhookEvents({ ) return { - items: itemsResult.rows, + items: /** @type {WebhookEventRow[]} */ (itemsResult.rows), total: Number(totalResult.rows[0]?.total || 0), } } +/** @returns {Promise} */ export async function listWebhookEventsByOrderId(orderId) { const result = await query( ` @@ -153,5 +169,5 @@ export async function listWebhookEventsByOrderId(orderId) { [Number(orderId)], ) - return result.rows + return /** @type {WebhookEventRow[]} */ (result.rows) } diff --git a/apps/backend/src/routes/admin/dashboard.js b/apps/backend/src/routes/admin/dashboard.js index 688efd3f..5924be92 100644 --- a/apps/backend/src/routes/admin/dashboard.js +++ b/apps/backend/src/routes/admin/dashboard.js @@ -1,6 +1,6 @@ import { Router } from 'express' -import { getAdminDashboardSummary } from '../../services/admin/admin-service.js' +import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js' import { createJsonHandler } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js index 6c8fd7e2..921567f0 100644 --- a/apps/backend/src/routes/admin/inventory.js +++ b/apps/backend/src/routes/admin/inventory.js @@ -2,12 +2,14 @@ import { Router } from 'express' import { createAdminInventoryItem, - getAdminInventoryItems, - getAdminInventorySkuSuggestions, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem, -} from '../../services/admin/admin-service.js' +} from '../../services/admin/admin-write-service.js' +import { + getAdminInventoryItems, + getAdminInventorySkuSuggestions, +} from '../../services/admin/admin-read-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/message-deliveries.js b/apps/backend/src/routes/admin/message-deliveries.js index 1637d387..9c3061be 100644 --- a/apps/backend/src/routes/admin/message-deliveries.js +++ b/apps/backend/src/routes/admin/message-deliveries.js @@ -1,6 +1,6 @@ import { Router } from 'express' -import { getAdminMessageDeliveries } from '../../services/admin/admin-service.js' +import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js' import { createJsonHandler } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/orders.js b/apps/backend/src/routes/admin/orders.js index ea565dd0..b192d475 100644 --- a/apps/backend/src/routes/admin/orders.js +++ b/apps/backend/src/routes/admin/orders.js @@ -1,6 +1,6 @@ import { Router } from 'express' -import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-service.js' +import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js' import { createJsonHandler } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 41f2b767..67758908 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -6,7 +6,7 @@ import { lookupAdminFulfillmentBindingOrder, updateAdminAgisoShopConfigs, updateAdminFulfillmentBindingConfigs, -} from '../../services/admin/admin-service.js' +} from '../../services/admin/admin-platform-config-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js index 0520ecfa..785041af 100644 --- a/apps/backend/src/routes/admin/tasks.js +++ b/apps/backend/src/routes/admin/tasks.js @@ -2,18 +2,20 @@ import { Router } from 'express' import { closeAdminTask, - confirmAdminTaskAssistedRole, completeAdminTaskManualDispatch, - getAdminTaskDetail, - getAdminTaskScreenshotPath, - getAdminTasks, + confirmAdminTaskAssistedRole, markAdminTaskManualReview, regenerateAdminTaskClaimLink, redeemAdminTaskAssisted, releaseAdminTaskInventoryBinding, releaseAdminTaskInventory, retryAdminTask, -} from '../../services/admin/admin-service.js' +} from '../../services/admin/admin-write-service.js' +import { + getAdminTaskDetail, + getAdminTaskScreenshotPath, + getAdminTasks, +} from '../../services/admin/admin-read-service.js' import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() diff --git a/apps/backend/src/routes/admin/webhook-events.js b/apps/backend/src/routes/admin/webhook-events.js index 155a2e5d..72a0e528 100644 --- a/apps/backend/src/routes/admin/webhook-events.js +++ b/apps/backend/src/routes/admin/webhook-events.js @@ -1,10 +1,12 @@ import { Router } from 'express' +import { + replayAdminWebhookEvent, +} from '../../services/admin/admin-write-service.js' import { getAdminWebhookEventDetail, getAdminWebhookEvents, - replayAdminWebhookEvent, -} from '../../services/admin/admin-service.js' +} from '../../services/admin/admin-read-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() diff --git a/apps/backend/src/services/admin/admin-dashboard-service.js b/apps/backend/src/services/admin/admin-dashboard-service.js new file mode 100644 index 00000000..34037b04 --- /dev/null +++ b/apps/backend/src/services/admin/admin-dashboard-service.js @@ -0,0 +1,52 @@ +// @ts-check + +import { query } from '../../db/client.js' +import { nowIso } from '../../utils/time.js' + +export async function getAdminDashboardSummary() { + const todayPrefix = nowIso().slice(0, 10) + const result = await query( + ` + SELECT + (SELECT COUNT(*)::int FROM orders WHERE to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1) AS today_orders, + ( + SELECT COUNT(*)::int + FROM fulfillment_tasks + WHERE task_status IN ('paid', 'link_generated') + OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired')) + ) AS paid_pending_claim, + ( + SELECT COUNT(*)::int + FROM fulfillment_tasks + WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming') + ) AS claiming_tasks, + ( + SELECT COUNT(*)::int + FROM fulfillment_tasks + WHERE task_status = 'redeemed' + AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1 + ) AS redeemed_today, + ( + SELECT COUNT(*)::int + FROM fulfillment_tasks + WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory') + ) AS abnormal_tasks, + ( + SELECT COUNT(DISTINCT sku_code)::int + FROM inventory_items + WHERE status = 'available' + ) AS sku_with_inventory + `, + [todayPrefix], + ) + const summary = result.rows[0] || {} + + return { + todayOrders: Number(summary?.today_orders || 0), + paidPendingClaim: Number(summary?.paid_pending_claim || 0), + claimingTasks: Number(summary?.claiming_tasks || 0), + redeemedToday: Number(summary?.redeemed_today || 0), + abnormalTasks: Number(summary?.abnormal_tasks || 0), + skuWithInventory: Number(summary?.sku_with_inventory || 0), + } +} diff --git a/apps/backend/src/services/admin/admin-message-delivery-service.js b/apps/backend/src/services/admin/admin-message-delivery-service.js new file mode 100644 index 00000000..c6ab1428 --- /dev/null +++ b/apps/backend/src/services/admin/admin-message-delivery-service.js @@ -0,0 +1,53 @@ +// @ts-check + +import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js' +import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' + +export async function getAdminMessageDeliveries(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listMessageDeliveries({ + page, + pageSize, + provider: String(query.provider || '').trim(), + platform: String(query.platform || '').trim(), + status: String(query.status || '').trim(), + shopId: String(query.shopId || '').trim(), + platformOrderId: String(query.platformOrderId || '').trim(), + taskNo: String(query.taskNo || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map(mapAdminMessageDeliveryListItem), + pagination: { page, pageSize, total }, + } +} + +function mapAdminMessageDeliveryListItem(item) { + return { + deliveryId: Number(item.id), + provider: String(item.provider || '').trim(), + platform: String(item.platform || '').trim(), + shopId: String(item.shop_id || '').trim(), + shopName: String(item.shop_name || '').trim(), + channel: String(item.channel || '').trim(), + orderId: item.order_id ? Number(item.order_id) : null, + taskId: item.task_id ? Number(item.task_id) : null, + taskNo: String(item.task_no || '').trim(), + taskStatus: String(item.task_status || '').trim(), + platformOrderId: String(item.platform_order_id || '').trim(), + recipientKey: String(item.recipient_key || '').trim(), + messageContent: String(item.message_content || '').trim(), + claimUrl: String(item.claim_url || '').trim(), + status: String(item.status || '').trim(), + requestUrl: String(item.request_url || '').trim(), + responseStatus: Number(item.response_status || 0), + response: safeParseJson(item.response_json), + errorMessage: String(item.error_message || '').trim(), + sentAt: item.sent_at || null, + createdAt: item.created_at, + updatedAt: item.updated_at, + } +} diff --git a/apps/backend/src/services/admin/admin-platform-config-service.js b/apps/backend/src/services/admin/admin-platform-config-service.js new file mode 100644 index 00000000..d05b5606 --- /dev/null +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -0,0 +1,491 @@ +// @ts-check + +import { query } from '../../db/client.js' +import { + getAgisoShopConfig, + getAgisoShopConfigMap, + getAgisoShopsFilePath, + saveAgisoShopConfigMap, +} from '../platforms/agiso/shop-config-service.js' +import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' +import { + getOrderFulfillmentBindingConfigs, + getOrderFulfillmentBindingsFilePath, + saveOrderFulfillmentBindingConfigs, +} from '../order/fulfillment-binding-config-service.js' +import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' +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' + +export async function getAdminAgisoShopConfigs() { + const configMap = getAgisoShopConfigMap() + const rowsResult = await query( + ` + SELECT + shop_id, + MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name, + MAX(created_at) AS latest_seen_at, + COUNT(*)::int AS webhook_event_count + FROM webhook_events + WHERE provider = 'agiso' AND trim(shop_id) != '' + GROUP BY shop_id + ORDER BY latest_seen_at DESC, shop_id DESC + `, + ) + const rows = rowsResult.rows + + return { + filePath: getAgisoShopsFilePath(), + shops: Object.entries(configMap) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([shopId, config]) => ({ + shopId, + shopName: String(config.shopName || '').trim(), + accessToken: String(config.accessToken || '').trim(), + accessTokenMasked: maskSecret(config.accessToken), + enabled: typeof config.enabled === 'boolean' ? config.enabled : null, + messageTemplate: String(config.messageTemplate || '').trim(), + appSecretConfigured: Boolean(String(config.appSecret || '').trim()), + apiVersion: String(config.apiVersion || '').trim(), + sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), + })), + observedShops: rows.map((row) => ({ + shopId: String(row.shop_id || '').trim(), + detectedShopName: String(row.detected_shop_name || '').trim(), + displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name), + latestSeenAt: row.latest_seen_at || null, + webhookEventCount: Number(row.webhook_event_count || 0), + configured: Boolean(configMap[String(row.shop_id || '').trim()]), + })), + } +} + +export function updateAdminAgisoShopConfigs(payload = {}) { + const rawItems = Array.isArray(payload.shops) ? payload.shops : [] + const nextMap = {} + + for (const item of rawItems) { + const shopId = String(item?.shopId || '').trim() + if (!shopId) { + continue + } + + const next = {} + const shopName = String(item?.shopName || '').trim() + const accessToken = String(item?.accessToken || '').trim() + const messageTemplate = String(item?.messageTemplate || '').trim() + const appSecret = String(item?.appSecret || '').trim() + const apiVersion = String(item?.apiVersion || '').trim() + const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim() + + if (shopName) { + next.shopName = shopName + } + if (accessToken) { + next.accessToken = accessToken + } + if (messageTemplate) { + next.messageTemplate = messageTemplate + } + if (appSecret) { + next.appSecret = appSecret + } + if (apiVersion) { + next.apiVersion = apiVersion + } + if (sendMessageEndpoint) { + next.sendMessageEndpoint = sendMessageEndpoint + } + if (typeof item?.enabled === 'boolean') { + next.enabled = item.enabled + } + + if (!next.accessToken) { + continue + } + + nextMap[shopId] = next + } + + const saved = saveAgisoShopConfigMap(nextMap) + + return { + filePath: getAgisoShopsFilePath(), + shops: Object.entries(saved) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([shopId, config]) => ({ + shopId, + shopName: String(config.shopName || '').trim(), + accessToken: String(config.accessToken || '').trim(), + accessTokenMasked: maskSecret(config.accessToken), + enabled: typeof config.enabled === 'boolean' ? config.enabled : null, + messageTemplate: String(config.messageTemplate || '').trim(), + appSecretConfigured: Boolean(String(config.appSecret || '').trim()), + apiVersion: String(config.apiVersion || '').trim(), + sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), + })), + } +} + +export async function getAdminFulfillmentBindingConfigs() { + const bindings = getOrderFulfillmentBindingConfigs() + const rowsResult = await query( + ` + SELECT + o.provider, + o.platform, + o.shop_id, + MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name, + COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id, + COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code, + COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name, + MAX(oi.created_at) AS latest_seen_at, + COUNT(*)::int AS order_item_count + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE + COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != '' + OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != '' + OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != '' + GROUP BY + o.provider, + o.platform, + o.shop_id, + external_item_id, + external_sku_code, + external_sku_name + ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC + LIMIT 200 + `, + ) + + return { + filePath: getOrderFulfillmentBindingsFilePath(), + bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), + observedProducts: rowsResult.rows.map((row) => mapAdminObservedProductItem({ + provider: String(row.provider || '').trim(), + platform: String(row.platform || '').trim(), + shopId: String(row.shop_id || '').trim(), + shopName: String(row.shop_name || '').trim(), + externalItemId: String(row.external_item_id || '').trim(), + externalSkuCode: String(row.external_sku_code || '').trim(), + externalSkuName: String(row.external_sku_name || '').trim(), + latestSeenAt: row.latest_seen_at || null, + orderItemCount: Number(row.order_item_count || 0), + }, bindings)), + } +} + +export async function lookupAdminFulfillmentBindingOrder(payload = {}) { + const provider = String(payload.provider || 'agiso').trim() || 'agiso' + const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' + const shopId = String(payload.shopId || '').trim() + const platformOrderId = String(payload.platformOrderId || '').trim() + + if (!shopId) { + throw createHttpError('请先填写店铺 ID', { + statusCode: 400, + errorCode: 'admin_fulfillment_lookup_missing_shop_id', + }) + } + + if (!platformOrderId) { + throw createHttpError('请先填写平台订单号', { + statusCode: 400, + errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', + }) + } + + if (provider !== 'agiso' || platform !== 'xianyu') { + throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { + statusCode: 400, + errorCode: 'admin_fulfillment_lookup_platform_not_supported', + }) + } + + const detailResult = await enrichAgisoXianyuTradeOrder({ + provider, + platform, + shopId, + shopName: '', + platformOrderId, + orderStatus: 'created', + payStatus: 'unpaid', + buyerId: '', + buyerName: '', + receiverContact: '', + totalAmount: 0, + currency: 'CNY', + paidAt: null, + rawPayload: {}, + items: [], + }, { + requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, + }) + + const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} + const items = Array.isArray(detail.items) ? detail.items : [] + + if (items.length === 0) { + const detailReason = String(detailResult?.reason || '').trim() + const detailMessage = String(detailResult?.errorMessage || '').trim() + let message = detailMessage + + if (!message && detailReason === 'missing_config') { + message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' + } + + if (!message) { + message = `未查询到订单 ${platformOrderId} 的商品明细` + } + + throw createHttpError(message, { + statusCode: 404, + errorCode: 'admin_fulfillment_lookup_order_items_not_found', + }) + } + + const bindings = getOrderFulfillmentBindingConfigs() + const resolvedShopName = pickFirstNonEmpty([ + detail.shopName, + getAgisoShopConfig(shopId)?.shopName, + ]) + + return { + order: { + provider, + platform, + shopId, + shopName: resolvedShopName, + platformOrderId, + buyerName: String(detail.buyerName || '').trim(), + totalAmountFen: Number(detail.totalAmount || 0), + totalAmount: formatFenToAmount(detail.totalAmount), + paidAt: detail.paidAt || null, + enriched: Boolean(detailResult?.enriched), + enrichReason: String(detailResult?.reason || '').trim(), + errorMessage: String(detailResult?.errorMessage || '').trim(), + }, + items: items.map((item, index) => { + const observed = { + provider, + platform, + shopId, + shopName: resolvedShopName, + externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), + externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]), + externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), + latestSeenAt: null, + orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), + } + + return { + lineId: [ + platformOrderId, + index + 1, + observed.externalSkuCode || 'na', + observed.externalItemId || 'na', + ].join(':'), + itemTitle: pickFirstNonEmpty([ + item?.skuName, + item?.externalSkuName, + item?.externalSkuCode, + item?.externalItemId, + ]), + quantity: observed.orderItemCount, + ...mapAdminObservedProductItem(observed, bindings), + } + }), + } +} + +export async function updateAdminFulfillmentBindingConfigs(payload = {}) { + const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] + await validateAdminFulfillmentBindingConfigs(bindingsInput) + const saved = saveOrderFulfillmentBindingConfigs(bindingsInput) + await syncConfiguredFulfillmentBindings() + + return { + filePath: getOrderFulfillmentBindingsFilePath(), + bindings: saved.map(mapAdminFulfillmentBindingConfigItem), + } +} + +async function validateAdminFulfillmentBindingConfigs(bindings = []) { + if (!Array.isArray(bindings)) { + throw createHttpError('履约配置格式不正确', { + statusCode: 400, + errorCode: 'admin_fulfillment_bindings_invalid_payload', + }) + } + + const seenKeys = new Set() + + for (const [index, rawBinding] of bindings.entries()) { + if (!isPlainObject(rawBinding)) { + throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { + statusCode: 400, + errorCode: 'admin_fulfillment_bindings_invalid_item', + }) + } + + const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' + const platform = String(rawBinding.platform || '').trim() + const shopId = String(rawBinding.shopId || '').trim() + const skuCode = String(rawBinding.skuCode || '').trim() + const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' + const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} + const externalItemId = String(match.externalItemId || '').trim() + const externalSkuCode = String(match.externalSkuCode || '').trim() + const externalSkuName = String(match.externalSkuName || '').trim() + + if (!skuCode) { + throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { + statusCode: 400, + errorCode: 'admin_fulfillment_bindings_missing_sku_code', + }) + } + + if (!externalItemId && !externalSkuCode && !externalSkuName) { + throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { + statusCode: 400, + errorCode: 'admin_fulfillment_bindings_missing_match_condition', + }) + } + + const profile = await getFulfillmentProfileByKey(profileKey) + if (!profile) { + throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, { + statusCode: 400, + errorCode: 'admin_fulfillment_bindings_invalid_profile_key', + }) + } + + const uniqueKey = [ + provider, + platform, + shopId, + externalItemId, + externalSkuCode, + normalizeProductName(externalSkuName), + skuCode, + ].join('::') + + if (seenKeys.has(uniqueKey)) { + throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { + statusCode: 409, + errorCode: 'admin_fulfillment_bindings_duplicate_rule', + }) + } + + seenKeys.add(uniqueKey) + } +} + +function mapAdminFulfillmentBindingConfigItem(item) { + const match = item?.match || {} + return { + provider: String(item?.provider || '').trim(), + platform: String(item?.platform || '').trim(), + shopId: String(item?.shopId || '').trim(), + skuCode: String(item?.skuCode || '').trim(), + skuName: String(item?.skuName || '').trim(), + profileKey: String(item?.profileKey || '').trim(), + enabled: item?.enabled !== false, + priority: Number(item?.priority || 100), + config: item?.config || {}, + match: { + externalSkuCode: String(match.externalSkuCode || '').trim(), + externalItemId: String(match.externalItemId || '').trim(), + externalSkuName: String(match.externalSkuName || '').trim(), + config: match.config || {}, + }, + } +} + +function matchesObservedProduct(binding, observed) { + const provider = String(binding?.provider || '').trim() + const platform = String(binding?.platform || '').trim() + const shopId = String(binding?.shopId || '').trim() + const match = binding?.match || {} + const externalSkuCode = String(match.externalSkuCode || '').trim() + const externalItemId = String(match.externalItemId || '').trim() + const externalSkuName = String(match.externalSkuName || '').trim() + + if (provider && provider !== String(observed?.provider || '').trim()) { + return false + } + + if (platform && platform !== String(observed?.platform || '').trim()) { + return false + } + + if (shopId && shopId !== String(observed?.shopId || '').trim()) { + return false + } + + return ( + (externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim()) + || (externalItemId && externalItemId === String(observed?.externalItemId || '').trim()) + || (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim()) + ) +} + +function mapAdminObservedProductItem(item, bindings = []) { + const matchedBinding = findMatchingObservedBinding(bindings, item) + + return { + provider: String(item?.provider || '').trim(), + platform: String(item?.platform || '').trim(), + shopId: String(item?.shopId || '').trim(), + shopName: String(item?.shopName || '').trim(), + externalItemId: String(item?.externalItemId || '').trim(), + externalSkuCode: String(item?.externalSkuCode || '').trim(), + externalSkuName: String(item?.externalSkuName || '').trim(), + latestSeenAt: item?.latestSeenAt || null, + orderItemCount: Number(item?.orderItemCount || 0), + configured: Boolean(matchedBinding), + matchedBinding: matchedBinding + ? { + skuCode: String(matchedBinding.skuCode || '').trim(), + skuName: String(matchedBinding.skuName || '').trim(), + profileKey: String(matchedBinding.profileKey || '').trim(), + } + : null, + } +} + +function findMatchingObservedBinding(bindings, observed) { + return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null +} + +function maskSecret(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + if (normalized.length <= 10) { + return `${normalized.slice(0, 2)}****${normalized.slice(-2)}` + } + + return `${normalized.slice(0, 6)}****${normalized.slice(-6)}` +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function pickFirstNonEmpty(values) { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} diff --git a/apps/backend/src/services/admin/admin-read-helpers.js b/apps/backend/src/services/admin/admin-read-helpers.js new file mode 100644 index 00000000..e01571f8 --- /dev/null +++ b/apps/backend/src/services/admin/admin-read-helpers.js @@ -0,0 +1,953 @@ +// @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' + +/** @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} + */ +export async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { + const headers = normalizeRecord(safeParseJson(item.headers_json)) + const query = normalizeRecord(safeParseJson(item.query_json)) + const body = normalizeRecord(safeParseJson(item.body_json)) + const payload = extractWebhookPayload(body) + const itemSources = extractWebhookItemSources(payload) + const rawAmount = pickFirstNonEmpty([ + payload.total_fee, + payload.totalFee, + payload.TotalFee, + payload.pay_fee, + payload.payFee, + payload.Payment, + payload.payment, + payload.buy_amount, + payload.buyAmount, + ]) + const totalAmountFen = parseAmountToFen(rawAmount) + const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0 + + const mapped = { + eventId: item.id, + provider: item.provider || 'agiso', + platform: item.platform, + platformRaw: pickFirstNonEmpty([ + query.fromPlatform, + query.from_platform, + body.fromPlatform, + body.from_platform, + payload.fromPlatform, + payload.FromPlatform, + payload.platform, + payload.Platform, + ]), + shopId: item.shop_id || pickFirstNonEmpty([ + payload.PlatformUserId, + payload.platformUserId, + payload.platform_user_id, + payload.seller_id, + payload.sellerId, + payload.SellerId, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]), + shopName: resolveDisplayShopName( + item.provider, + item.shop_id || pickFirstNonEmpty([ + payload.PlatformUserId, + payload.platformUserId, + payload.platform_user_id, + payload.seller_id, + payload.sellerId, + payload.SellerId, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]), + item.shop_name || pickFirstNonEmpty([ + payload.shopName, + payload.shop_name, + payload.ShopName, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]), + ), + eventType: item.event_type, + eventKey: item.event_key, + signatureValid: Boolean(item.signature_valid), + processed: Boolean(item.processed), + processError: item.process_error, + relatedOrderId: item.related_order_id, + createdAt: item.created_at, + platformOrderId: resolveAgisoTradePlatformOrderId(payload), + buyerId: pickFirstNonEmpty([ + payload.buyer_id, + payload.buyerId, + payload.BuyerId, + payload.openid, + payload.BuyerOpenUid, + payload.buyer_open_uid, + payload.buyerOpenUid, + ]), + buyerName: pickFirstNonEmpty([ + payload.buyer_name, + payload.buyerName, + payload.BuyerName, + payload.nick, + payload.BuyerNick, + payload.buyer_nick, + payload.buyerNick, + ]), + totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '', + totalAmountFen, + currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY', + itemCount: itemSources.length, + taskCount, + messageText: pickFirstNonEmpty([ + payload.MsgTypeDes, + payload.msgTypeDes, + payload.msg_type_desc, + payload.message, + payload.msg, + ]), + requestTimestamp: pickFirstNonEmpty([query.timestamp]), + requestSign: pickFirstNonEmpty([query.sign]), + aopic: pickFirstNonEmpty([query.aopic]), + sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]), + sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]), + userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]), + } + + if (!includeRaw) { + return mapped + } + + return { + ...mapped, + headers, + query, + body, + payload, + } +} + +/** + * @param {InventoryItemRow} item + * @returns {Promise} + */ +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) + + return { + inventoryItemId: item.id, + skuCode: item.sku_code, + batchNo: item.batch_no, + credentialType: item.credential_type || 'tencent_code', + displayValue: item.display_value, + status: item.status, + 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, + invalidReason: item.invalid_reason || '', + deliveredAt: item.delivered_at, + createdAt: item.created_at, + updatedAt: item.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 +} + +export async function getRequiredInventoryItem(inventoryItemId) { + const inventoryItem = await getInventoryItemById(Number(inventoryItemId)) + + if (!inventoryItem) { + throw createHttpError('库存项不存在', { + statusCode: 404, + errorCode: 'admin_inventory_not_found', + }) + } + + 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} + */ +export async function mapAdminOrderListItem(item) { + const [tasks, orderItems] = await Promise.all([ + listTasksByOrderId(item.id), + listOrderItemsByOrderId(item.id), + ]) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) + const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap) + const itemSummary = summarizeOrderItems(orderItems) + + return { + orderId: item.id, + provider: item.provider || 'agiso', + platform: item.platform, + shopId: item.shop_id || '', + shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name), + platformOrderId: item.platform_order_id, + orderStatus: item.order_status, + payStatus: item.pay_status, + buyerId: item.buyer_id || '', + buyerName: item.buyer_name, + receiverContact: item.receiver_contact || '', + totalAmount: formatFenToAmount(item.total_amount), + totalAmountFen: normalizeFen(item.total_amount), + currency: item.currency, + paidAt: item.paid_at, + createdAt: item.created_at, + updatedAt: item.updated_at, + itemCount: orderItems.length, + totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0), + itemSummary, + taskCount: Number(item.task_count || tasks.length || 0), + systemBindingStatus: bindingSummary.systemBindingStatus, + userBindingStatus: bindingSummary.userBindingStatus, + systemBoundTaskCount: bindingSummary.systemBoundTaskCount, + completedBindingTaskCount: bindingSummary.completedBindingTaskCount, + totalBindingCount: bindingSummary.totalBindingCount, + reservedBindingCount: bindingSummary.reservedBindingCount, + consumedBindingCount: bindingSummary.consumedBindingCount, + releasedBindingCount: bindingSummary.releasedBindingCount, + } +} + +export function summarizeOrderItems(items) { + const normalizedItems = Array.isArray(items) ? items : [] + + if (normalizedItems.length === 0) { + return '' + } + + const [firstItem] = normalizedItems + const firstLabel = resolveOrderItemTitle(firstItem) + || String(firstItem?.sku_name || firstItem?.sku_code || '').trim() + + if (normalizedItems.length === 1) { + return firstLabel + } + + 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) +} + +function extractWebhookItemSources(payload) { + const normalizedPayload = normalizeRecord(payload) + const candidates = [ + normalizedPayload.orders, + normalizedPayload.Orders, + normalizedPayload.items, + normalizedPayload.Items, + normalizedPayload.order_items, + ] + + for (const candidate of candidates) { + if (Array.isArray(candidate) && candidate.length > 0) { + return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) + } + } + + if ( + pickFirstNonEmpty([ + normalizedPayload.item_id, + normalizedPayload.itemId, + normalizedPayload.ItemId, + normalizedPayload.num_iid, + normalizedPayload.numIid, + normalizedPayload.NumIid, + ]) + ) { + return [normalizedPayload] + } + + return [] +} + +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 '' +} + +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 new file mode 100644 index 00000000..13d16970 --- /dev/null +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -0,0 +1,407 @@ +// @ts-check + +import { buildClaimUrl } from '../claim/claim-service.js' +import { getClaimTokenById } from '../../repositories/claim-token-repo.js' +import { getInventoryItemById, listInventoryItems, listInventorySkuSuggestions } from '../../repositories/inventory-repo.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { getOrderById, listOrders } from '../../repositories/order-repo.js' +import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js' +import { listTaskInventoryBindingsByTaskId } from '../../repositories/task-inventory-binding-repo.js' +import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' +import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' +import { createHttpError } from '../../utils/http.js' +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, + mapAdminInventoryListItem, + mapAdminOrderListItem, + mapAdminTaskEvent, + mapAdminTaskInventoryBinding, + mapAdminTaskListItem, + mapAdminTaskSummary, + mapAdminWebhookEvent, + mapManualDispatchContext, + parseTaskContext, + parseTaskState, + mapRedeemResolutionContext, + resolveOrderItemDeliveryMode, + resolveOrderItemTitle, + resolveAdminTaskScreenshotUrl, + resolveDisplayShopName, + summarizeOrderItems, +} from './admin-read-helpers.js' + +/** @typedef {import('../../types/admin-read-models.js').AdminInventoryListResponse} AdminInventoryListResponse */ +/** @typedef {import('../../types/admin-read-models.js').AdminInventorySkuSuggestionResponse} AdminInventorySkuSuggestionResponse */ +/** @typedef {import('../../types/admin-read-models.js').AdminOrderListResponse} AdminOrderListResponse */ +/** @typedef {import('../../types/admin-read-models.js').AdminTaskListResponse} AdminTaskListResponse */ +/** @typedef {import('../../types/admin-read-models.js').AdminWebhookEventListResponse} AdminWebhookEventListResponse */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminInventoryListQueryInput} AdminInventoryListQueryInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminInventorySkuSuggestionQueryInput} AdminInventorySkuSuggestionQueryInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminOrderListQueryInput} AdminOrderListQueryInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminTaskListQueryInput} AdminTaskListQueryInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminWebhookEventListQueryInput} AdminWebhookEventListQueryInput */ + +/** @returns {Promise} */ +/** @param {AdminOrderListQueryInput} [query] */ +export async function getAdminOrders(query = /** @type {AdminOrderListQueryInput} */ ({})) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listOrders({ + page, + pageSize, + platformOrderId: String(query.platformOrderId || '').trim(), + payStatus: String(query.payStatus || '').trim(), + skuCode: String(query.skuCode || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: await Promise.all(items.map((item) => mapAdminOrderListItem(item))), + pagination: { page, pageSize, total }, + } +} + +/** @param {AdminEntityIdInput} orderId */ +export async function getAdminOrderDetail(orderId) { + const order = await getOrderById(Number(orderId)) + + if (!order) { + throw createHttpError('订单不存在', { + statusCode: 404, + errorCode: 'admin_order_not_found', + }) + } + + const [items, tasks, webhookEvents] = await Promise.all([ + listOrderItemsByOrderId(order.id), + listTasksByOrderId(order.id), + listWebhookEventsByOrderId(order.id), + ]) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) + const itemSummary = summarizeOrderItems(items) + + return { + order: { + orderId: order.id, + provider: order.provider || 'agiso', + platform: order.platform, + shopId: order.shop_id || '', + shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name), + platformOrderId: order.platform_order_id, + orderStatus: order.order_status, + payStatus: order.pay_status, + buyerId: order.buyer_id, + buyerName: order.buyer_name, + receiverContact: order.receiver_contact, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), + currency: order.currency, + paidAt: order.paid_at, + createdAt: order.created_at, + updatedAt: order.updated_at, + itemSummary, + rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' + ? order.raw_payload_json + : JSON.parse(String(order.raw_payload_json || '{}')), + bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), + agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), + }, + items: items.map((item) => ({ + orderItemId: item.id, + skuCode: item.sku_code, + skuName: item.sku_name, + itemTitle: resolveOrderItemTitle(item), + quantity: item.quantity, + deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), + spec: item.spec_json && typeof item.spec_json === 'object' + ? item.spec_json + : JSON.parse(String(item.spec_json || '{}')), + })), + tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))), + webhookEvents: webhookEvents.map((event) => ({ + eventId: event.id, + provider: event.provider || 'agiso', + platform: event.platform, + shopId: event.shop_id || '', + shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name), + eventType: event.event_type, + eventKey: event.event_key, + signatureValid: Boolean(event.signature_valid), + processed: Boolean(event.processed), + processError: event.process_error, + createdAt: event.created_at, + })), + } +} + +/** @returns {Promise} */ +/** @param {AdminTaskListQueryInput} [query] */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminTasks( + query = /** @type {AdminTaskListQueryInput} */ ({}), + session = null, +) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listTasks({ + page, + pageSize, + status: String(query.status || '').trim(), + platformOrderId: String(query.platformOrderId || '').trim(), + taskNo: String(query.taskNo || '').trim(), + skuCode: String(query.skuCode || '').trim(), + roleId: String(query.roleId || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id)) + const viewerContext = createAdminViewerContext(session) + + return { + items: items.map((item) => mapAdminTaskListItem( + item, + getTaskBindingSummary(taskBindingSummaryMap, item.id), + viewerContext, + )), + pagination: { page, pageSize, total }, + } +} + +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminTaskDetail(taskId, session = null) { + const task = await getTaskById(Number(taskId)) + + if (!task) { + throw createHttpError('任务不存在', { + statusCode: 404, + errorCode: 'admin_task_not_found', + }) + } + + const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([ + getOrderById(task.order_id), + getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null), + getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), + listTaskEventsByTaskId(task.id), + listTaskInventoryBindingsByTaskId(task.id), + ]) + const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] + const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null + const taskContext = parseTaskContext(task) + const taskState = parseTaskState(task) + const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings) + const viewerContext = createAdminViewerContext(session) + const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext)) + const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' + const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) + + return { + task: mapAdminTaskListItem({ + ...task, + sku_code: orderItem?.sku_code || '', + sku_name: orderItem?.sku_name || '', + inventory_display_value: inventoryItem?.display_value || '', + claim_token: claimToken?.token || '', + }, taskBindingSummary, viewerContext), + order: order + ? { + orderId: order.id, + provider: order.provider || 'agiso', + platform: order.platform, + shopId: order.shop_id || '', + shopName: order.shop_name || '', + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + } + : null, + orderItem: orderItem + ? { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: orderItem.sku_name, + quantity: orderItem.quantity, + } + : null, + claimToken: claimToken + ? { + primaryClaimTokenId: claimToken.id, + token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', + status: claimToken.status, + expiredAt: claimToken.expired_at, + claimUrl, + } + : null, + inventory: inventoryItem + ? { + inventoryItemId: inventoryItem.id, + skuCode: inventoryItem.sku_code, + batchNo: inventoryItem.batch_no, + credentialType: inventoryItem.credential_type || 'tencent_code', + displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '', + status: inventoryItem.status, + } + : null, + inventoryBindings: mappedInventoryBindings, + artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, + screenshotUrl, + review: { + required: isAssistedClaimTask(task), + screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null, + roleId: String(taskState.reviewRoleId || '').trim() || '', + roleName: String(taskState.reviewRoleName || '').trim() || '', + }, + redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution), + manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), + events: taskEvents.map(mapAdminTaskEvent), + operations: { + canRetry: viewerContext.canManageTaskLifecycle + && !isManualDispatchTask(task) + && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), + canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease), + canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), + canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status), + canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status), + canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), + canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext), + canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext), + canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, + }, + } +} + +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminTaskScreenshotPath(taskId, session = null) { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + + if (viewerContext.role === 'support') { + if (!task.browser_session_id) { + throw createHttpError('当前任务还没有客服复核截图', { + statusCode: 404, + errorCode: 'admin_task_review_screenshot_not_found', + }) + } + + return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) + } + + if (task.screenshot_path) { + return task.screenshot_path + } + + if (task.browser_session_id) { + return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) + } + + throw createHttpError('当前任务还没有截图', { + statusCode: 404, + errorCode: 'admin_task_screenshot_not_found', + }) +} + +/** @returns {Promise} */ +/** @param {AdminInventoryListQueryInput} [query] */ +export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({})) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listInventoryItems({ + page, + pageSize, + skuCode: String(query.skuCode || '').trim(), + credentialType: String(query.credentialType || '').trim(), + status: String(query.status || '').trim(), + batchNo: String(query.batchNo || '').trim(), + }) + + return { + items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))), + pagination: { page, pageSize, total }, + } +} + +/** @returns {Promise} */ +/** @param {AdminInventorySkuSuggestionQueryInput} [query] */ +export async function getAdminInventorySkuSuggestions( + query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}), +) { + const credentialType = String(query.credentialType || '').trim() + const keyword = String(query.keyword || '').trim() + const requestedLimit = Number(query.limit) + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.min(100, Math.floor(requestedLimit)) + : 50 + const items = await listInventorySkuSuggestions({ + credentialType, + keyword, + limit, + }) + + return { + items: items.map((item) => ({ + skuCode: String(item.sku_code || '').trim(), + credentialType: String(item.credential_type || '').trim() || 'tencent_code', + totalCount: Number(item.total_count || 0), + availableCount: Number(item.available_count || 0), + latestUpdatedAt: item.latest_updated_at || null, + })), + } +} + +/** @returns {Promise} */ +/** @param {AdminWebhookEventListQueryInput} [query] */ +export async function getAdminWebhookEvents(query = /** @type {AdminWebhookEventListQueryInput} */ ({})) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWebhookEvents({ + page, + pageSize, + provider: String(query.provider || '').trim(), + platform: String(query.platform || '').trim(), + platformOrderId: String(query.platformOrderId || '').trim(), + processed: String(query.processed || '').trim(), + relatedOrderId: String(query.relatedOrderId || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))), + pagination: { page, pageSize, total }, + } +} + +/** @param {AdminEntityIdInput} eventId */ +export async function getAdminWebhookEventDetail(eventId) { + const event = await getWebhookEventById(Number(eventId)) + + if (!event) { + throw createHttpError('Webhook 事件不存在', { + statusCode: 404, + errorCode: 'admin_webhook_not_found', + }) + } + + return mapAdminWebhookEvent(event, { includeRaw: true }) +} diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index d2f63b16..a5310774 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -1,2439 +1,74 @@ -import { runtimeConfig } from '../../config/runtime.js' -import { query } from '../../db/client.js' -import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' -import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js' -import { - getAgisoShopConfig, - getAgisoShopConfigMap, - getAgisoShopsFilePath, - saveAgisoShopConfigMap, -} from '../platforms/agiso/shop-config-service.js' -import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' -import { - getOrderFulfillmentBindingConfigs, - getOrderFulfillmentBindingsFilePath, - saveOrderFulfillmentBindingConfigs, -} from '../order/fulfillment-binding-config-service.js' -import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' -import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js' -import { - createInventoryItems, - getInventoryItemById, - invalidateInventoryItem, - listInventoryItems, - listInventorySkuSuggestions, - markInventoryItemDelivered, - releaseReservedInventoryItem, -} from '../../repositories/inventory-repo.js' -import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' -import { getOrderById, listOrders } from '../../repositories/order-repo.js' -import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' -import { - getTaskInventoryBindingById, - listTaskInventoryBindingsByTaskId, - listTaskInventoryBindingSummariesByTaskIds, -} from '../../repositories/task-inventory-binding-repo.js' -import { createTaskEvent, listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' -import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' -import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js' -import { createHttpError } from '../../utils/http.js' -import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js' -import { nowIso } from '../../utils/time.js' -import { reserveInventoryForTask } from '../order/inventory-service.js' -import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' -import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js' -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' -import { normalizeProductName } from '../order/product-match-service.js' -import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' -import { normalizeAdminRole } from './admin-auth-service.js' -import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' - -export async function getAdminDashboardSummary() { - const todayPrefix = nowIso().slice(0, 10) - const result = await query( - ` - SELECT - (SELECT COUNT(*)::int FROM orders WHERE to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1) AS today_orders, - ( - SELECT COUNT(*)::int - FROM fulfillment_tasks - WHERE task_status IN ('paid', 'link_generated') - OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired')) - ) AS paid_pending_claim, - ( - SELECT COUNT(*)::int - FROM fulfillment_tasks - WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming') - ) AS claiming_tasks, - ( - SELECT COUNT(*)::int - FROM fulfillment_tasks - WHERE task_status = 'redeemed' - AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1 - ) AS redeemed_today, - ( - SELECT COUNT(*)::int - FROM fulfillment_tasks - WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory') - ) AS abnormal_tasks, - ( - SELECT COUNT(DISTINCT sku_code)::int - FROM inventory_items - WHERE status = 'available' - ) AS sku_with_inventory - `, - [todayPrefix], - ) - const summary = result.rows[0] || {} - - return { - todayOrders: Number(summary?.today_orders || 0), - paidPendingClaim: Number(summary?.paid_pending_claim || 0), - claimingTasks: Number(summary?.claiming_tasks || 0), - redeemedToday: Number(summary?.redeemed_today || 0), - abnormalTasks: Number(summary?.abnormal_tasks || 0), - skuWithInventory: Number(summary?.sku_with_inventory || 0), - } -} - -export async function getAdminOrders(query = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listOrders({ - page, - pageSize, - platformOrderId: String(query.platformOrderId || '').trim(), - payStatus: String(query.payStatus || '').trim(), - skuCode: String(query.skuCode || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - - return { - items: await Promise.all(items.map((item) => mapAdminOrderListItem(item))), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminOrderDetail(orderId) { - const order = await getOrderById(Number(orderId)) - - if (!order) { - throw createHttpError('订单不存在', { - statusCode: 404, - errorCode: 'admin_order_not_found', - }) - } - - const [items, tasks, webhookEvents] = await Promise.all([ - listOrderItemsByOrderId(order.id), - listTasksByOrderId(order.id), - listWebhookEventsByOrderId(order.id), - ]) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) - const itemSummary = summarizeOrderItems(items) - - return { - order: { - orderId: order.id, - provider: order.provider || 'agiso', - platform: order.platform, - shopId: order.shop_id || '', - shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name), - platformOrderId: order.platform_order_id, - orderStatus: order.order_status, - payStatus: order.pay_status, - buyerId: order.buyer_id, - buyerName: order.buyer_name, - receiverContact: order.receiver_contact, - totalAmount: formatFenToAmount(order.total_amount), - totalAmountFen: normalizeFen(order.total_amount), - currency: order.currency, - paidAt: order.paid_at, - createdAt: order.created_at, - updatedAt: order.updated_at, - itemSummary, - rawPayload: safeParseJson(order.raw_payload_json), - bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), - agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), - }, - items: items.map((item) => ({ - orderItemId: item.id, - skuCode: item.sku_code, - skuName: item.sku_name, - itemTitle: resolveOrderItemTitle(item), - quantity: item.quantity, - deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), - spec: safeParseJson(item.spec_json), - })), - tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))), - webhookEvents: webhookEvents.map((event) => ({ - eventId: event.id, - provider: event.provider || 'agiso', - platform: event.platform, - shopId: event.shop_id || '', - shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name), - eventType: event.event_type, - eventKey: event.event_key, - signatureValid: Boolean(event.signature_valid), - processed: Boolean(event.processed), - processError: event.process_error, - createdAt: event.created_at, - })), - } -} - -export async function getAdminTasks(query = {}, session = null) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listTasks({ - page, - pageSize, - status: String(query.status || '').trim(), - platformOrderId: String(query.platformOrderId || '').trim(), - taskNo: String(query.taskNo || '').trim(), - skuCode: String(query.skuCode || '').trim(), - roleId: String(query.roleId || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id)) - const viewerContext = createAdminViewerContext(session) - - return { - items: items.map((item) => mapAdminTaskListItem( - item, - getTaskBindingSummary(taskBindingSummaryMap, item.id), - viewerContext, - )), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminMessageDeliveries(query = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listMessageDeliveries({ - page, - pageSize, - provider: String(query.provider || '').trim(), - platform: String(query.platform || '').trim(), - status: String(query.status || '').trim(), - shopId: String(query.shopId || '').trim(), - platformOrderId: String(query.platformOrderId || '').trim(), - taskNo: String(query.taskNo || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - - return { - items: items.map(mapAdminMessageDeliveryListItem), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminTaskDetail(taskId, session = null) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('任务不存在', { - statusCode: 404, - errorCode: 'admin_task_not_found', - }) - } - - const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([ - getOrderById(task.order_id), - getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null), - getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), - listTaskEventsByTaskId(task.id), - listTaskInventoryBindingsByTaskId(task.id), - ]) - const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] - const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null - const taskContext = parseTaskContext(task) - const taskState = parseTaskState(task) - const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings) - const viewerContext = createAdminViewerContext(session) - const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext)) - const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' - const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) - - return { - task: mapAdminTaskListItem({ - ...task, - sku_code: orderItem?.sku_code || '', - sku_name: orderItem?.sku_name || '', - inventory_display_value: inventoryItem?.display_value || '', - claim_token: claimToken?.token || '', - }, taskBindingSummary, viewerContext), - order: order - ? { - orderId: order.id, - provider: order.provider || 'agiso', - platform: order.platform, - shopId: order.shop_id || '', - shopName: order.shop_name || '', - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - } - : null, - orderItem: orderItem - ? { - orderItemId: orderItem.id, - skuCode: orderItem.sku_code, - skuName: orderItem.sku_name, - quantity: orderItem.quantity, - } - : null, - claimToken: claimToken - ? { - primaryClaimTokenId: claimToken.id, - token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', - status: claimToken.status, - expiredAt: claimToken.expired_at, - claimUrl, - } - : null, - inventory: inventoryItem - ? { - inventoryItemId: inventoryItem.id, - skuCode: inventoryItem.sku_code, - batchNo: inventoryItem.batch_no, - credentialType: inventoryItem.credential_type || 'tencent_code', - displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '', - status: inventoryItem.status, - } - : null, - inventoryBindings: mappedInventoryBindings, - artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, - screenshotUrl, - review: { - required: isAssistedClaimTask(task), - screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null, - roleId: String(taskState.reviewRoleId || '').trim() || '', - roleName: String(taskState.reviewRoleName || '').trim() || '', - }, - redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution), - manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), - events: taskEvents.map(mapAdminTaskEvent), - operations: { - canRetry: viewerContext.canManageTaskLifecycle - && !isManualDispatchTask(task) - && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), - canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease), - canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), - canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status), - canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status), - canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), - canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext), - canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext), - canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, - }, - } -} - -export async function getAdminTaskScreenshotPath(taskId, session = null) { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - if (viewerContext.role === 'support') { - if (!task.browser_session_id) { - throw createHttpError('当前任务还没有客服复核截图', { - statusCode: 404, - errorCode: 'admin_task_review_screenshot_not_found', - }) - } - - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - - if (task.screenshot_path) { - return task.screenshot_path - } - - if (task.browser_session_id) { - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - - throw createHttpError('当前任务还没有截图', { - statusCode: 404, - errorCode: 'admin_task_screenshot_not_found', - }) -} - -export async function getAdminInventoryItems(query = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listInventoryItems({ - page, - pageSize, - skuCode: String(query.skuCode || '').trim(), - credentialType: String(query.credentialType || '').trim(), - status: String(query.status || '').trim(), - batchNo: String(query.batchNo || '').trim(), - }) - - return { - items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))), - pagination: { page, pageSize, total }, - } -} - -export async function createAdminInventoryItem(payload = {}) { - const skuCode = String(payload.skuCode || '').trim() - const displayValue = String(payload.displayValue || '').trim() - const batchNo = String(payload.batchNo || '').trim() - const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' - - if (!skuCode || !displayValue) { - throw createHttpError('缺少 skuCode 或 displayValue', { - statusCode: 400, - errorCode: 'admin_inventory_create_invalid', - }) - } - - const now = nowIso() - const created = await createInventoryItems([ - { - skuCode, - displayValue, - batchNo, - credentialType, - createdAt: now, - updatedAt: now, - }, - ]) - - if (created === 0) { - throw createHttpError('库存凭据已存在,不能重复新增', { - statusCode: 409, - errorCode: 'admin_inventory_duplicate', - }) - } - - const { items } = await listInventoryItems({ - page: 1, - pageSize: 1, - skuCode, - }) - const createdItem = items.find((item) => item.display_value === displayValue) || null - - return { - inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, - } -} - -export async function getAdminInventorySkuSuggestions(query = {}) { - const credentialType = String(query.credentialType || '').trim() - const keyword = String(query.keyword || '').trim() - const requestedLimit = Number(query.limit) - const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 - ? Math.min(100, Math.floor(requestedLimit)) - : 50 - const items = await listInventorySkuSuggestions({ - credentialType, - keyword, - limit, - }) - - return { - items: items.map((item) => ({ - skuCode: String(item.sku_code || '').trim(), - credentialType: String(item.credential_type || '').trim() || 'tencent_code', - totalCount: Number(item.total_count || 0), - availableCount: Number(item.available_count || 0), - latestUpdatedAt: item.latest_updated_at || null, - })), - } -} - -export async function importAdminInventoryItems(payload = {}) { - const rows = normalizeInventoryImportRows(payload) - - if (rows.length === 0) { - throw createHttpError('没有可导入的库存凭据数据', { - statusCode: 400, - errorCode: 'admin_inventory_import_empty', - }) - } - - const now = nowIso() - const normalizedRows = rows.map((row) => ({ - skuCode: row.skuCode, - batchNo: row.batchNo, - displayValue: row.displayValue, - credentialType: row.credentialType, - createdAt: now, - updatedAt: now, - })) - - const created = await createInventoryItems(normalizedRows) - - return { - total: normalizedRows.length, - created, - duplicated: normalizedRows.length - created, - } -} - -export async function releaseAdminInventoryItem(inventoryItemId) { - const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - - if (inventoryItem.status !== 'reserved') { - throw createHttpError('当前库存项不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_inventory_release_not_allowed', - }) - } - - const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso()) - - return { - inventoryItem: await mapAdminInventoryListItem(updated), - } -} - -export async function invalidateAdminInventoryItem(inventoryItemId, payload = {}) { - const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - - if (inventoryItem.status !== 'available') { - throw createHttpError('只有可用库存项才能作废,请先释放预占', { - statusCode: 409, - errorCode: 'admin_inventory_invalidate_not_allowed', - }) - } - - const reason = String(payload.reason || '').trim() || '后台手动作废' - const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso()) - - return { - inventoryItem: await mapAdminInventoryListItem(updated), - } -} - -export async function getAdminWebhookEvents(query = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listWebhookEvents({ - page, - pageSize, - provider: String(query.provider || '').trim(), - platform: String(query.platform || '').trim(), - platformOrderId: String(query.platformOrderId || '').trim(), - processed: String(query.processed || '').trim(), - relatedOrderId: String(query.relatedOrderId || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - - return { - items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminWebhookEventDetail(eventId) { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - return mapAdminWebhookEvent(event, { includeRaw: true }) -} - -export async function replayAdminWebhookEvent(eventId) { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - if (String(event.provider || event.platform || '').trim() !== 'agiso') { - throw createHttpError('当前只支持重放 agiso webhook', { - statusCode: 409, - errorCode: 'admin_webhook_replay_not_supported', - }) - } - - const result = await replayAgisoTradeWebhookEvent(event) - - return { - eventId: event.id, - replayed: true, - result, - } -} - -export async function getAdminAgisoShopConfigs() { - const configMap = getAgisoShopConfigMap() - const rowsResult = await query( - ` - SELECT - shop_id, - MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name, - MAX(created_at) AS latest_seen_at, - COUNT(*)::int AS webhook_event_count - FROM webhook_events - WHERE provider = 'agiso' AND trim(shop_id) != '' - GROUP BY shop_id - ORDER BY latest_seen_at DESC, shop_id DESC - `, - ) - const rows = rowsResult.rows - - return { - filePath: getAgisoShopsFilePath(), - shops: Object.entries(configMap) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => ({ - shopId, - shopName: String(config.shopName || '').trim(), - accessToken: String(config.accessToken || '').trim(), - accessTokenMasked: maskSecret(config.accessToken), - enabled: typeof config.enabled === 'boolean' ? config.enabled : null, - messageTemplate: String(config.messageTemplate || '').trim(), - appSecretConfigured: Boolean(String(config.appSecret || '').trim()), - apiVersion: String(config.apiVersion || '').trim(), - sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), - })), - observedShops: rows.map((row) => ({ - shopId: String(row.shop_id || '').trim(), - detectedShopName: String(row.detected_shop_name || '').trim(), - displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name), - latestSeenAt: row.latest_seen_at || null, - webhookEventCount: Number(row.webhook_event_count || 0), - configured: Boolean(configMap[String(row.shop_id || '').trim()]), - })), - } -} - -export function updateAdminAgisoShopConfigs(payload = {}) { - const rawItems = Array.isArray(payload.shops) ? payload.shops : [] - const nextMap = {} - - for (const item of rawItems) { - const shopId = String(item?.shopId || '').trim() - if (!shopId) { - continue - } - - const next = {} - const shopName = String(item?.shopName || '').trim() - const accessToken = String(item?.accessToken || '').trim() - const messageTemplate = String(item?.messageTemplate || '').trim() - const appSecret = String(item?.appSecret || '').trim() - const apiVersion = String(item?.apiVersion || '').trim() - const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim() - - if (shopName) { - next.shopName = shopName - } - if (accessToken) { - next.accessToken = accessToken - } - if (messageTemplate) { - next.messageTemplate = messageTemplate - } - if (appSecret) { - next.appSecret = appSecret - } - if (apiVersion) { - next.apiVersion = apiVersion - } - if (sendMessageEndpoint) { - next.sendMessageEndpoint = sendMessageEndpoint - } - if (typeof item?.enabled === 'boolean') { - next.enabled = item.enabled - } - - if (!next.accessToken) { - continue - } - - nextMap[shopId] = next - } - - const saved = saveAgisoShopConfigMap(nextMap) - - return { - filePath: getAgisoShopsFilePath(), - shops: Object.entries(saved) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => ({ - shopId, - shopName: String(config.shopName || '').trim(), - accessToken: String(config.accessToken || '').trim(), - accessTokenMasked: maskSecret(config.accessToken), - enabled: typeof config.enabled === 'boolean' ? config.enabled : null, - messageTemplate: String(config.messageTemplate || '').trim(), - appSecretConfigured: Boolean(String(config.appSecret || '').trim()), - apiVersion: String(config.apiVersion || '').trim(), - sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), - })), - } -} - -export async function getAdminFulfillmentBindingConfigs() { - const bindings = getOrderFulfillmentBindingConfigs() - const rowsResult = await query( - ` - SELECT - o.provider, - o.platform, - o.shop_id, - MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name, - MAX(oi.created_at) AS latest_seen_at, - COUNT(*)::int AS order_item_count - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != '' - OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != '' - OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != '' - GROUP BY - o.provider, - o.platform, - o.shop_id, - external_item_id, - external_sku_code, - external_sku_name - ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC - LIMIT 200 - `, - ) - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), - observedProducts: rowsResult.rows.map((row) => mapAdminObservedProductItem({ - provider: String(row.provider || '').trim(), - platform: String(row.platform || '').trim(), - shopId: String(row.shop_id || '').trim(), - shopName: String(row.shop_name || '').trim(), - externalItemId: String(row.external_item_id || '').trim(), - externalSkuCode: String(row.external_sku_code || '').trim(), - externalSkuName: String(row.external_sku_name || '').trim(), - latestSeenAt: row.latest_seen_at || null, - orderItemCount: Number(row.order_item_count || 0), - }, bindings)), - } -} - -export async function lookupAdminFulfillmentBindingOrder(payload = {}) { - const provider = String(payload.provider || 'agiso').trim() || 'agiso' - const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' - const shopId = String(payload.shopId || '').trim() - const platformOrderId = String(payload.platformOrderId || '').trim() - - if (!shopId) { - throw createHttpError('请先填写店铺 ID', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_shop_id', - }) - } - - if (!platformOrderId) { - throw createHttpError('请先填写平台订单号', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', - }) - } - - if (provider !== 'agiso' || platform !== 'xianyu') { - throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_platform_not_supported', - }) - } - - const detailResult = await enrichAgisoXianyuTradeOrder({ - provider, - platform, - shopId, - shopName: '', - platformOrderId, - orderStatus: 'created', - payStatus: 'unpaid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - paidAt: null, - rawPayload: {}, - items: [], - }, { - requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, - }) - - const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} - const items = Array.isArray(detail.items) ? detail.items : [] - - if (items.length === 0) { - const detailReason = String(detailResult?.reason || '').trim() - const detailMessage = String(detailResult?.errorMessage || '').trim() - let message = detailMessage - - if (!message && detailReason === 'missing_config') { - message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' - } - - if (!message) { - message = `未查询到订单 ${platformOrderId} 的商品明细` - } - - throw createHttpError(message, { - statusCode: 404, - errorCode: 'admin_fulfillment_lookup_order_items_not_found', - }) - } - - const bindings = getOrderFulfillmentBindingConfigs() - const resolvedShopName = pickFirstNonEmpty([ - detail.shopName, - getAgisoShopConfig(shopId)?.shopName, - ]) - - return { - order: { - provider, - platform, - shopId, - shopName: resolvedShopName, - platformOrderId, - buyerName: String(detail.buyerName || '').trim(), - totalAmountFen: Number(detail.totalAmount || 0), - totalAmount: formatFenToAmount(detail.totalAmount), - paidAt: detail.paidAt || null, - enriched: Boolean(detailResult?.enriched), - enrichReason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - }, - items: items.map((item, index) => { - const observed = { - provider, - platform, - shopId, - shopName: resolvedShopName, - externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), - externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]), - externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), - latestSeenAt: null, - orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), - } - - return { - lineId: [ - platformOrderId, - index + 1, - observed.externalSkuCode || 'na', - observed.externalItemId || 'na', - ].join(':'), - itemTitle: pickFirstNonEmpty([ - item?.skuName, - item?.externalSkuName, - item?.externalSkuCode, - item?.externalItemId, - ]), - quantity: observed.orderItemCount, - ...mapAdminObservedProductItem(observed, bindings), - } - }), - } -} - -export async function updateAdminFulfillmentBindingConfigs(payload = {}) { - const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] - await validateAdminFulfillmentBindingConfigs(bindingsInput) - const saved = saveOrderFulfillmentBindingConfigs(bindingsInput) - await syncConfiguredFulfillmentBindings() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: saved.map(mapAdminFulfillmentBindingConfigItem), - } -} - -async function validateAdminFulfillmentBindingConfigs(bindings = []) { - if (!Array.isArray(bindings)) { - throw createHttpError('履约配置格式不正确', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_payload', - }) - } - - const seenKeys = new Set() - - for (const [index, rawBinding] of bindings.entries()) { - if (!isPlainObject(rawBinding)) { - throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_item', - }) - } - - const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' - const platform = String(rawBinding.platform || '').trim() - const shopId = String(rawBinding.shopId || '').trim() - const skuCode = String(rawBinding.skuCode || '').trim() - const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' - const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - - if (!skuCode) { - throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_sku_code', - }) - } - - if (!externalItemId && !externalSkuCode && !externalSkuName) { - throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_match_condition', - }) - } - - const profile = await getFulfillmentProfileByKey(profileKey) - if (!profile) { - throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_profile_key', - }) - } - - const uniqueKey = [ - provider, - platform, - shopId, - externalItemId, - externalSkuCode, - normalizeProductName(externalSkuName), - skuCode, - ].join('::') - - if (seenKeys.has(uniqueKey)) { - throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { - statusCode: 409, - errorCode: 'admin_fulfillment_bindings_duplicate_rule', - }) - } - - seenKeys.add(uniqueKey) - } -} - -export async function retryAdminTask(taskId) { - const task = await getRequiredTask(taskId) - const now = nowIso() - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_retry_not_allowed', - }) - } - - if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重试', { - statusCode: 409, - errorCode: 'admin_task_retry_not_allowed', - }) - } - - const orderItems = await listOrderItemsByOrderId(task.order_id) - const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null - const taskContext = parseTaskContext(task) - const primaryRequirement = taskContext.primaryRequirement || null - let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task) - let claimTokenId = getTaskPrimaryClaimTokenId(task) - let nextStatus = 'link_generated' - let lastError = '' - let claimExpiresAt = getTaskClaimExpiresAt(task) - let claimUrl = '' - let token = '' - - if (!reservedInventoryItemId) { - const reserved = await reserveInventoryForTask({ - skuCode: orderItem?.sku_code || '', - taskId: task.id, - credentialType: primaryRequirement?.credentialType || 'tencent_code', - roleKey: primaryRequirement?.roleKey || 'primary_code', - }) - - if (!reserved) { - nextStatus = 'waiting_inventory' - lastError = '库存不足,等待可用库存凭据' - } else { - reservedInventoryItemId = reserved.id - } - } - - if (nextStatus === 'link_generated' && !claimTokenId) { - const claimToken = await createTaskClaimToken(task.id) - claimTokenId = claimToken.id - claimExpiresAt = claimToken.expired_at - claimUrl = claimToken.claimUrl - token = claimToken.token - } - - const updatedTask = await updateTask(task.id, { - task_status: nextStatus, - inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', - claim_token: token || task.claim_token || '', - claim_expires_at: claimExpiresAt, - last_error: lastError, - updated_at: now, - }) - - const payload = { - task: mapTaskActionPayload(updatedTask), - } - - if (claimUrl) { - payload.claimUrl = claimUrl - payload.token = token - } - - return payload -} - -export async function releaseAdminTaskInventory(taskId) { - const task = await getRequiredTask(taskId) - const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) - - if (!primaryInventoryItemId) { - throw createHttpError('当前任务没有预占库存项', { - statusCode: 409, - errorCode: 'admin_task_no_reserved_inventory', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存项', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - await releaseReservedInventoryItem(primaryInventoryItemId, nowIso()) - const updatedTask = await updateTask(task.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '已手动释放预占库存项', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function releaseAdminTaskInventoryBinding(taskId, bindingId) { - const task = await getRequiredTask(taskId) - const binding = await getTaskInventoryBindingById(Number(bindingId)) - - if (!binding || Number(binding.task_id) !== Number(task.id)) { - throw createHttpError('任务库存绑定不存在', { - statusCode: 404, - errorCode: 'admin_task_inventory_binding_not_found', - }) - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - throw createHttpError('当前库存绑定不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_task_inventory_binding_release_not_allowed', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存绑定', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(binding.inventory_item_id, now) - - const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) - const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) - const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') - const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') - const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') - const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) - ? 'waiting_inventory' - : task.task_status - - if (!hasReservedBindings) { - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), - updated_at: now, - }) - - await createTaskEvent(task.id, 'inventory_binding_released', { - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - roleKey: String(binding.role_key || '').trim(), - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - } -} - -export async function regenerateAdminTaskClaimLink(taskId, session = null) { - const task = await getRequiredTask(taskId) - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - const viewerContext = createAdminViewerContext(session) - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_claim_not_allowed', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重新生成领取链接', { - statusCode: 409, - errorCode: 'admin_task_regenerate_not_allowed', - }) - } - - if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { - throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { - statusCode: 403, - errorCode: 'admin_task_regenerate_permission_denied', - }) - } - - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: nowIso(), - }) - } - - const claimToken = await createTaskClaimToken(task.id) - const updatedTask = await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - task_status: 'link_generated', - last_error: '', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - claimUrl: claimToken.claimUrl, - token: claimToken.token, - } -} - -export async function confirmAdminTaskAssistedRole(taskId, session = null) { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') - await confirmClaimRoleForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function redeemAdminTaskAssisted(taskId, session = null) { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') - await redeemClaimTaskForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function closeAdminTask(taskId) { - const task = await getRequiredTask(taskId) - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能关闭', { - statusCode: 409, - errorCode: 'admin_task_close_not_allowed', - }) - } - - const updatedTask = await updateTask(task.id, { - task_status: 'closed', - delivery_status: 'closed', - last_error: task.last_error || '已手动关闭任务', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function markAdminTaskManualReview(taskId) { - const task = await getRequiredTask(taskId) - const updatedTask = await updateTask(task.id, { - task_status: 'manual_review', - delivery_status: task.delivery_status || 'pending', - last_error: task.last_error || '已转人工处理', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function completeAdminTaskManualDispatch(taskId, payload = {}, session = null) { - const task = await getRequiredTask(taskId) - - if (!isManualDispatchTask(task)) { - throw createHttpError('当前任务不是人工履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_manual_dispatch', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_already_completed', - }) - } - - const now = nowIso() - const outcome = normalizeManualDispatchOutcome(payload.outcome) - const resultMessage = String(payload.resultMessage || '').trim() - const deliveryReference = String(payload.deliveryReference || '').trim() - const deliveredCredential = String(payload.deliveredCredential || '').trim() - const context = parseTaskContext(task) - const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' - const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' - const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' - const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' - const nextInventoryStatus = outcome === 'delivered' && inventoryItemId - ? 'consumed' - : task.inventory_status || 'not_required' - const manualDispatch = { - outcome, - deliveryReference, - deliveredCredential, - resultMessage: resultMessage || fallbackMessage, - completedAt: now, - completedBy: session - ? { - userId: Number(session.userId || 0), - username: String(session.username || ''), - role: String(session.role || ''), - } - : null, - } - - if (outcome === 'delivered' && inventoryItemId) { - await markInventoryItemDelivered(inventoryItemId, now) - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - delivery_status: nextDeliveryStatus, - result_code: resultCode, - result_message: resultMessage || fallbackMessage, - user_action_status: 'not_required', - last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', - redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, - context_json: JSON.stringify({ - ...context, - manualDispatch, - }), - updated_at: now, - }) - - await createTaskEvent(task.id, 'manual_dispatch_completed', { - outcome, - resultCode, - resultMessage: resultMessage || fallbackMessage, - deliveryReference, - deliveredCredentialMasked: maskCode(deliveredCredential), - completedBy: manualDispatch.completedBy, - }, now) - - const taskAfterAutoDelivery = outcome === 'delivered' - ? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: await getOrderById(task.order_id), - task: updatedTask, - trigger: 'manual_dispatch_completed', - })).task || updatedTask - : updatedTask - - return { - outcome, - task: mapTaskActionPayload(taskAfterAutoDelivery), - } -} - -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, - } -} - -function mapAdminFulfillmentBindingConfigItem(item) { - const match = item?.match || {} - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - skuCode: String(item?.skuCode || '').trim(), - skuName: String(item?.skuName || '').trim(), - profileKey: String(item?.profileKey || '').trim(), - enabled: item?.enabled !== false, - priority: Number(item?.priority || 100), - config: item?.config || {}, - match: { - externalSkuCode: String(match.externalSkuCode || '').trim(), - externalItemId: String(match.externalItemId || '').trim(), - externalSkuName: String(match.externalSkuName || '').trim(), - config: match.config || {}, - }, - } -} - -function mapAdminMessageDeliveryListItem(item) { - return { - deliveryId: Number(item.id), - provider: String(item.provider || '').trim(), - platform: String(item.platform || '').trim(), - shopId: String(item.shop_id || '').trim(), - shopName: String(item.shop_name || '').trim(), - channel: String(item.channel || '').trim(), - orderId: item.order_id ? Number(item.order_id) : null, - taskId: item.task_id ? Number(item.task_id) : null, - taskNo: String(item.task_no || '').trim(), - taskStatus: String(item.task_status || '').trim(), - platformOrderId: String(item.platform_order_id || '').trim(), - recipientKey: String(item.recipient_key || '').trim(), - messageContent: String(item.message_content || '').trim(), - claimUrl: String(item.claim_url || '').trim(), - status: String(item.status || '').trim(), - requestUrl: String(item.request_url || '').trim(), - responseStatus: Number(item.response_status || 0), - response: safeParseJson(item.response_json), - errorMessage: String(item.error_message || '').trim(), - sentAt: item.sent_at || null, - createdAt: item.created_at, - updatedAt: item.updated_at, - } -} - -function matchesObservedProduct(binding, observed) { - const provider = String(binding?.provider || '').trim() - const platform = String(binding?.platform || '').trim() - const shopId = String(binding?.shopId || '').trim() - const match = binding?.match || {} - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - - if (provider && provider !== String(observed?.provider || '').trim()) { - return false - } - - if (platform && platform !== String(observed?.platform || '').trim()) { - return false - } - - if (shopId && shopId !== String(observed?.shopId || '').trim()) { - return false - } - - return ( - (externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim()) - || (externalItemId && externalItemId === String(observed?.externalItemId || '').trim()) - || (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim()) - ) -} - -function mapAdminObservedProductItem(item, bindings = []) { - const matchedBinding = findMatchingObservedBinding(bindings, item) - - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - shopName: String(item?.shopName || '').trim(), - externalItemId: String(item?.externalItemId || '').trim(), - externalSkuCode: String(item?.externalSkuCode || '').trim(), - externalSkuName: String(item?.externalSkuName || '').trim(), - latestSeenAt: item?.latestSeenAt || null, - orderItemCount: Number(item?.orderItemCount || 0), - configured: Boolean(matchedBinding), - matchedBinding: matchedBinding - ? { - skuCode: String(matchedBinding.skuCode || '').trim(), - skuName: String(matchedBinding.skuName || '').trim(), - profileKey: String(matchedBinding.profileKey || '').trim(), - } - : null, - } -} - -function findMatchingObservedBinding(bindings, observed) { - return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null -} - -async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { - const headers = normalizeRecord(safeParseJson(item.headers_json)) - const query = normalizeRecord(safeParseJson(item.query_json)) - const body = normalizeRecord(safeParseJson(item.body_json)) - const payload = extractWebhookPayload(body) - const itemSources = extractWebhookItemSources(payload) - const rawAmount = pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.Payment, - payload.payment, - payload.buy_amount, - payload.buyAmount, - ]) - const totalAmountFen = parseAmountToFen(rawAmount) - const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0 - - const mapped = { - eventId: item.id, - provider: item.provider || 'agiso', - platform: item.platform, - platformRaw: pickFirstNonEmpty([ - query.fromPlatform, - query.from_platform, - body.fromPlatform, - body.from_platform, - payload.fromPlatform, - payload.FromPlatform, - payload.platform, - payload.Platform, - ]), - shopId: item.shop_id || pickFirstNonEmpty([ - payload.PlatformUserId, - payload.platformUserId, - payload.platform_user_id, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]), - shopName: resolveDisplayShopName( - item.provider, - item.shop_id || pickFirstNonEmpty([ - payload.PlatformUserId, - payload.platformUserId, - payload.platform_user_id, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]), - item.shop_name || pickFirstNonEmpty([ - payload.shopName, - payload.shop_name, - payload.ShopName, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]), - ), - eventType: item.event_type, - eventKey: item.event_key, - signatureValid: Boolean(item.signature_valid), - processed: Boolean(item.processed), - processError: item.process_error, - relatedOrderId: item.related_order_id, - createdAt: item.created_at, - platformOrderId: resolveAgisoTradePlatformOrderId(payload), - buyerId: pickFirstNonEmpty([ - payload.buyer_id, - payload.buyerId, - payload.BuyerId, - payload.openid, - payload.BuyerOpenUid, - payload.buyer_open_uid, - payload.buyerOpenUid, - ]), - buyerName: pickFirstNonEmpty([ - payload.buyer_name, - payload.buyerName, - payload.BuyerName, - payload.nick, - payload.BuyerNick, - payload.buyer_nick, - payload.buyerNick, - ]), - totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '', - totalAmountFen, - currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY', - itemCount: itemSources.length, - taskCount, - messageText: pickFirstNonEmpty([ - payload.MsgTypeDes, - payload.msgTypeDes, - payload.msg_type_desc, - payload.message, - payload.msg, - ]), - requestTimestamp: pickFirstNonEmpty([query.timestamp]), - requestSign: pickFirstNonEmpty([query.sign]), - aopic: pickFirstNonEmpty([query.aopic]), - sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]), - sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]), - userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]), - } - - if (!includeRaw) { - return mapped - } - - return { - ...mapped, - headers, - query, - body, - payload, - } -} - -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) - - return { - inventoryItemId: item.id, - skuCode: item.sku_code, - batchNo: item.batch_no, - credentialType: item.credential_type || 'tencent_code', - displayValue: item.display_value, - status: item.status, - 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, - invalidReason: item.invalid_reason || '', - deliveredAt: item.delivered_at, - createdAt: item.created_at, - updatedAt: item.updated_at, - } -} - -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, - } -} - -async function getRequiredTask(taskId) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('任务不存在', { - statusCode: 404, - errorCode: 'admin_task_not_found', - }) - } - - return task -} - -async function getRequiredInventoryItem(inventoryItemId) { - const inventoryItem = await getInventoryItemById(Number(inventoryItemId)) - - if (!inventoryItem) { - throw createHttpError('库存项不存在', { - statusCode: 404, - errorCode: 'admin_inventory_not_found', - }) - } - - return inventoryItem -} - -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 || ''), - } -} - -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, - } -} - -function createEmptyTaskBindingSummary() { - return { - totalBindingCount: 0, - reservedBindingCount: 0, - consumedBindingCount: 0, - releasedBindingCount: 0, - roleKeys: [], - } -} - -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 -} - -function getTaskBindingSummary(summaryMap, taskId) { - return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary() -} - -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, - } -} - -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), - } -} - -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()) -} - -async function mapAdminOrderListItem(item) { - const [tasks, orderItems] = await Promise.all([ - listTasksByOrderId(item.id), - listOrderItemsByOrderId(item.id), - ]) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) - const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap) - const itemSummary = summarizeOrderItems(orderItems) - - return { - orderId: item.id, - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shop_id || '', - shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name), - platformOrderId: item.platform_order_id, - orderStatus: item.order_status, - payStatus: item.pay_status, - buyerId: item.buyer_id || '', - buyerName: item.buyer_name, - receiverContact: item.receiver_contact || '', - totalAmount: formatFenToAmount(item.total_amount), - totalAmountFen: normalizeFen(item.total_amount), - currency: item.currency, - paidAt: item.paid_at, - createdAt: item.created_at, - updatedAt: item.updated_at, - itemCount: orderItems.length, - totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0), - itemSummary, - taskCount: Number(item.task_count || tasks.length || 0), - systemBindingStatus: bindingSummary.systemBindingStatus, - userBindingStatus: bindingSummary.userBindingStatus, - systemBoundTaskCount: bindingSummary.systemBoundTaskCount, - completedBindingTaskCount: bindingSummary.completedBindingTaskCount, - totalBindingCount: bindingSummary.totalBindingCount, - reservedBindingCount: bindingSummary.reservedBindingCount, - consumedBindingCount: bindingSummary.consumedBindingCount, - releasedBindingCount: bindingSummary.releasedBindingCount, - } -} - -function summarizeOrderItems(items) { - const normalizedItems = Array.isArray(items) ? items : [] - - if (normalizedItems.length === 0) { - return '' - } - - const [firstItem] = normalizedItems - const firstLabel = resolveOrderItemTitle(firstItem) - || String(firstItem?.sku_name || firstItem?.sku_code || '').trim() - - if (normalizedItems.length === 1) { - return firstLabel - } - - return `${firstLabel} 等 ${normalizedItems.length} 项` -} - -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, - ]) -} - -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() -} - -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, - } -} - -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 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 parseTaskContext(task) { - const value = task?.context_json - - if (!value) { - return {} - } - - if (typeof value === 'object') { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -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 {} - } -} - -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, - } -} - -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 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, - } -} - -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 normalizeManualDispatchOutcome(value) { - const normalized = String(value || '').trim().toLowerCase() - - if (normalized === 'failed') { - return 'failed' - } - - return 'delivered' -} - -function isManualDispatchTask(task) { - return String(task?.executor_key || '').trim() === 'manual_dispatch' -} - -function isAssistedClaimTask(task) { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -function getTaskPrimaryInventoryItemId(task) { - const value = Number(task?.primary_inventory_item_id || 0) - return Number.isFinite(value) && value > 0 ? value : null -} - -function getTaskPrimaryClaimTokenId(task) { - const value = Number(task?.primary_claim_token_id || 0) - return Number.isFinite(value) && value > 0 ? value : null -} - -function getTaskClaimExpiresAt(task) { - return task?.claim_expires_at || task?.primary_claim_expires_at || null -} - -function getTaskRetryCount(task) { - return Number(task?.attempt_count || 0) -} - -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', - } -} - -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) -} - -function canViewerConfirmAssistedRole(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - return false - } - - return String(task?.task_status || '').trim() === 'claimed' -} - -function canViewerRedeemAssistedTask(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - return false - } - - return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) -} - -function ensureViewerCanOperateAssistedTask(task, viewerContext, action) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - throw createHttpError('当前账号没有此操作权限', { - statusCode: 403, - errorCode: 'admin_task_assisted_permission_denied', - }) - } - - if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) { - throw createHttpError('当前任务状态还不能确认角色', { - statusCode: 409, - errorCode: 'admin_task_assisted_confirm_not_allowed', - }) - } - - if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) { - throw createHttpError('当前任务状态还不能开始兑换', { - statusCode: 409, - errorCode: 'admin_task_assisted_redeem_not_allowed', - }) - } -} - -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 '' -} - -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 -} - -function normalizeInventoryImportRows(payload) { - const rows = [] - const directRows = Array.isArray(payload.rows) ? payload.rows : [] - const bulkCodes = Array.isArray(payload.codes) ? payload.codes : [] - - if (directRows.length > 0) { - for (const row of directRows) { - const skuCode = String(row?.skuCode || '').trim() - const displayValue = String(row?.displayValue || '').trim() - const batchNo = String(row?.batchNo || '').trim() - const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' - - if (!skuCode || !displayValue) { - continue - } - - rows.push({ skuCode, displayValue, batchNo, credentialType }) - } - } - - if (bulkCodes.length > 0) { - const skuCode = String(payload.skuCode || '').trim() - const batchNo = String(payload.batchNo || '').trim() - const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' - - if (!skuCode) { - throw createHttpError('批量导入时缺少 skuCode', { - statusCode: 400, - errorCode: 'admin_inventory_import_missing_sku', - }) - } - - for (const rawValue of bulkCodes) { - const displayValue = String(rawValue || '').trim() - if (!displayValue) { - continue - } - rows.push({ skuCode, displayValue, batchNo, credentialType }) - } - } - - return dedupeRows(rows) -} - -function dedupeRows(rows) { - const seen = new Set() - const output = [] - - for (const row of rows) { - const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.displayValue}` - if (seen.has(key)) { - continue - } - seen.add(key) - output.push(row) - } - - return output -} - -function maskSecret(value) { - const normalized = String(value || '').trim() - if (!normalized) { - return '' - } - - if (normalized.length <= 10) { - return `${normalized.slice(0, 2)}****${normalized.slice(-2)}` - } - - return `${normalized.slice(0, 6)}****${normalized.slice(-6)}` -} - -function normalizeRecord(value) { - return value && typeof value === 'object' && !Array.isArray(value) ? value : {} -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function extractWebhookPayload(body) { - return extractAgisoTradePayload(body) -} - -function extractWebhookItemSources(payload) { - const normalizedPayload = normalizeRecord(payload) - const candidates = [ - normalizedPayload.orders, - normalizedPayload.Orders, - normalizedPayload.items, - normalizedPayload.Items, - normalizedPayload.order_items, - ] - - for (const candidate of candidates) { - if (Array.isArray(candidate) && candidate.length > 0) { - return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) - } - } - - if ( - pickFirstNonEmpty([ - normalizedPayload.item_id, - normalizedPayload.itemId, - normalizedPayload.ItemId, - normalizedPayload.num_iid, - normalizedPayload.numIid, - normalizedPayload.NumIid, - ]) - ) { - return [normalizedPayload] - } - - return [] -} - -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 '' -} - -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)}` -} +// @ts-check + +export { + buildOrderAgisoAutoDeliverySummary, + buildOrderBindingSummary, + canRegenerateClaimLinkForViewer, + canViewerConfirmAssistedRole, + canViewerRedeemAssistedTask, + createAdminViewerContext, + createEmptyTaskBindingSummary, + createTaskBindingSummaryFromBindings, + getRequiredInventoryItem, + getRequiredTask, + getTaskBindingSummary, + getTaskBindingSummaryMap, + getTaskPrimaryClaimTokenId, + getTaskPrimaryInventoryItemId, + mapAdminInventoryListItem, + mapAdminOrderListItem, + mapAdminTaskEvent, + mapAdminTaskInventoryBinding, + mapAdminTaskListItem, + mapAdminTaskSummary, + mapAdminWebhookEvent, + mapManualDispatchContext, + mapRedeemResolutionContext, + mapTaskActionPayload, + parseTaskContext, + parseTaskState, + resolveOrderItemDeliveryMode, + resolveOrderItemTitle, + resolveAdminTaskScreenshotUrl, + resolveDisplayShopName, +} from './admin-read-helpers.js' + +export { + getAdminInventoryItems, + getAdminInventorySkuSuggestions, + getAdminOrderDetail, + getAdminOrders, + getAdminTaskDetail, + getAdminTasks, + getAdminTaskScreenshotPath, + getAdminWebhookEventDetail, + getAdminWebhookEvents, +} from './admin-read-service.js' + +export { + closeAdminTask, + completeAdminTaskManualDispatch, + confirmAdminTaskAssistedRole, + createAdminInventoryItem, + importAdminInventoryItems, + invalidateAdminInventoryItem, + markAdminTaskManualReview, + redeemAdminTaskAssisted, + regenerateAdminTaskClaimLink, + releaseAdminInventoryItem, + releaseAdminTaskInventory, + releaseAdminTaskInventoryBinding, + replayAdminWebhookEvent, + retryAdminTask, +} from './admin-write-service.js' + +export { + getAdminAgisoShopConfigs, + getAdminFulfillmentBindingConfigs, + lookupAdminFulfillmentBindingOrder, + updateAdminAgisoShopConfigs, + updateAdminFulfillmentBindingConfigs, +} from './admin-platform-config-service.js' + +export { getAdminDashboardSummary } from './admin-dashboard-service.js' +export { getAdminMessageDeliveries } from './admin-message-delivery-service.js' diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js new file mode 100644 index 00000000..cfe1a1ee --- /dev/null +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -0,0 +1,719 @@ +// @ts-check + +import { + createInventoryItems, + invalidateInventoryItem, + listInventoryItems, + markInventoryItemDelivered, + releaseReservedInventoryItem, +} from '../../repositories/inventory-repo.js' +import { updateClaimToken } from '../../repositories/claim-token-repo.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { getOrderById } from '../../repositories/order-repo.js' +import { updateTask } from '../../repositories/task-repo.js' +import { + getTaskInventoryBindingById, + listTaskInventoryBindingsByTaskId, +} from '../../repositories/task-inventory-binding-repo.js' +import { createTaskEvent } from '../../repositories/task-event-repo.js' +import { getWebhookEventById } from '../../repositories/webhook-event-repo.js' +import { createTaskClaimToken } from '../claim/claim-service.js' +import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js' +import { reserveInventoryForTask } from '../order/inventory-service.js' +import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' +import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { + canViewerConfirmAssistedRole, + canViewerRedeemAssistedTask, + createAdminViewerContext, + getRequiredInventoryItem, + getRequiredTask, + getTaskPrimaryClaimTokenId, + getTaskPrimaryInventoryItemId, + isAssistedClaimTask, + isManualDispatchTask, + mapAdminInventoryListItem, + mapTaskActionPayload, + parseTaskContext, +} from './admin-read-helpers.js' + +/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */ +/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryCreateInput} AdminInventoryCreateInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryImportInput} AdminInventoryImportInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryImportRowInput} AdminInventoryImportRowInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateInput */ +/** @typedef {import('../../types/admin-write-models.js').AdminInventoryImportResponse} AdminInventoryImportResponse */ +/** @typedef {import('../../types/admin-write-models.js').AdminInventoryMutationResponse} AdminInventoryMutationResponse */ +/** @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 */ +/** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */ + +/** @returns {Promise} */ +/** @param {AdminInventoryCreateInput} [payload] */ +export async function createAdminInventoryItem(payload = /** @type {AdminInventoryCreateInput} */ ({})) { + const skuCode = String(payload.skuCode || '').trim() + const displayValue = String(payload.displayValue || '').trim() + const batchNo = String(payload.batchNo || '').trim() + const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' + + if (!skuCode || !displayValue) { + throw createHttpError('缺少 skuCode 或 displayValue', { + statusCode: 400, + errorCode: 'admin_inventory_create_invalid', + }) + } + + const now = nowIso() + const created = await createInventoryItems([ + { + skuCode, + displayValue, + batchNo, + credentialType, + createdAt: now, + updatedAt: now, + }, + ]) + + if (created === 0) { + throw createHttpError('库存凭据已存在,不能重复新增', { + statusCode: 409, + errorCode: 'admin_inventory_duplicate', + }) + } + + const { items } = await listInventoryItems({ + page: 1, + pageSize: 1, + skuCode, + }) + const createdItem = items.find((item) => item.display_value === displayValue) || null + + return { + inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, + } +} + +/** @returns {Promise} */ +/** @param {AdminInventoryImportInput} [payload] */ +export async function importAdminInventoryItems(payload = /** @type {AdminInventoryImportInput} */ ({})) { + const rows = normalizeInventoryImportRows(payload) + + if (rows.length === 0) { + throw createHttpError('没有可导入的库存凭据数据', { + statusCode: 400, + errorCode: 'admin_inventory_import_empty', + }) + } + + const now = nowIso() + const normalizedRows = rows.map((row) => ({ + skuCode: row.skuCode, + batchNo: row.batchNo, + displayValue: row.displayValue, + credentialType: row.credentialType, + createdAt: now, + updatedAt: now, + })) + + const created = await createInventoryItems(normalizedRows) + + return { + total: normalizedRows.length, + created, + duplicated: normalizedRows.length - created, + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} inventoryItemId */ +export async function releaseAdminInventoryItem(inventoryItemId) { + const inventoryItem = await getRequiredInventoryItem(inventoryItemId) + + if (inventoryItem.status !== 'reserved') { + throw createHttpError('当前库存项不是预占状态,不能释放', { + statusCode: 409, + errorCode: 'admin_inventory_release_not_allowed', + }) + } + + const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso()) + + return { + inventoryItem: await mapAdminInventoryListItem(updated), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} inventoryItemId */ +/** @param {AdminInventoryInvalidateInput} [payload] */ +export async function invalidateAdminInventoryItem( + inventoryItemId, + payload = /** @type {AdminInventoryInvalidateInput} */ ({}), +) { + const inventoryItem = await getRequiredInventoryItem(inventoryItemId) + + if (inventoryItem.status !== 'available') { + throw createHttpError('只有可用库存项才能作废,请先释放预占', { + statusCode: 409, + errorCode: 'admin_inventory_invalidate_not_allowed', + }) + } + + const reason = String(payload.reason || '').trim() || '后台手动作废' + const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso()) + + return { + inventoryItem: await mapAdminInventoryListItem(updated), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} eventId */ +export async function replayAdminWebhookEvent(eventId) { + const event = await getWebhookEventById(Number(eventId)) + + if (!event) { + throw createHttpError('Webhook 事件不存在', { + statusCode: 404, + errorCode: 'admin_webhook_not_found', + }) + } + + if (String(event.provider || event.platform || '').trim() !== 'agiso') { + throw createHttpError('当前只支持重放 agiso webhook', { + statusCode: 409, + errorCode: 'admin_webhook_replay_not_supported', + }) + } + + const result = await replayAgisoTradeWebhookEvent(event) + + return { + eventId: event.id, + replayed: true, + result, + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +export async function releaseAdminTaskInventory(taskId) { + const task = await getRequiredTask(taskId) + const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) + + if (!primaryInventoryItemId) { + throw createHttpError('当前任务没有预占库存项', { + statusCode: 409, + errorCode: 'admin_task_no_reserved_inventory', + }) + } + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能释放库存项', { + statusCode: 409, + errorCode: 'admin_task_release_not_allowed', + }) + } + + const now = nowIso() + await releaseReservedInventoryItem(primaryInventoryItemId, now) + const updatedTask = await updateTask(task.id, { + task_status: 'waiting_inventory', + inventory_status: 'pending', + last_error: '已手动释放预占库存项', + updated_at: now, + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminEntityIdInput} bindingId */ +export async function releaseAdminTaskInventoryBinding(taskId, bindingId) { + const task = await getRequiredTask(taskId) + const binding = await getTaskInventoryBindingById(Number(bindingId)) + + if (!binding || Number(binding.task_id) !== Number(task.id)) { + throw createHttpError('任务库存绑定不存在', { + statusCode: 404, + errorCode: 'admin_task_inventory_binding_not_found', + }) + } + + if (String(binding.binding_status || '').trim() !== 'reserved') { + throw createHttpError('当前库存绑定不是预占状态,不能释放', { + statusCode: 409, + errorCode: 'admin_task_inventory_binding_release_not_allowed', + }) + } + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能释放库存绑定', { + statusCode: 409, + errorCode: 'admin_task_release_not_allowed', + }) + } + + const now = nowIso() + await releaseReservedInventoryItem(binding.inventory_item_id, now) + + const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) + const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) + const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') + const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') + const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') + const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) + ? 'waiting_inventory' + : task.task_status + + if (!hasReservedBindings) { + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + if (primaryClaimTokenId) { + await updateClaimToken(primaryClaimTokenId, { + status: 'revoked', + updated_at: now, + }) + } + } + + const updatedTask = await updateTask(task.id, { + task_status: nextTaskStatus, + inventory_status: nextInventoryStatus, + last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), + updated_at: now, + }) + + await createTaskEvent(task.id, 'inventory_binding_released', { + bindingId: Number(binding.id), + inventoryItemId: Number(binding.inventory_item_id), + roleKey: String(binding.role_key || '').trim(), + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + bindingId: Number(binding.id), + inventoryItemId: Number(binding.inventory_item_id), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function regenerateAdminTaskClaimLink(taskId, session = null) { + const task = await getRequiredTask(taskId) + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + const viewerContext = createAdminViewerContext(session) + + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_claim_not_allowed', + }) + } + + if (['redeemed', 'closed'].includes(task.task_status)) { + throw createHttpError('当前任务状态不允许重新生成领取链接', { + statusCode: 409, + errorCode: 'admin_task_regenerate_not_allowed', + }) + } + + if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { + throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { + statusCode: 403, + errorCode: 'admin_task_regenerate_permission_denied', + }) + } + + const now = nowIso() + if (primaryClaimTokenId) { + await updateClaimToken(primaryClaimTokenId, { + status: 'revoked', + updated_at: now, + }) + } + + const claimToken = await createTaskClaimToken(task.id) + const updatedTask = await updateTask(task.id, { + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + task_status: 'link_generated', + last_error: '', + updated_at: now, + }) + + return { + task: mapTaskActionPayload(updatedTask), + claimUrl: claimToken.claimUrl, + token: claimToken.token, + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function confirmAdminTaskAssistedRole(taskId, session = null) { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') + await confirmClaimRoleForAdminTask(task.id) + const updatedTask = await getRequiredTask(task.id) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function redeemAdminTaskAssisted(taskId, session = null) { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') + await redeemClaimTaskForAdminTask(task.id) + const updatedTask = await getRequiredTask(task.id) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +export async function closeAdminTask(taskId) { + const task = await getRequiredTask(taskId) + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能关闭', { + statusCode: 409, + errorCode: 'admin_task_close_not_allowed', + }) + } + + const updatedTask = await updateTask(task.id, { + task_status: 'closed', + delivery_status: 'closed', + last_error: task.last_error || '已手动关闭任务', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +export async function markAdminTaskManualReview(taskId) { + const task = await getRequiredTask(taskId) + const updatedTask = await updateTask(task.id, { + task_status: 'manual_review', + delivery_status: task.delivery_status || 'pending', + last_error: task.last_error || '已转人工处理', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +export async function retryAdminTask(taskId) { + const task = await getRequiredTask(taskId) + const now = nowIso() + + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_retry_not_allowed', + }) + } + + if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { + throw createHttpError('当前任务状态不允许重试', { + statusCode: 409, + errorCode: 'admin_task_retry_not_allowed', + }) + } + + const taskContext = parseTaskContext(task) + const primaryRequirement = taskContext.primaryRequirement || null + const orderItems = await listOrderItemsByOrderId(task.order_id) + const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null + let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task) + let claimTokenId = getTaskPrimaryClaimTokenId(task) + let nextStatus = 'link_generated' + let lastError = '' + let claimExpiresAt = getTaskClaimExpiresAt(task) + let claimUrl = '' + let token = '' + + if (!reservedInventoryItemId) { + const reserved = await reserveInventoryForTask({ + skuCode: orderItem?.sku_code || '', + taskId: task.id, + credentialType: primaryRequirement?.credentialType || 'tencent_code', + roleKey: primaryRequirement?.roleKey || 'primary_code', + }) + + if (!reserved) { + nextStatus = 'waiting_inventory' + lastError = '库存不足,等待可用库存凭据' + } else { + reservedInventoryItemId = reserved.id + } + } + + if (nextStatus === 'link_generated' && !claimTokenId) { + const claimToken = await createTaskClaimToken(task.id) + claimTokenId = claimToken.id + claimExpiresAt = claimToken.expired_at + claimUrl = claimToken.claimUrl + token = claimToken.token + } + + const updatedTask = await updateTask(task.id, { + task_status: nextStatus, + inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', + claim_token: token || task.claim_token || '', + claim_expires_at: claimExpiresAt, + last_error: lastError, + updated_at: now, + }) + + const response = { + task: mapTaskActionPayload(updatedTask), + } + + if (claimUrl) { + response.claimUrl = claimUrl + response.token = token + } + + return response +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {{ outcome?: string, resultMessage?: string, deliveryReference?: string, deliveredCredential?: string }} [payload] */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function completeAdminTaskManualDispatch(taskId, payload = {}, session = null) { + const task = await getRequiredTask(taskId) + + if (!isManualDispatchTask(task)) { + throw createHttpError('当前任务不是人工履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_manual_dispatch', + }) + } + + if (['redeemed', 'closed'].includes(task.task_status)) { + throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_already_completed', + }) + } + + const now = nowIso() + const outcome = normalizeManualDispatchOutcome(payload.outcome) + const resultMessage = String(payload.resultMessage || '').trim() + const deliveryReference = String(payload.deliveryReference || '').trim() + const deliveredCredential = String(payload.deliveredCredential || '').trim() + const context = parseTaskContext(task) + const inventoryItemId = getTaskPrimaryInventoryItemId(task) + const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' + const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' + const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' + const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' + const nextInventoryStatus = outcome === 'delivered' && inventoryItemId + ? 'consumed' + : task.inventory_status || 'not_required' + const manualDispatch = { + outcome, + deliveryReference, + deliveredCredential, + resultMessage: resultMessage || fallbackMessage, + completedAt: now, + completedBy: session + ? { + userId: Number(session.userId || 0), + username: String(session.username || ''), + role: String(session.role || ''), + } + : null, + } + + if (outcome === 'delivered' && inventoryItemId) { + await markInventoryItemDelivered(inventoryItemId, now) + } + + const updatedTask = await updateTask(task.id, { + task_status: nextTaskStatus, + inventory_status: nextInventoryStatus, + delivery_status: nextDeliveryStatus, + result_code: resultCode, + result_message: resultMessage || fallbackMessage, + user_action_status: 'not_required', + last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', + redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, + context_json: JSON.stringify({ + ...context, + manualDispatch, + }), + updated_at: now, + }) + + await createTaskEvent(task.id, 'manual_dispatch_completed', { + outcome, + resultCode, + resultMessage: resultMessage || fallbackMessage, + deliveryReference, + deliveredCredentialMasked: maskCode(deliveredCredential), + completedBy: manualDispatch.completedBy, + }, now) + + const taskAfterAutoDelivery = outcome === 'delivered' + ? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ + order: await getOrderById(task.order_id), + task: updatedTask, + trigger: 'manual_dispatch_completed', + })).task || updatedTask + : updatedTask + + return { + outcome, + task: mapTaskActionPayload(taskAfterAutoDelivery), + } +} + +/** + * @param {AdminInventoryImportInput} payload + * @returns {Array>>} + */ +function normalizeInventoryImportRows(payload) { + const rows = [] + const directRows = Array.isArray(payload.rows) ? payload.rows : [] + const bulkCodes = Array.isArray(payload.codes) ? payload.codes : [] + + if (directRows.length > 0) { + for (const row of directRows) { + const skuCode = String(row?.skuCode || '').trim() + const displayValue = String(row?.displayValue || '').trim() + const batchNo = String(row?.batchNo || '').trim() + const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' + + if (!skuCode || !displayValue) { + continue + } + + rows.push({ skuCode, displayValue, batchNo, credentialType }) + } + } + + if (bulkCodes.length > 0) { + const skuCode = String(payload.skuCode || '').trim() + const batchNo = String(payload.batchNo || '').trim() + const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' + + if (!skuCode) { + throw createHttpError('批量导入时缺少 skuCode', { + statusCode: 400, + errorCode: 'admin_inventory_import_missing_sku', + }) + } + + for (const rawValue of bulkCodes) { + const displayValue = String(rawValue || '').trim() + if (!displayValue) { + continue + } + rows.push({ skuCode, displayValue, batchNo, credentialType }) + } + } + + return dedupeRows(rows) +} + +/** + * @param {Array>>} rows + */ +function dedupeRows(rows) { + const seen = new Set() + const output = [] + + for (const row of rows) { + const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.displayValue}` + if (seen.has(key)) { + continue + } + seen.add(key) + output.push(row) + } + + return output +} + + +/** + * @param {Awaited>} task + * @param {ReturnType} viewerContext + * @param {'confirm' | 'redeem'} action + */ +function ensureViewerCanOperateAssistedTask(task, viewerContext, action) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_task_assisted_permission_denied', + }) + } + + if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) { + throw createHttpError('当前任务状态还不能确认角色', { + statusCode: 409, + errorCode: 'admin_task_assisted_confirm_not_allowed', + }) + } + + if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) { + throw createHttpError('当前任务状态还不能开始兑换', { + statusCode: 409, + errorCode: 'admin_task_assisted_redeem_not_allowed', + }) + } +} + +function normalizeManualDispatchOutcome(value) { + const normalized = String(value || '').trim().toLowerCase() + + if (normalized === 'failed') { + return 'failed' + } + + return 'delivered' +} + +function getTaskClaimExpiresAt(task) { + return task?.claim_expires_at || task?.primary_claim_expires_at || null +} + +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)}` +} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js index 7c500b6a..d438b89d 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js @@ -8,6 +8,15 @@ import { parseJsonObject } from '../../../../utils/json.js' import { logWebhook } from '../../../../utils/logger.js' import { nowIso } from '../../../../utils/time.js' +/** + * @typedef {{ + * order?: Record | null + * task?: Record | null + * trigger?: string + * }} EnsureAgisoXianyuAutoDeliveryInput + */ + +/** @param {EnsureAgisoXianyuAutoDeliveryInput} input */ export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({ order, task, diff --git a/apps/backend/src/types/admin-read-inputs.js b/apps/backend/src/types/admin-read-inputs.js new file mode 100644 index 00000000..39d3564d --- /dev/null +++ b/apps/backend/src/types/admin-read-inputs.js @@ -0,0 +1,74 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * userId?: number | string + * username?: string + * role?: string + * }} AdminViewerSessionInput + */ + +/** + * @typedef {{ + * page?: number | string + * pageSize?: number | string + * platformOrderId?: string + * payStatus?: string + * skuCode?: string + * dateFrom?: string + * dateTo?: string + * }} AdminOrderListQueryInput + */ + +/** + * @typedef {{ + * page?: number | string + * pageSize?: number | string + * status?: string + * platformOrderId?: string + * taskNo?: string + * skuCode?: string + * roleId?: string + * dateFrom?: string + * dateTo?: string + * }} AdminTaskListQueryInput + */ + +/** + * @typedef {{ + * page?: number | string + * pageSize?: number | string + * skuCode?: string + * credentialType?: string + * status?: string + * batchNo?: string + * }} AdminInventoryListQueryInput + */ + +/** + * @typedef {{ + * credentialType?: string + * keyword?: string + * limit?: number | string + * }} AdminInventorySkuSuggestionQueryInput + */ + +/** + * @typedef {{ + * page?: number | string + * pageSize?: number | string + * provider?: string + * platform?: string + * platformOrderId?: string + * processed?: string + * relatedOrderId?: string + * dateFrom?: string + * dateTo?: string + * }} AdminWebhookEventListQueryInput + */ + +/** + * @typedef {number | string} AdminEntityIdInput + */ diff --git a/apps/backend/src/types/admin-read-models.js b/apps/backend/src/types/admin-read-models.js new file mode 100644 index 00000000..1b427594 --- /dev/null +++ b/apps/backend/src/types/admin-read-models.js @@ -0,0 +1,204 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * totalBindingCount: number + * reservedBindingCount: number + * consumedBindingCount: number + * releasedBindingCount: number + * roleKeys: string[] + * }} AdminTaskBindingSummary + */ + +/** + * @typedef {{ + * status: string + * trigger: string + * reason: string + * platformOrderId: string + * responseStatus: number + * errorMessage: string + * requestId: string + * aldsType: number | null + * updatedAt: string | null + * }} AdminAgisoAutoDeliveryStatus + */ + +/** + * @typedef {{ + * orderId: number + * provider: string + * platform: string + * shopId: string + * shopName: string + * platformOrderId: string + * orderStatus: string + * payStatus: string + * buyerId: string + * buyerName: string + * receiverContact: string + * totalAmount: string + * totalAmountFen: number + * currency: string + * paidAt: string | null + * createdAt: string + * updatedAt: string + * itemCount: number + * totalQuantity: number + * itemSummary: string + * taskCount: number + * systemBindingStatus: string + * userBindingStatus: string + * systemBoundTaskCount: number + * completedBindingTaskCount: number + * totalBindingCount: number + * reservedBindingCount: number + * consumedBindingCount: number + * releasedBindingCount: number + * }} AdminOrderListItem + */ + +/** + * @typedef {{ + * taskId: number + * taskNo: string + * platformOrderId: string + * skuCode: string + * skuName: string + * status: string + * executorKey: string + * deliveryStatus: string + * resultCode: string + * resultMessage: string + * systemBindingStatus: string + * userBindingStatus: string + * loginType: string + * roleName: string + * roleId: string + * browserSessionId: string + * claimedAt: string | null + * roleConfirmedAt: string | null + * redeemedAt: string | null + * retryCount: number + * bindingSummary: AdminTaskBindingSummary + * lastError: string + * createdAt: string + * updatedAt: string + * inventoryDisplayMasked: string + * inventoryCredentialType: string + * claimToken: string + * screenshotPath: string + * agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null + * }} AdminTaskListItem + */ + +/** + * @typedef {{ + * inventoryItemId: number + * skuCode: string + * batchNo: string + * credentialType: string + * displayValue: string + * status: string + * reservedByTaskId: number | null + * reservedByTaskNo: string + * platformOrderId: string + * systemBindingStatus: string + * userBindingStatus: string + * invalidReason: string + * deliveredAt: string | null + * createdAt: string + * updatedAt: string + * }} AdminInventoryItemListItem + */ + +/** + * @typedef {{ + * eventId: number + * provider: string + * platform: string + * platformRaw: string + * shopId: string + * shopName: string + * eventType: string + * eventKey: string + * signatureValid: boolean + * processed: boolean + * processError: string + * relatedOrderId: number | null + * platformOrderId: string + * buyerId: string + * buyerName: string + * totalAmount: string + * totalAmountFen: number + * currency: string + * itemCount: number + * taskCount: number + * messageText: string + * requestTimestamp: string + * requestSign: string + * aopic: string + * sourceHost: string + * sourceIp: string + * userAgent: string + * createdAt: string + * headers?: Record + * query?: Record + * body?: Record + * payload?: Record + * }} AdminWebhookEventListItem + */ + +/** + * @typedef {{ + * skuCode: string + * credentialType: string + * totalCount: number + * availableCount: number + * latestUpdatedAt: string | null + * }} AdminInventorySkuSuggestion + */ + +/** + * @typedef {{ + * page: number + * pageSize: number + * total: number + * }} AdminPagination + */ + +/** + * @typedef {{ + * items: AdminOrderListItem[] + * pagination: AdminPagination + * }} AdminOrderListResponse + */ + +/** + * @typedef {{ + * items: AdminTaskListItem[] + * pagination: AdminPagination + * }} AdminTaskListResponse + */ + +/** + * @typedef {{ + * items: AdminInventoryItemListItem[] + * pagination: AdminPagination + * }} AdminInventoryListResponse + */ + +/** + * @typedef {{ + * items: AdminWebhookEventListItem[] + * pagination: AdminPagination + * }} AdminWebhookEventListResponse + */ + +/** + * @typedef {{ + * items: AdminInventorySkuSuggestion[] + * }} AdminInventorySkuSuggestionResponse + */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js new file mode 100644 index 00000000..f0a04e08 --- /dev/null +++ b/apps/backend/src/types/admin-write-inputs.js @@ -0,0 +1,56 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * skuCode?: string + * displayValue?: string + * batchNo?: string + * credentialType?: string + * }} AdminInventoryCreateInput + */ + +/** + * @typedef {{ + * skuCode?: string + * displayValue?: string + * batchNo?: string + * credentialType?: string + * }} AdminInventoryImportRowInput + */ + +/** + * @typedef {{ + * rows?: AdminInventoryImportRowInput[] + * codes?: unknown[] + * skuCode?: string + * batchNo?: string + * credentialType?: string + * }} AdminInventoryImportInput + */ + +/** + * @typedef {{ + * shopId?: string + * shopName?: string + * accessToken?: string + * enabled?: boolean | null + * messageTemplate?: string + * appSecret?: string + * apiVersion?: string + * sendMessageEndpoint?: string + * }} AdminAgisoShopConfigWriteItemInput + */ + +/** + * @typedef {{ + * shops?: AdminAgisoShopConfigWriteItemInput[] + * }} AdminAgisoShopConfigSaveInput + */ + +/** + * @typedef {{ + * reason?: string + * }} AdminInventoryInvalidateInput + */ diff --git a/apps/backend/src/types/admin-write-models.js b/apps/backend/src/types/admin-write-models.js new file mode 100644 index 00000000..c9157e0f --- /dev/null +++ b/apps/backend/src/types/admin-write-models.js @@ -0,0 +1,90 @@ +// @ts-check + +export {} + +/** + * @typedef {import('./admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem + */ + +/** + * @typedef {{ + * inventoryItem: AdminInventoryItemListItem | null + * }} AdminInventoryMutationResponse + */ + +/** + * @typedef {{ + * total: number + * created: number + * duplicated: number + * }} AdminInventoryImportResponse + */ + +/** + * @typedef {{ + * shopId: string + * shopName: string + * accessToken: string + * accessTokenMasked: string + * enabled: boolean | null + * messageTemplate: string + * appSecretConfigured: boolean + * apiVersion: string + * sendMessageEndpoint: string + * }} AdminAgisoShopConfigItem + */ + +/** + * @typedef {{ + * filePath: string + * shops: AdminAgisoShopConfigItem[] + * }} AdminAgisoShopConfigSaveResponse + */ + +/** + * @typedef {{ + * eventId: number + * replayed: boolean + * result: unknown + * }} AdminWebhookReplayResponse + */ + +/** + * @typedef {{ + * taskId: number + * taskNo: string + * status: string + * deliveryStatus: string + * resultCode: string + * resultMessage: string + * inventoryItemId: number | null + * primaryClaimTokenId: number | null + * lastError: string + * updatedAt: string + * }} AdminTaskActionPayload + */ + +/** + * @typedef {{ + * task: AdminTaskActionPayload + * claimUrl?: string + * token?: string + * }} AdminTaskActionResponse + */ + +/** + * @typedef {AdminTaskActionResponse & { + * bindingId: number + * inventoryItemId: number + * }} AdminTaskBindingReleaseResponse + */ + +/** + * @typedef {'delivered' | 'failed'} AdminManualDispatchOutcome + */ + +/** + * @typedef {AdminTaskActionResponse & { + * outcome: AdminManualDispatchOutcome + * }} AdminTaskManualDispatchResponse + */ diff --git a/apps/backend/src/types/repository-inputs.js b/apps/backend/src/types/repository-inputs.js new file mode 100644 index 00000000..27295a96 --- /dev/null +++ b/apps/backend/src/types/repository-inputs.js @@ -0,0 +1,213 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * page?: number + * pageSize?: number + * platformOrderId?: string + * payStatus?: string + * skuCode?: string + * dateFrom?: string + * dateTo?: string + * }} OrderListQueryInput + */ + +/** + * @typedef {{ + * provider: string + * platform: string + * shopId?: string + * shopName?: string + * platformOrderId: string + * orderStatus: string + * payStatus: string + * buyerId: string + * buyerName: string + * receiverContact: string + * totalAmount: number | string + * currency: string + * rawPayloadJson?: string | Record + * paidAt?: string | null + * createdAt: string + * updatedAt: string + * }} OrderUpsertInput + */ + +/** + * @typedef {{ + * skuCode: string + * skuName: string + * quantity: number + * specJson?: string | Record + * itemSnapshotJson?: string | Record + * createdAt: string + * updatedAt: string + * }} OrderItemReplaceInput + */ + +/** + * @typedef {{ + * page?: number + * pageSize?: number + * status?: string + * platformOrderId?: string + * taskNo?: string + * skuCode?: string + * roleId?: string + * dateFrom?: string + * dateTo?: string + * }} TaskListQueryInput + */ + +/** + * @typedef {{ + * browserSessionId?: string + * loginType?: string + * nickname?: string + * roleId?: string + * roleName?: string + * area?: string + * partitionName?: string + * screenshotPath?: string + * artifactsJson?: string | Record + * stateJson?: string | Record + * }} TaskTencentContextPatch + */ + +/** + * @typedef {{ + * orderId: number + * orderItemId: number + * unitIndex: number + * taskNo: string + * provider: string + * platform: string + * shopId?: string + * shopName?: string + * platformOrderId: string + * profileId: string + * executorKey: string + * taskStatus?: string + * inventoryStatus?: string + * deliveryStatus?: string + * resultCode?: string + * resultMessage?: string + * claimToken?: string + * claimExpiresAt?: string | null + * automationMode?: string + * requiresClaim?: boolean + * userActionStatus?: string + * attemptCount?: number + * lastError?: string + * contextJson?: string | Record + * createdAt: string + * updatedAt: string + * tencentContext?: TaskTencentContextPatch + * }} TaskCreateInput + */ + +/** + * @typedef {{ + * task_status?: string + * inventory_status?: string + * delivery_status?: string + * result_code?: string + * result_message?: string + * claim_token?: string + * claim_expires_at?: string | null + * automation_mode?: string + * requires_claim?: boolean + * user_action_status?: string + * attempt_count?: number + * last_error?: string + * context_json?: string | Record + * claimed_at?: string | null + * role_confirmed_at?: string | null + * redeemed_at?: string | null + * updated_at?: string + * browser_session_id?: string + * login_type?: string + * nickname?: string + * role_id?: string + * role_name?: string + * area?: string + * partition_name?: string + * screenshot_path?: string + * artifacts_json?: string | Record + * state_json?: string | Record + * }} TaskUpdatePatch + */ + +/** + * @typedef {{ + * page?: number + * pageSize?: number + * skuCode?: string + * credentialType?: string + * status?: string + * batchNo?: string + * }} InventoryListQueryInput + */ + +/** + * @typedef {{ + * credentialType?: string + * keyword?: string + * limit?: number + * }} InventorySkuSuggestionQueryInput + */ + +/** + * @typedef {{ + * batchNo?: string + * skuCode: string + * credentialType?: string + * displayValue: string + * payload?: Record + * createdAt: string + * updatedAt: string + * }} InventoryCreateItemInput + */ + +/** + * @typedef {{ + * page?: number + * pageSize?: number + * provider?: string + * platform?: string + * platformOrderId?: string + * processed?: string + * relatedOrderId?: string + * dateFrom?: string + * dateTo?: string + * }} WebhookEventListQueryInput + */ + +/** + * @typedef {{ + * provider: string + * platform: string + * shopId?: string + * shopName?: string + * eventType: string + * eventKey: string + * signatureValid?: boolean + * headersJson?: string | Record + * queryJson?: string | Record + * bodyJson?: string | Record + * processed?: boolean + * processError?: string + * relatedOrderId?: number | null + * createdAt: string + * }} WebhookEventCreateInput + */ + +/** + * @typedef {{ + * processed?: boolean + * process_error?: string + * related_order_id?: number | null + * }} WebhookEventUpdatePatch + */ diff --git a/apps/backend/src/types/repository-rows.js b/apps/backend/src/types/repository-rows.js new file mode 100644 index 00000000..dbedf629 --- /dev/null +++ b/apps/backend/src/types/repository-rows.js @@ -0,0 +1,215 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * id: number + * provider: string + * platform: string + * shop_id: string + * shop_name: string + * platform_order_id: string + * order_status: string + * pay_status: string + * buyer_id: string + * buyer_name: string + * receiver_contact: string + * total_amount: number | string + * currency: string + * raw_payload_json: string | Record + * paid_at: string | null + * created_at: string + * updated_at: string + * }} OrderRow + */ + +/** + * @typedef {OrderRow & { + * task_count?: number + * }} OrderListRow + */ + +/** + * @typedef {{ + * id: number + * order_id: number + * sku_code: string + * sku_name: string + * quantity: number + * spec_json: string | Record + * item_snapshot_json?: string | Record + * created_at?: string + * updated_at?: string + * }} OrderItemRow + */ + +/** + * @typedef {{ + * id: number + * order_id: number + * order_item_id: number + * platform_order_id: string + * task_no: string + * executor_key: string + * task_status: string + * delivery_status: string + * result_code: string + * result_message: string + * inventory_status: string + * user_action_status: string + * browser_session_id: string + * login_type: string + * role_name: string + * role_id: string + * claim_token: string + * primary_claim_token: string + * primary_claim_token_id: number | null + * primary_inventory_item_id: number | null + * inventory_display_value: string + * primary_inventory_display_value: string + * inventory_credential_type?: string + * primary_inventory_credential_type?: string + * artifacts_json: string | Record + * context_json: string | Record + * screenshot_path: string + * last_error: string + * retry_count: number + * created_at: string + * updated_at: string + * claimed_at: string | null + * role_confirmed_at: string | null + * redeemed_at: string | null + * sku_code?: string + * sku_name?: string + * }} TaskRow + */ + +/** + * @typedef {{ + * id: number + * sku_code: string + * batch_no: string + * credential_type: string + * display_value: string + * status: string + * invalid_reason: string + * delivered_at: string | null + * created_at: string + * updated_at: string + * reserved_by_task_id: number | null + * reserved_by_task_no: string + * platform_order_id: string + * }} InventoryItemRow + */ + +/** + * @typedef {{ + * sku_code: string + * credential_type: string + * total_count: number + * available_count: number + * latest_updated_at: string | null + * }} InventorySkuSuggestionRow + */ + +/** + * @typedef {{ + * id: number + * provider: string + * platform: string + * shop_id: string + * shop_name: string + * event_type: string + * event_key: string + * signature_valid: boolean + * headers_json: string | Record + * query_json: string | Record + * body_json: string | Record + * processed: boolean + * process_error: string + * related_order_id: number | null + * created_at: string + * }} WebhookEventRow + */ + +/** + * @typedef {{ + * id: number + * task_id: number + * inventory_item_id: number + * role_key: string + * quantity: number + * binding_status: string + * metadata_json: string | Record + * invalid_reason: string + * consumed_at: string | null + * released_at: string | null + * created_at: string + * updated_at: string + * sku_code: string + * batch_no: string + * credential_type: string + * display_value: string + * inventory_status: string + * }} TaskInventoryBindingRow + */ + +/** + * @typedef {{ + * task_id: number + * total_binding_count: number + * reserved_binding_count: number + * consumed_binding_count: number + * released_binding_count: number + * role_keys: string[] + * }} TaskInventoryBindingSummaryRow + */ + +/** + * @typedef {{ + * id: number + * token: string + * status: string + * expired_at: string + * created_at?: string + * updated_at?: string + * }} ClaimTokenRow + */ + +/** + * @typedef {{ + * id: number + * event_type: string + * payload_json: string | Record + * created_at: string + * }} TaskEventRow + */ + +/** + * @typedef {{ + * items: OrderListRow[] + * total: number + * }} OrderListQueryResult + */ + +/** + * @typedef {{ + * items: TaskRow[] + * total: number + * }} TaskListQueryResult + */ + +/** + * @typedef {{ + * items: InventoryItemRow[] + * total: number + * }} InventoryListQueryResult + */ + +/** + * @typedef {{ + * items: WebhookEventRow[] + * total: number + * }} WebhookEventListQueryResult + */ diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js new file mode 100644 index 00000000..8a1f3c6a --- /dev/null +++ b/apps/backend/src/types/runtime-config.js @@ -0,0 +1,102 @@ +// @ts-check + +export {} + +/** + * @typedef {{ + * username: string + * password: string + * role: string + * status?: string + * }} AdminDefaultUser + */ + +/** + * @typedef {{ + * enabled?: boolean + * shopName?: string + * accessToken?: string + * messageTemplate?: string + * appSecret?: string + * apiVersion?: string + * sendMessageEndpoint?: string + * }} AgisoMessagingShopConfig + */ + +/** + * @typedef {Record} AgisoMessagingShopsConfig + */ + +/** + * @typedef {{ + * server: { + * port: number + * } + * browser: { + * chromePath: string + * headless: boolean | null + * devtools: boolean | null + * keepAlive: boolean | null + * prewarm: boolean | null + * slowMoMs: number | null + * } + * session: { + * debug: boolean + * } + * ocr: { + * projectRoot: string + * } + * data: { + * root: string + * } + * database: { + * url: string + * ssl: boolean + * maxConnections: number + * } + * orders: { + * claimBaseUrl: string + * tokenTtlHours: number + * } + * admin: { + * sessionSecret: string + * sessionTtlHours: number + * defaultUsers: AdminDefaultUser[] + * } + * platforms: { + * agiso: { + * appSecret: string + * tradeDetail: { + * endpoint: string + * apiVersion: string + * timeoutMs: number + * } + * autoDelivery: { + * enabled: boolean + * endpoint: string + * apiVersion: string + * aldsType: number + * ignoreAldsLog: boolean + * ignoreBlackList: boolean + * ignoreOnOff: boolean + * ignoreRefundCheck: boolean + * ignoreRestricted: boolean + * ignoreTradeStatusCheck: boolean + * } + * messaging: { + * enabled: boolean + * appId: string + * accessToken: string + * appSecret: string + * apiVersion: string + * sendMessageEndpoint: string + * messageTemplate: string + * shops: AgisoMessagingShopsConfig + * } + * } + * } + * redeem: { + * proofMode: string + * } + * }} RuntimeConfig + */ diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 00000000..dca4ba2d --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowJs": true, + "checkJs": false, + "noEmit": true, + "strict": false, + "skipLibCheck": true, + "types": ["node"], + "lib": ["ES2022"] + }, + "include": [ + "src/config/runtime.js", + "src/repositories/inventory-repo.js", + "src/repositories/order-item-repo.js", + "src/repositories/order-repo.js", + "src/repositories/task-inventory-binding-repo.js", + "src/repositories/task-repo.js", + "src/repositories/webhook-event-repo.js", + "src/services/admin/admin-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/types/**/*.js" + ], + "exclude": [ + "node_modules", + "data" + ] +} diff --git a/docs/backend-refactor-status.md b/docs/backend-refactor-status.md deleted file mode 100644 index c6bc5d51..00000000 --- a/docs/backend-refactor-status.md +++ /dev/null @@ -1,255 +0,0 @@ -# Backend Refactor Status - -最后更新:2026-04-09(已切换到“测试数据可丢弃,不做历史迁移兼容”前提) - -## 当前结论 - -后端正在从“单一腾讯领取兑换工具”迁移到“通用订单履约系统”。 - -当前代码已经完成了下面两件大事: - -1. 数据层已经切到 PostgreSQL,并引入了新的通用履约模型。 -2. 仓库结构已经分出 `db / repositories / services / routes / frontend / deploy` 等清晰边界。 - -当前仍处于新旧架构并存阶段,但已经明确采用下面的推进原则: - -- 现有数据库数据视为测试数据,可直接清空 -- 不再为了旧测试数据保留历史迁移修复逻辑 -- 新配置只认当前履约模型,不再从旧映射自动推导 - -当前主要表现为: - -- 新 schema 已经使用 `fulfillment_* / inventory_items / task_inventory_bindings` -- 核心 service 主链路已经开始直接使用“主 token / 主库存绑定”语义 -- admin/public 资源名已经切到 `inventory` -- 剩余工作主要集中在“多库存绑定能力上浮”和“测试/种子数据体系化” - -## 当前真实架构 - -从工程实现视角,当前后端可以按 5 层理解: - -1. 接口层 - - `src/routes/` - - 负责接 HTTP 请求、鉴权、组装响应 -2. 应用编排层 - - `src/services/order/` - - `src/services/claim/` - - `src/services/admin/` - - 负责订单、任务、库存、领取、后台操作编排 -3. 执行能力层 - - `src/services/session/` - - `src/services/platforms/` - - 负责浏览器自动化、平台消息发送、OCR 调用 -4. 持久化层 - - `src/repositories/` - - 负责 orders、tasks、inventory、claim_tokens、webhook_events 的读写 -5. 基础设施层 - - `src/db/` - - `src/config/` - - 负责数据库连接、迁移、运行时配置、bootstrap - -## 已完成部分 - -### 1. 通用履约模型已经落库 - -当前迁移文件已经不再围绕旧式单库存码模型设计,而是引入了: - -- `fulfillment_profiles` -- `fulfillment_profile_requirements` -- `sku_fulfillment_bindings` -- `inventory_items` -- `fulfillment_tasks` -- `task_inventory_bindings` -- `tencent_browser_contexts` -- `message_deliveries` - -这说明系统底层已经支持: - -- 同一个 SKU 绑定不同履约方式 -- 不同 provider / platform / shop 的差异化履约策略 -- 一个任务绑定多个库存项 -- 不同 `credential_type` 的库存凭据 -- 手动发货与腾讯领取兑换并存 - -### 2. 履约目录 bootstrap 已经具备基础抽象 - -当前 bootstrap 中已经定义了两个核心 profile: - -- `manual_review` -- `tencent_claim_redeem` - -说明“手动发货”和“腾讯领取兑换”已经从业务概念上进入统一履约目录,而不是散落在业务代码里。 - -### 3. Admin 路由已经完成按领域拆分 - -后台路由不再全部堆在一个文件里,而是按: - -- auth -- dashboard -- users -- orders -- tasks -- inventory -- webhook-events -- platform-config - -拆成独立模块,便于继续演进。 - -### 4. 核心任务读写已经开始切回新 schema 语义 - -当前仓库层已经明确暴露: - -- `primary_claim_token_*` -- `primary_inventory_item_*` - -应用层也开始改为基于这些字段处理“主领取 token / 主库存绑定”,而不是继续把它们伪装成旧 `claim_token_id / reserved_cdk_id`。 - -另外,`claimed_at / role_confirmed_at / redeemed_at` 这些任务时间字段现在也已真正通过 repository 落库,不再只是 service 层表面传值。 - -### 5. 本地 Docker 开发栈已经重新打通 - -当前开发 Compose 已改为: - -- `postgres` 提供开发数据库 -- backend 通过 `DATABASE_URL` 连接 PostgreSQL -- 启动时自动执行 migration 与 bootstrap - -这轮还顺手修掉了一个启动顺序问题: - -- 之前 backend 启动时没有等待 migration 完成,就先执行 fulfillment catalog bootstrap -- 现已改为先 `await runDatabaseMigrations()`,再继续后续初始化 - -本地验证结果: - -- `docker compose -f docker-compose.dev.yml` 可拉起 `postgres / backend / frontend / web` -- `/health` 可正常返回 -- admin 登录可用 -- `GET /api/v1/admin/tasks` 已能正常鉴权并返回结果 - -### 6. `manual_dispatch` 已具备完整人工履约闭环 - -当前 `manual_dispatch` 不再只是把任务停在 `manual_review`,而是已经补齐成新的正式履约路径: - -- paid 后的人工履约任务会进入 `manual_review` -- admin 新增 `POST /api/v1/admin/tasks/:taskId/complete-manual-dispatch` -- 支持把人工履约结果明确回写为 `delivered` 或 `failed` -- 结果会写入 `task_status / delivery_status / result_code / result_message / context_json` -- 如果任务绑定了库存凭据,人工完成时也会同步把库存置为 consumed -- `task_events` 已开始记录人工履约完成事件 - -同时,人工履约任务已经不再允许误走旧自动链路: - -- 不再允许对 `manual_dispatch` 执行自动重试 -- 不再允许为 `manual_dispatch` 重新生成领取链接 -- admin 详情页已补上人工履约回写表单与结果展示 - -### 7. admin 任务接口开始去除旧 `CDK` 过渡命名 - -这一轮继续把任务侧接口往新模型对齐: - -- 任务释放入口已改为 `release-inventory` -- 任务 action payload 不再返回 `reservedCdkId` -- 主领取 token 字段统一为 `primaryClaimTokenId` -- 前端任务详情/列表不再依赖 `reservedCdkCodeMasked` 和 `cdk` 兼容对象 - -当前任务管理界面已经优先使用: - -- `inventoryItemId` -- `inventory.displayValue` -- `inventory.credentialType` -- `primaryClaimTokenId` - -### 8. admin 库存管理域已经切到 `inventory` 资源名 - -这一轮把库存管理域的外层入口也继续切到新模型: - -- 后端 admin API 已从 `/api/v1/admin/cdks` 切到 `/api/v1/admin/inventory` -- 前端后台路由已从 `/admin/cdks` 切到 `/admin/inventory` -- 库存管理页面、导航、审计 action、targetType 已统一改为库存项语义 -- 库存列表返回结构已改为 `inventoryItemId / displayValue / credentialType` - -现在从 URL、页面和 admin payload 角度,库存域已经不再把资源本身暴露成旧 `cdk` 名称。 - -### 9. 后端内部 `cdk` 命名主链路已经收口到 `inventory` - -这轮已经把后端主链路里的历史命名继续清理掉: - -- `repositories/cdk-repo.js` 已收口为 `repositories/inventory-repo.js` -- `services/order/cdk-service.js` 已收口为 `services/order/inventory-service.js` -- admin / claim / delivery 主链路已改用 `getInventoryItemById / reserveInventoryForTask / releaseReservedInventoryItem` 等命名 -- 任务错误文案已统一改成“库存项 / 库存凭据”语义 - -现在 `apps/backend/src` 主干里已经不再保留旧 `cdk-*` 仓储与服务文件名。 - -### 10. 开发种子数据入口已经补上 - -这轮已经补上新的开发数据入口,不再只能靠手工 webhook 或临时 SQL 验证: - -- 新增 `apps/backend/scripts/seed-dev-data.js` -- 新增 `npm run seed:dev-data` -- 支持 `--apply --reset` 先清库再重建固定场景 -- 当前已覆盖: - - `manual_pending` - - `manual_completed` - - `claim_ready` - - `claim_claimed` - - `claim_role_confirmed` - - `claim_retry_pending` - - `claim_expired` - - `claim_waiting_inventory` - -这意味着后续继续改任务列表、订单详情、履约编排时,已经有一套可重复验证的最小场景。 - -## 当前未完成部分 - -### 1. 多凭据任务能力还没有完全上浮到应用层 - -底层已经支持一个任务绑定多个库存项,但上层展示和操作仍偏向: - -- 虽然 admin 任务详情已经开始返回并展示 `inventoryBindings` -- 任务列表和订单详情已经开始展示绑定摘要计数 -- 但列表操作入口和大部分应用编排仍主要围绕一个主库存项 -- 一个任务只暴露一个领取 token -- 后台操作还没有按“绑定集合”粒度设计 - -### 2. 测试数据初始化还不够体系化 - -虽然已经明确“测试数据可丢弃”,也已经有 PostgreSQL 清库脚本和基础 seed 脚本,但当前场景库仍然偏薄。 - -还缺: - -- `redeeming / redeemed with screenshot / claim token revoked` 等更靠近真实自动化结果的场景 -- 更接近真实订单组合的多任务、多数量、多库存绑定样本 - -### 3. 任务状态与库存绑定状态仍可进一步解耦 - -当前虽然已经有 `task_status / delivery_status / inventory_status / task_inventory_bindings` 四套状态,但 admin 视角仍以“任务主状态”为中心: - -- 详情页已经开始展示库存绑定集合 -- 任务列表和订单详情已经开始展示绑定摘要 -- 但订单列表、任务操作入口还没有把多库存绑定事件完整展开 -- `manual_dispatch` 与自动领取任务共用的列表状态摘要还比较粗 -- 订单编排层还没有把“多库存、多步骤”履约任务展开成更清晰的应用层对象 - -## 现在最该继续做的事 - -1. 把“单个主库存项”心智继续升级为“任务库存绑定集合” -2. 让 admin 层逐步暴露多库存绑定、多履约步骤的真实状态 -3. 在现有 seed 基础上继续扩充更多任务状态和编排场景 -4. 继续把订单列表和任务操作入口也切到绑定集合视角 - -当前这件事已经继续往前走了一步: - -- 旧 SQLite 思路的 `scripts/cleanup-dev-data.js` 已经替换为基于 PostgreSQL 新 schema 的清库脚本 -- admin 任务详情已开始暴露并展示 `task_inventory_bindings` 集合 -- admin 任务列表和订单详情已开始消费绑定摘要计数 -- `scripts/seed-dev-data.js` 已扩展到 claim 主流程多状态样本,bootstrap 仍然只负责 profile/catalog,不负责完整业务测试数据生成 - -## 文档说明 - -以下旧文档已删除,不再作为事实来源: - -- `apps/backend/方案A-订单编排与自动兑换设计.md` -- `apps/backend/阶段1-4实施规格说明.md` - -后续请以代码、迁移文件和本状态文档为准。 diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md new file mode 100644 index 00000000..b1741cec --- /dev/null +++ b/docs/backend-typescript-migration-plan.md @@ -0,0 +1,197 @@ +# Backend TypeScript 迁移计划 + +最后更新:2026-04-14 + +## 结论 + +当前后端不建议整体重写为 Go。 + +更合适的路线是: + +1. 保留 Node.js + Express + Playwright + PostgreSQL 主栈 +2. 先把后端渐进迁移到 TypeScript +3. 同时拆分超大 service 文件 +4. 补足核心业务链路测试 + +这样可以先解决“可维护性”和“改动风险”问题,而不会额外引入一次高成本的跨语言重写。 + +## 为什么现在不直接重写 Go + +当前后端的主要复杂度并不来自语言本身,而是来自: + +- 订单、任务、库存、领取、自动发货、webhook 多条链路同时演进 +- Playwright 浏览器自动化本身就天然偏 Node 生态 +- 若整体换成 Go,浏览器自动化大概率仍需保留 Node worker +- 这样会把系统变成 Go + Node + Python 三段式,边界更多,联调更难 + +所以现阶段最有效的动作,不是换语言,而是先把现有 Node 后端“类型化、分层化、可测试化”。 + +## 当前主要维护痛点 + +从当前仓库状态看,风险主要集中在: + +- 少数超大编排文件已经承担过多职责 + - `apps/backend/src/services/admin/admin-service.js` + - `apps/backend/src/services/claim/claim-session-service.js` + - `apps/backend/src/services/order/webhook-service.js` + - `apps/backend/src/services/session/session.js` +- 领域对象靠运行时约定在 routes / services / repositories 之间传递 +- 当前自动化测试覆盖还偏薄,跨链路改动缺少回归保护 + +## 迁移目标 + +本次迁移不是为了“全部改成 .ts 才算完成”,而是为了达到下面几个目标: + +1. 让核心领域对象具备稳定的静态类型 +2. 让新增改动优先进入类型系统,而不是继续扩大纯 JS 面积 +3. 让超大 service 文件拆分时有类型边界可依赖 +4. 让 typecheck 和测试一起成为后端的标准校验步骤 + +## 分阶段计划 + +### 阶段 1:建立类型基础设施 + +目标: + +- 引入后端 `tsconfig` +- 增加 `npm run typecheck` +- 先从最核心的共享配置和基础领域对象开始建类型 +- 暂不改变现有运行方式,不引入编译产物,不影响 Docker 启动 + +范围: + +- `runtimeConfig` +- 公共配置对象 +- 后续会继续补: + - admin DTO + - order / task / inventory 基础类型 + - webhook 解析结果类型 + +验收标准: + +- 后端可以执行 `npm run typecheck` +- 现有 `npm run dev` / `npm run start` / Docker 启动流程不受影响 + +### 阶段 2:按领域拆分超大 service + +目标: + +- 保持运行逻辑不变 +- 先把“巨石编排文件”拆成更小的用例编排模块 + +建议拆分顺序: + +1. `admin-service.js` + - `admin-order-service` + - `admin-task-service` + - `admin-inventory-service` + - `admin-webhook-service` +2. `claim-session-service.js` + - `claim-session-lifecycle` + - `claim-role-service` + - `claim-redeem-orchestrator` +3. `webhook-service.js` + - `webhook-parse` + - `webhook-validate` + - `webhook-process` + +### 阶段 3:扩大类型覆盖 + +目标: + +- 新增模块优先使用 `.ts` +- 旧模块逐步迁移,而不是一次性全改 +- 让 routes / services / repositories 之间共享同一套领域类型 + +建议优先顺序: + +1. `src/types/` 下沉淀共享领域模型 +2. repository 返回值显式类型化 +3. service 入参 / 出参类型化 +4. 再迁移最稳定的新模块到 `.ts` + +### 阶段 4:补核心回归测试 + +优先补这些主链路: + +1. webhook 入单与重放 +2. 库存预占 / 释放 / 换码 +3. claim 会话创建 / 关闭 / 切换 +4. 腾讯兑换结果分类 +5. 自动发货触发条件 + +## 已开始执行的第一步 + +本轮已经落地: + +1. backend 新增 `tsconfig.json` +2. backend 新增 `npm run typecheck` +3. 新增 `src/types/runtime-config.js` +4. `src/config/runtime.js` 已接入第一批类型检查 +5. 新增 `src/types/admin-read-models.js` +6. `src/services/admin/admin-service.js` 的订单 / 任务 / 库存 / webhook 读取返回模型已接入第二批类型检查 +7. 新增 `src/types/repository-rows.js` +8. `admin-service.js` 的核心读取映射函数参数,已开始明确依赖 repository 行模型 +9. `order / task / inventory / webhook / order-item / task-inventory-binding` repository 已补第一批返回类型 +10. backend `tsconfig.json` 已将上述 repository 纳入显式 `typecheck` 范围 +11. `npm --prefix apps/backend run typecheck` 已在 repository 扩围后通过 +12. 新增 `src/types/repository-inputs.js` +13. `order / task / inventory / webhook / order-item` repository 已开始显式使用共享输入类型 +14. `npm --prefix apps/backend run typecheck` 已在输入类型接入后继续通过 +15. 新增 `src/services/admin/admin-read-service.js`,开始承接后台查询型接口 +16. `orders / tasks / inventory / webhook-events` 管理路由已优先切到 `admin-read-service` +17. 读服务首轮拆分后,backend `typecheck` 继续通过 +18. 新增 `src/services/admin/admin-read-helpers.js`,开始承接读侧共享 helper +19. `admin-read-service.js` 已改为优先依赖 `admin-read-helpers.js`,不再直接依赖 `admin-service.js` +20. helper 下沉后,backend `typecheck` 继续通过 +21. `admin-service.js` 已开始复用 `admin-read-helpers.js`,并删除首批重复的读侧 helper +22. `order / task / inventory / webhook` repository 已补共享查询参数类型 +23. `npm --prefix apps/backend run typecheck` 已在查询参数类型接入后继续通过 +24. 新增 `src/types/admin-read-inputs.js`,开始承接后台读接口的 service 入参边界 +25. `admin-read-service.js` 的订单 / 任务 / 库存 / webhook 查询与详情入口已接入共享输入类型 +26. `npm --prefix apps/backend run typecheck` 已在 service 输入类型接入后继续通过 +27. 新增 `src/services/admin/admin-write-service.js`,开始承接后台库存写接口 +28. `inventory` 管理路由已切到 `admin-write-service`,`admin-service.js` 删除首批库存写侧实现 +29. 新增 `src/types/admin-write-inputs.js`,库存写接口已开始复用共享输入类型 +30. backend `typecheck` 已覆盖 `admin-write-service.js` +31. 新增 `src/types/admin-write-models.js`,库存写接口返回结构已接入共享响应类型 +32. `admin-write-service.js` 的 create / import / release / invalidate 已补返回值 JSDoc +33. `webhook replay` 与 `Agiso 店铺配置保存` 已下沉到 `admin-write-service.js` +34. `webhook-events / platform-config` 写路由已切到 `admin-write-service` +35. `admin-service.js` 删除第二批可独立的写侧实现,并保留兼容导出 +36. `task` 生命周期写操作第一批已下沉到 `admin-write-service.js` +37. `tasks` 管理路由已切换首批 task 写接口到 `admin-write-service` +38. 新增 task 写侧共享响应类型,task action / binding release 已接入 JSDoc +39. `retryAdminTask` 与 `completeAdminTaskManualDispatch` 已下沉到 `admin-write-service.js` +40. `tasks` 管理路由已全部切到 `admin-write-service` 承接 task 写接口 +41. `admin-service.js` 已删除 task 写侧主体实现,仅保留兼容导出 +42. 新增 `admin-platform-config-service.js`,开始承接 platform-config 整块能力 +43. `platform-config` 路由已切换到独立 service,不再经过 `admin-service.js` +44. `admin-service.js` 已删除平台配置相关读写与 helper,实现进一步收敛 +45. 新增 `admin-dashboard-service.js`,后台概览已独立 +46. 新增 `admin-message-delivery-service.js`,消息发送记录查询已独立 +47. `dashboard / message-deliveries` 路由已直接依赖独立 service +48. `admin-service.js` 已收敛为兼容导出层,不再直接承载后台业务实现 +49. 后台读 / 写 / 平台配置 / 概览 / 消息记录已分散到独立 service 模块 + +这一步的设计原则是“只加静态约束,不动运行链路”,所以不会影响: + +- `npm run dev` +- `npm run start` +- Docker 开发 / 部署 + +## 下一步建议 + +第一批继续推进时,建议按这个顺序: + +1. 为 `admin` 路由层补请求体 / 查询参数的共享输入类型 +2. 评估是否把部分 `admin-read-helpers.js` 再细拆成按领域 helper 模块 +3. 继续补 repository / service 的边界约束,但不急着改 `.ts` 扩展名 +4. 为 webhook、库存换码、自动发货补测试 + +## 执行原则 + +- 不为迁移而迁移,优先迁移最常改、最容易改坏的链路 +- 不追求一口气全量改 `.ts` +- 优先建立“新代码进入类型系统”的机制 +- 每一步都要求可回滚、可验证、不中断现有开发流