From 60054adcc45dffbb2d9228634e597b17194e1df1 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 9 Apr 2026 12:24:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E9=87=8D=E6=9E=84-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development.example | 1 - .env.production.example | 1 - README.md | 9 +- apps/backend/README.md | 1 + apps/backend/config/default.cjs | 1 - apps/backend/scripts/cleanup-dev-data.js | 492 +------- apps/backend/src/config/runtime.js | 5 - apps/backend/src/index.js | 12 +- .../src/repositories/claim-token-repo.js | 10 + apps/backend/src/repositories/task-repo.js | 26 +- apps/backend/src/routes/admin.js | 473 +------ apps/backend/src/routes/admin/audit-logs.js | 19 + apps/backend/src/routes/admin/auth.js | 35 + apps/backend/src/routes/admin/cdks.js | 79 ++ apps/backend/src/routes/admin/dashboard.js | 17 + apps/backend/src/routes/admin/orders.js | 26 + .../src/routes/admin/platform-config.js | 37 + apps/backend/src/routes/admin/shared.js | 84 ++ apps/backend/src/routes/admin/tasks.js | 128 ++ apps/backend/src/routes/admin/users.js | 97 ++ .../src/routes/admin/webhook-events.js | 48 + .../src/services/admin/admin-audit-service.js | 36 +- .../src/services/admin/admin-auth-service.js | 14 +- .../src/services/admin/admin-query-utils.js | 38 + .../src/services/admin/admin-service.js | 388 +++--- .../fulfillment-bootstrap-service.js | 21 +- .../services/claim/claim-session-service.js | 59 +- .../services/order/delivery-task-service.js | 137 +- .../services/order/order-repair-service.js | 222 ---- .../src/services/order/order-service.js | 16 +- .../src/services/order/webhook-service.js | 15 - apps/backend/方案A-订单编排与自动兑换设计.md | 1077 ---------------- apps/backend/阶段1-4实施规格说明.md | 1111 ----------------- apps/frontend/src/types/admin.ts | 11 +- docker-compose.dev.yml | 28 +- docker-compose.yml | 26 +- docs/backend-refactor-status.md | 154 +++ 37 files changed, 1324 insertions(+), 3630 deletions(-) create mode 100644 apps/backend/src/routes/admin/audit-logs.js create mode 100644 apps/backend/src/routes/admin/auth.js create mode 100644 apps/backend/src/routes/admin/cdks.js create mode 100644 apps/backend/src/routes/admin/dashboard.js create mode 100644 apps/backend/src/routes/admin/orders.js create mode 100644 apps/backend/src/routes/admin/platform-config.js create mode 100644 apps/backend/src/routes/admin/shared.js create mode 100644 apps/backend/src/routes/admin/tasks.js create mode 100644 apps/backend/src/routes/admin/users.js create mode 100644 apps/backend/src/routes/admin/webhook-events.js create mode 100644 apps/backend/src/services/admin/admin-query-utils.js delete mode 100644 apps/backend/src/services/order/order-repair-service.js delete mode 100644 apps/backend/方案A-订单编排与自动兑换设计.md delete mode 100644 apps/backend/阶段1-4实施规格说明.md create mode 100644 docs/backend-refactor-status.md diff --git a/.env.development.example b/.env.development.example index 22a2395b..50d8d43b 100644 --- a/.env.development.example +++ b/.env.development.example @@ -22,7 +22,6 @@ TENCENT_SESSION_DEBUG=false ADMIN_SESSION_SECRET=dev-local-session-secret ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] -ORDER_SKU_MAPPINGS_JSON={"32768":"dnf-cdk-a","dnf-cdk-a":"dnf-cdk-a"} ORDER_FULFILLMENT_BINDINGS_JSON=[] AGISO_APP_SECRET= diff --git a/.env.production.example b/.env.production.example index 6e277161..0f359cae 100644 --- a/.env.production.example +++ b/.env.production.example @@ -20,7 +20,6 @@ TENCENT_SESSION_DEBUG=false ADMIN_SESSION_SECRET=replace-with-a-long-random-secret ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"replace-with-strong-admin-password","role":"admin"},{"username":"operator","password":"replace-with-strong-operator-password","role":"operator"}] -ORDER_SKU_MAPPINGS_JSON={"32768":"dnf-cdk-a","dnf-cdk-a":"dnf-cdk-a"} ORDER_FULFILLMENT_BINDINGS_JSON=[] AGISO_APP_SECRET= diff --git a/README.md b/README.md index 8c03cbb3..fdf0409c 100644 --- a/README.md +++ b/README.md @@ -53,23 +53,27 @@ docker compose -f docker-compose.dev.yml up -d --build 开发版 Compose 现在是热更新模式: +- `postgres` 容器提供 PostgreSQL - 后端源码挂载到容器里,运行 `npm run dev` - 前端源码挂载到容器里,运行 `vite` - Caddy 只负责把 `80/443` 反代到前后端容器 通常只有首次启动、改 Dockerfile、改系统依赖时才需要 `--build`。 -开发版 Compose 会把后端数据目录直接挂载到: +开发版 Compose 会把后端运行产物目录直接挂载到: - [apps/backend/data](/Users/yml/codes/order-site-workspace/apps/backend/data) 这样本地可以直接看到: -- SQLite 数据库 - `data/logs/*.log` - 浏览器会话产物 - 截图和证明文件 +PostgreSQL 数据则保存在 Docker volume 里: + +- `postgres_dev_data` + 当前 Dockerfile 已默认针对国内服务器优化以下下载源: - Debian `apt` 使用腾讯云镜像 @@ -97,6 +101,7 @@ docker compose build \ - 改后端 `src/`:容器内自动热重启 - 改前端 `src/`:Vite 自动热更新 - 改 OCR Python:后端下次调用时直接走挂载后的最新源码 +- 改数据库 schema:后端启动时会自动执行 migration 详细文档: diff --git a/apps/backend/README.md b/apps/backend/README.md index a305ae30..f9899abc 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -108,6 +108,7 @@ npm run start 当前 `docker-compose.dev.yml` 已切到源码挂载模式: +- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接 - 后端容器启动时执行 `npm install` 和 `npm run dev` - OCR 子服务会在容器里执行 `pip install -e /app/subservices/ocr-worker` - 所以平时改 `src/`、改 OCR Python、改 `.env`,一般都不需要重建镜像 diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index 4500867a..394d3a60 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -35,7 +35,6 @@ module.exports = { orders: { claimBaseUrl: 'http://127.0.0.1:5173/#/claim', tokenTtlHours: 24, - skuMappings: {}, fulfillmentBindings: [], }, diff --git a/apps/backend/scripts/cleanup-dev-data.js b/apps/backend/scripts/cleanup-dev-data.js index 9b1bb53a..3f92bea4 100644 --- a/apps/backend/scripts/cleanup-dev-data.js +++ b/apps/backend/scripts/cleanup-dev-data.js @@ -1,21 +1,22 @@ #!/usr/bin/env node -import fs from 'node:fs' -import path from 'node:path' import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { DatabaseSync } from 'node:sqlite' -const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) -const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..') -const DEFAULT_DB_PATH = path.join(PROJECT_ROOT, 'data', 'order-site.db') +import { closeDb, query } from '../src/db/client.js' +import { runtimeConfig } from '../src/config/runtime.js' -const PRESETS = { - 'codex-debug': { - platformOrderIds: ['DEBUG-ORDER-001', '2067719225654999', '2067719225655000', '2067719225655001'], - shopIds: ['debug-shop', 'shop-10001', 'shop-10002', 'shop-10003'], - }, -} +const RESET_TABLES = [ + 'message_deliveries', + 'tencent_browser_contexts', + 'task_events', + 'claim_tokens', + 'task_inventory_bindings', + 'fulfillment_tasks', + 'order_items', + 'orders', + 'webhook_events', + 'inventory_items', +] const options = parseArgs(process.argv.slice(2)) @@ -24,437 +25,74 @@ if (options.help) { process.exit(0) } -const preset = options.preset ? PRESETS[options.preset] : null -if (options.preset && !preset) { - console.error(`未知 preset: ${options.preset}`) - printHelp() - process.exit(1) -} - -const selectors = { - provider: options.provider, - platform: options.platform, - orderIds: normalizeNumberList(options.orderIds), - platformOrderIds: uniqueList([...(preset?.platformOrderIds || []), ...options.platformOrderIds]), - shopIds: uniqueList([...(preset?.shopIds || []), ...options.shopIds]), - shopNames: uniqueList(options.shopNames), -} - -if (!hasAnySelector(selectors)) { - console.error('至少需要一个筛选条件,或使用 --preset codex-debug') - printHelp() - process.exit(1) -} - -const dbPath = resolveDbPath(options.dbPath) -const db = new DatabaseSync(dbPath) -db.exec('PRAGMA foreign_keys = ON;') -db.exec('PRAGMA busy_timeout = 3000;') - -const matched = collectMatches(db, selectors) - -printSummary(dbPath, selectors, matched, options.apply) - -if (!options.apply) { - console.log('\n当前为预览模式。确认后追加 `--apply` 执行删除。') - process.exit(0) -} - -db.exec('BEGIN') - try { - deleteByIds(db, 'message_deliveries', matched.messageDeliveryIds) - deleteByIds(db, 'webhook_events', matched.webhookEventIds) - deleteByIds(db, 'orders', matched.orderIds) - db.exec('COMMIT') - console.log('\n清理完成。若后台服务正在运行,建议重启一次 backend 容器,确保管理后台视图立即刷新。') + const counts = await collectCounts() + + printSummary(counts) + + if (!options.apply) { + console.log('\n当前为预览模式。确认后追加 `--apply` 执行清库。') + process.exit(0) + } + + await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`) + + console.log('\n已清空测试业务数据。') + console.log('保留内容:fulfillment profiles/bindings、admin users、配置文件。') } catch (error) { - db.exec('ROLLBACK') console.error('\n清理失败:', error instanceof Error ? error.message : String(error || '未知错误')) - process.exit(1) + process.exitCode = 1 +} finally { + await closeDb() } -function collectMatches(db, selectors) { - const orderWhere = buildOrderWhere(selectors) - const orders = db.prepare(` - SELECT id, provider, platform, shop_id, shop_name, platform_order_id - FROM orders - ${orderWhere.sql} - ORDER BY id DESC - `).all(...orderWhere.params) +async function collectCounts() { + const output = [] - const orderIds = orders.map((item) => Number(item.id)) - const taskIds = orderIds.length > 0 - ? db.prepare(` - SELECT id - FROM delivery_tasks - WHERE order_id IN (${placeholders(orderIds.length)}) - ORDER BY id DESC - `).all(...orderIds).map((item) => Number(item.id)) - : [] - - const webhookWhere = buildWebhookWhere(selectors, orderIds) - const webhookEvents = db.prepare(` - SELECT id, provider, platform, shop_id, shop_name, event_type, event_key, related_order_id - FROM webhook_events - ${webhookWhere.sql} - ORDER BY id DESC - `).all(...webhookWhere.params) - - const messageWhere = buildMessageWhere(selectors, orderIds, taskIds) - const messageDeliveries = db.prepare(` - SELECT id, provider, platform, shop_id, shop_name, platform_order_id, status - FROM message_deliveries - ${messageWhere.sql} - ORDER BY id DESC - `).all(...messageWhere.params) - - return { - orders, - orderIds, - taskIds, - webhookEvents, - webhookEventIds: webhookEvents.map((item) => Number(item.id)), - messageDeliveries, - messageDeliveryIds: messageDeliveries.map((item) => Number(item.id)), - } -} - -function buildOrderWhere(selectors) { - const baseFilters = [] - const matchFilters = [] - const params = [] - const matchParams = [] - - if (selectors.provider) { - baseFilters.push('provider = ?') - params.push(selectors.provider) - } - - if (selectors.platform) { - baseFilters.push('platform = ?') - params.push(selectors.platform) - } - - if (selectors.orderIds.length > 0) { - matchFilters.push(`id IN (${placeholders(selectors.orderIds.length)})`) - matchParams.push(...selectors.orderIds) - } - - if (selectors.platformOrderIds.length > 0) { - matchFilters.push(`platform_order_id IN (${placeholders(selectors.platformOrderIds.length)})`) - matchParams.push(...selectors.platformOrderIds) - } - - if (selectors.shopIds.length > 0) { - matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`) - matchParams.push(...selectors.shopIds) - } - - if (selectors.shopNames.length > 0) { - matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`) - matchParams.push(...selectors.shopNames) - } - - const filters = [...baseFilters] - if (matchFilters.length > 0) { - filters.push(`(${matchFilters.join(' OR ')})`) - params.push(...matchParams) - } - - return { - sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -function buildWebhookWhere(selectors, orderIds) { - const baseFilters = [] - const matchFilters = [] - const params = [] - const matchParams = [] - - if (selectors.provider) { - baseFilters.push('provider = ?') - params.push(selectors.provider) - } - - if (selectors.platform) { - baseFilters.push('platform = ?') - params.push(selectors.platform) - } - - if (orderIds.length > 0) { - matchFilters.push(`related_order_id IN (${placeholders(orderIds.length)})`) - matchParams.push(...orderIds) - } - - if (selectors.shopIds.length > 0) { - matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`) - matchParams.push(...selectors.shopIds) - } - - if (selectors.shopNames.length > 0) { - matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`) - matchParams.push(...selectors.shopNames) - } - - if (selectors.platformOrderIds.length > 0) { - const eventKeyFilters = selectors.platformOrderIds.map(() => 'event_key LIKE ?').join(' OR ') - const bodyFilters = selectors.platformOrderIds.map(() => 'body_json LIKE ?').join(' OR ') - matchFilters.push(`(${eventKeyFilters})`) - matchParams.push(...selectors.platformOrderIds.map((item) => `%${item}%`)) - matchFilters.push(`(${bodyFilters})`) - matchParams.push(...selectors.platformOrderIds.map((item) => `%${item}%`)) - } - - const filters = [...baseFilters] - if (matchFilters.length > 0) { - filters.push(`(${matchFilters.join(' OR ')})`) - params.push(...matchParams) - } - - return { - sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -function buildMessageWhere(selectors, orderIds, taskIds) { - const baseFilters = [] - const matchFilters = [] - const params = [] - const matchParams = [] - - if (selectors.provider) { - baseFilters.push('provider = ?') - params.push(selectors.provider) - } - - if (selectors.platform) { - baseFilters.push('platform = ?') - params.push(selectors.platform) - } - - if (orderIds.length > 0) { - matchFilters.push(`order_id IN (${placeholders(orderIds.length)})`) - matchParams.push(...orderIds) - } - - if (taskIds.length > 0) { - matchFilters.push(`task_id IN (${placeholders(taskIds.length)})`) - matchParams.push(...taskIds) - } - - if (selectors.shopIds.length > 0) { - matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`) - matchParams.push(...selectors.shopIds) - } - - if (selectors.shopNames.length > 0) { - matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`) - matchParams.push(...selectors.shopNames) - } - - if (selectors.platformOrderIds.length > 0) { - matchFilters.push(`platform_order_id IN (${placeholders(selectors.platformOrderIds.length)})`) - matchParams.push(...selectors.platformOrderIds) - } - - const filters = [...baseFilters] - if (matchFilters.length > 0) { - filters.push(`(${matchFilters.join(' OR ')})`) - params.push(...matchParams) - } - - return { - sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -function deleteByIds(db, tableName, ids) { - if (!Array.isArray(ids) || ids.length === 0) { - return - } - - db.prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders(ids.length)})`).run(...ids) -} - -function resolveDbPath(overridePath) { - const candidates = [ - String(overridePath || '').trim(), - DEFAULT_DB_PATH, - ].filter(Boolean) - - for (const candidate of candidates) { - const resolved = path.resolve(candidate) - if (fs.existsSync(resolved)) { - return resolved - } - } - - console.error(`未找到数据库文件。默认查找路径: ${DEFAULT_DB_PATH}`) - console.error('可使用 `--db-path ` 手动指定。') - process.exit(1) -} - -function parseArgs(argv) { - const output = { - help: false, - apply: false, - dbPath: '', - preset: '', - provider: '', - platform: '', - orderIds: [], - platformOrderIds: [], - shopIds: [], - shopNames: [], - } - - for (let index = 0; index < argv.length; index += 1) { - const current = String(argv[index] || '').trim() - - if (!current) { - continue - } - - if (current === '--help' || current === '-h') { - output.help = true - continue - } - - if (current === '--apply') { - output.apply = true - continue - } - - if (current === '--db-path') { - output.dbPath = String(argv[index + 1] || '').trim() - index += 1 - continue - } - - if (current === '--preset') { - output.preset = String(argv[index + 1] || '').trim() - index += 1 - continue - } - - if (current === '--provider') { - output.provider = String(argv[index + 1] || '').trim() - index += 1 - continue - } - - if (current === '--platform') { - output.platform = String(argv[index + 1] || '').trim() - index += 1 - continue - } - - if (current === '--order-id') { - output.orderIds.push(String(argv[index + 1] || '').trim()) - index += 1 - continue - } - - if (current === '--platform-order-id') { - output.platformOrderIds.push(String(argv[index + 1] || '').trim()) - index += 1 - continue - } - - if (current === '--shop-id') { - output.shopIds.push(String(argv[index + 1] || '').trim()) - index += 1 - continue - } - - if (current === '--shop-name') { - output.shopNames.push(String(argv[index + 1] || '').trim()) - index += 1 - continue - } - - console.error(`未知参数: ${current}`) - printHelp() - process.exit(1) + for (const tableName of RESET_TABLES) { + const result = await query(`SELECT COUNT(*)::int AS total FROM ${tableName}`) + output.push({ + tableName, + total: Number(result.rows[0]?.total || 0), + }) } return output } -function printSummary(dbPath, selectors, matched, apply) { - console.log(`数据库: ${dbPath}`) - console.log(`模式: ${apply ? '执行删除' : '预览'}`) - console.log('筛选条件:') - console.log(JSON.stringify(selectors, null, 2)) - - console.log('\n匹配结果:') - console.log(`orders: ${matched.orders.length}`) - printRows(matched.orders, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'platform_order_id']) - console.log(`delivery_tasks: ${matched.taskIds.length}`) - console.log(`webhook_events: ${matched.webhookEvents.length}`) - printRows(matched.webhookEvents, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'event_type', 'related_order_id']) - console.log(`message_deliveries: ${matched.messageDeliveries.length}`) - printRows(matched.messageDeliveries, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'platform_order_id', 'status']) -} - -function printRows(rows, keys) { - if (!Array.isArray(rows) || rows.length === 0) { - return +function parseArgs(argv) { + return { + apply: argv.includes('--apply'), + help: argv.includes('-h') || argv.includes('--help'), } - - const preview = rows.slice(0, 10).map((row) => Object.fromEntries(keys.map((key) => [key, row[key]]))) - console.log(JSON.stringify(preview, null, 2)) -} - -function placeholders(count) { - return new Array(count).fill('?').join(', ') -} - -function uniqueList(values) { - return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))] -} - -function normalizeNumberList(values) { - return uniqueList(values) - .map((item) => Number(item)) - .filter((item) => Number.isInteger(item) && item > 0) -} - -function hasAnySelector(selectors) { - return Boolean( - selectors.provider || - selectors.platform || - selectors.orderIds.length > 0 || - selectors.platformOrderIds.length > 0 || - selectors.shopIds.length > 0 || - selectors.shopNames.length > 0, - ) } function printHelp() { console.log(` 用法: - node scripts/cleanup-dev-data.js [筛选条件] [--apply] + node scripts/cleanup-dev-data.js [--apply] -常用参数: - --platform-order-id 按平台订单号删除,可重复传入 - --shop-id 按店铺 ID 删除,可重复传入 - --shop-name 按店铺名删除,可重复传入 - --order-id 按内部订单 ID 删除,可重复传入 - --provider 限定 provider,例如 agiso - --platform 限定业务平台,例如 xianyu - --db-path 手动指定数据库文件 - --preset codex-debug 清理当前对话里生成的调试样例 - --apply 真正执行删除;不传时只预览 +说明: + 基于当前 PostgreSQL fulfillment schema 清空测试业务数据。 + 默认只预览各表记录数,追加 --apply 后执行 TRUNCATE。 -示例: - npm run cleanup:dev-data -- --platform-order-id DEBUG-ORDER-001 - npm run cleanup:dev-data -- --shop-id shop-10003 --apply - npm run cleanup:dev-data -- --preset codex-debug --apply - `) +会清空: + ${RESET_TABLES.join('\n ')} + +会保留: + fulfillment_profiles / fulfillment_profile_requirements / sku_fulfillment_bindings + admin_users / admin_audit_logs + 本地配置文件 + 数据库连接: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'} +`.trim()) +} + +function printSummary(counts) { + const totalRows = counts.reduce((sum, item) => sum + item.total, 0) + + console.log('准备清理当前测试业务数据:') + for (const item of counts) { + console.log(`- ${item.tableName}: ${item.total}`) + } + console.log(`- total: ${totalRows}`) } diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index a6f39284..3dda3fe2 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -156,11 +156,6 @@ function applyEnvOverrides(baseConfig) { nextConfig.orders.tokenTtlHours = tokenTtlHours } - const skuMappings = parseJsonObject(process.env.ORDER_SKU_MAPPINGS_JSON) - if (skuMappings) { - nextConfig.orders.skuMappings = skuMappings - } - const fulfillmentBindings = parseJsonArray(process.env.ORDER_FULFILLMENT_BINDINGS_JSON) if (fulfillmentBindings) { nextConfig.orders.fulfillmentBindings = fulfillmentBindings diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index 43826966..808e88b9 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -7,7 +7,7 @@ import adminRouter from './routes/admin.js' import claimsRouter from './routes/claims.js' import webhooksRouter from './routes/webhooks.js' import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js' -import { repairAgisoOrderData } from './services/order/order-repair-service.js' +import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js' import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js' import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js' import { logError, logInfo, logWarn } from './utils/logger.js' @@ -17,8 +17,8 @@ const app = express() const port = Number(runtimeConfig.server.port || 3000) runDatabaseMigrations() +await ensureFulfillmentCatalogBootstrapped() await ensureAdminUsersBootstrapped() -await repairHistoricalOrders() app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*') @@ -91,14 +91,6 @@ async function bootstrapOcr() { } } -async function repairHistoricalOrders() { - try { - await repairAgisoOrderData() - } catch (error) { - logWarn('[startup]', `历史 Agiso 咸鱼订单修复跳过: ${formatStartupError(error)}`) - } -} - function formatStartupError(error) { if (error instanceof Error && error.message) { return error.message.split('\n')[0].trim() diff --git a/apps/backend/src/repositories/claim-token-repo.js b/apps/backend/src/repositories/claim-token-repo.js index 06996dad..d063d11f 100644 --- a/apps/backend/src/repositories/claim-token-repo.js +++ b/apps/backend/src/repositories/claim-token-repo.js @@ -14,6 +14,16 @@ export async function createClaimToken(input) { created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (task_id) DO UPDATE + SET + token = EXCLUDED.token, + status = EXCLUDED.status, + expired_at = EXCLUDED.expired_at, + used_at = EXCLUDED.used_at, + max_use_count = EXCLUDED.max_use_count, + used_count = EXCLUDED.used_count, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at RETURNING * `, [ diff --git a/apps/backend/src/repositories/task-repo.js b/apps/backend/src/repositories/task-repo.js index 96309495..4b8c2f5e 100644 --- a/apps/backend/src/repositories/task-repo.js +++ b/apps/backend/src/repositories/task-repo.js @@ -2,10 +2,10 @@ import { query, withTransaction } from '../db/client.js' const TASK_FIELDS = ` ft.*, - ct.id AS claim_token_id, - ct.token AS claim_token, - ct.status AS claim_token_status, - ct.expired_at AS claim_token_expired_at, + ct.id AS primary_claim_token_id, + ct.token AS primary_claim_token, + ct.status AS primary_claim_token_status, + ct.expired_at AS primary_claim_expires_at, ctx.browser_session_id, ctx.login_type, ctx.nickname, @@ -16,10 +16,10 @@ const TASK_FIELDS = ` ctx.screenshot_path, ctx.artifacts_json, ctx.state_json, - inv.inventory_item_id AS reserved_cdk_id, - inv.display_value AS cdk_code, - inv.credential_type AS cdk_credential_type, - inv.binding_status AS inventory_binding_status + inv.inventory_item_id AS primary_inventory_item_id, + inv.display_value AS primary_inventory_display_value, + inv.credential_type AS primary_inventory_credential_type, + inv.binding_status AS primary_inventory_binding_status ` const TASK_JOINS = ` @@ -159,8 +159,11 @@ export async function updateTask(taskId, patch) { attempt_count = $11, last_error = $12, context_json = $13::jsonb, - updated_at = $14 - WHERE id = $15 + claimed_at = $14, + role_confirmed_at = $15, + redeemed_at = $16, + updated_at = $17 + WHERE id = $18 `, [ next.task_status, @@ -176,6 +179,9 @@ export async function updateTask(taskId, patch) { next.attempt_count || 0, next.last_error || '', next.context_json || '{}', + next.claimed_at || null, + next.role_confirmed_at || null, + next.redeemed_at || null, next.updated_at, Number(taskId), ], diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js index 139a0caa..95ef23d5 100644 --- a/apps/backend/src/routes/admin.js +++ b/apps/backend/src/routes/admin.js @@ -1,463 +1,32 @@ import { Router } from 'express' -import { - closeAdminTask, - createAdminCdk, - getAdminAgisoShopConfigs, - getAdminCdks, - getAdminDashboardSummary, - getAdminOrderDetail, - getAdminOrders, - getAdminTaskDetail, - getAdminTaskScreenshotPath, - getAdminTasks, - getAdminWebhookEventDetail, - getAdminWebhookEvents, - importAdminCdks, - invalidateAdminCdk, - markAdminTaskManualReview, - replayAdminWebhookEvent, - releaseAdminInventoryCdk, - regenerateAdminTaskClaimLink, - releaseAdminTaskCdk, - retryAdminTask, - updateAdminAgisoShopConfigs, -} from '../services/admin/admin-service.js' -import { - createManagedAdminUser, - getAdminUserList, - getAdminSessionSummary, - loginAdmin, - resetManagedAdminUserPassword, - requireAdminRole, - updateManagedAdminUserRole, - updateManagedAdminUserStatus, - verifyAdminSessionToken, -} from '../services/admin/admin-auth-service.js' -import { getAdminAuditLogs, writeAdminAuditLog } from '../services/admin/admin-audit-service.js' -import { buildNotFoundPayload, buildSuccessPayload, createHttpError, sendRouteError } from '../utils/http.js' +import authRouter from './admin/auth.js' +import auditLogsRouter from './admin/audit-logs.js' +import cdksRouter from './admin/cdks.js' +import dashboardRouter from './admin/dashboard.js' +import ordersRouter from './admin/orders.js' +import platformConfigRouter from './admin/platform-config.js' +import { requireAdminSession } from './admin/shared.js' +import tasksRouter from './admin/tasks.js' +import usersRouter from './admin/users.js' +import webhookEventsRouter from './admin/webhook-events.js' +import { buildNotFoundPayload } from '../utils/http.js' const router = Router() -router.post('/auth/login', (req, res) => { - try { - const data = loginAdmin(req.body?.username, req.body?.password) - res.json(buildSuccessPayload(data, '登录成功')) - } catch (error) { - sendRouteError(res, error, '后台登录失败', '[admin/auth/login]') - } -}) - -router.get('/auth/session', (req, res) => { - try { - const data = getAdminSessionSummary(extractBearerToken(req)) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取后台登录态失败', '[admin/auth/session]') - } -}) - -router.post('/auth/logout', (_req, res) => { - res.json(buildSuccessPayload({ success: true }, '已退出登录')) -}) - -router.use((req, res, next) => { - try { - req.adminSession = verifyAdminSessionToken(extractBearerToken(req)) - next() - } catch (error) { - sendRouteError(res, error, '后台鉴权失败', '[admin/auth]') - } -}) - -router.get('/dashboard/summary', (req, res) => { - try { - const data = getAdminDashboardSummary() - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取后台概览失败', '[admin/dashboard/summary]') - } -}) - -router.get('/users', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = getAdminUserList(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取后台用户列表失败', '[admin/users]') - } -}) - -router.post('/users', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = createManagedAdminUser(req.body) - writeAdminAuditLog(req.adminSession, { - action: 'admin_user_created', - targetType: 'admin_user', - targetId: String(data.user.userId), - data: { - username: data.user.username, - role: data.user.role, - status: data.user.status, - }, - }) - res.json(buildSuccessPayload(data, '后台用户已创建')) - } catch (error) { - sendRouteError(res, error, '创建后台用户失败', '[admin/users:create]') - } -}) - -router.post('/users/:userId/role', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = updateManagedAdminUserRole(req.params.userId, req.body, req.adminSession) - writeAdminAuditLog(req.adminSession, { - action: 'admin_user_role_updated', - targetType: 'admin_user', - targetId: String(data.user.userId), - data: { - username: data.user.username, - role: data.user.role, - }, - }) - res.json(buildSuccessPayload(data, '用户角色已更新')) - } catch (error) { - sendRouteError(res, error, '更新用户角色失败', '[admin/users/:userId/role]') - } -}) - -router.post('/users/:userId/status', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = updateManagedAdminUserStatus(req.params.userId, req.body, req.adminSession) - writeAdminAuditLog(req.adminSession, { - action: 'admin_user_status_updated', - targetType: 'admin_user', - targetId: String(data.user.userId), - data: { - username: data.user.username, - status: data.user.status, - }, - }) - res.json(buildSuccessPayload(data, '用户状态已更新')) - } catch (error) { - sendRouteError(res, error, '更新用户状态失败', '[admin/users/:userId/status]') - } -}) - -router.post('/users/:userId/reset-password', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = resetManagedAdminUserPassword(req.params.userId, req.body) - writeAdminAuditLog(req.adminSession, { - action: 'admin_user_password_reset', - targetType: 'admin_user', - targetId: String(data.user.userId), - data: { - username: data.user.username, - }, - }) - res.json(buildSuccessPayload(data, '用户密码已重置')) - } catch (error) { - sendRouteError(res, error, '重置用户密码失败', '[admin/users/:userId/reset-password]') - } -}) - -router.get('/audit-logs', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = getAdminAuditLogs(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取操作审计日志失败', '[admin/audit-logs]') - } -}) - -router.get('/platform-config/agiso-shops', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = getAdminAgisoShopConfigs() - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取 Agiso 店铺配置失败', '[admin/platform-config/agiso-shops]') - } -}) - -router.post('/platform-config/agiso-shops', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = updateAdminAgisoShopConfigs(req.body) - writeAdminAuditLog(req.adminSession, { - action: 'platform_shop_config_updated', - targetType: 'platform_config', - targetId: 'agiso_shops', - data: { - shopCount: data.shops.length, - filePath: data.filePath, - }, - }) - res.json(buildSuccessPayload(data, 'Agiso 店铺配置已保存')) - } catch (error) { - sendRouteError(res, error, '保存 Agiso 店铺配置失败', '[admin/platform-config/agiso-shops]') - } -}) - -router.get('/orders', (req, res) => { - try { - const data = getAdminOrders(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取订单列表失败', '[admin/orders]') - } -}) - -router.get('/orders/:orderId', (req, res) => { - try { - const data = getAdminOrderDetail(req.params.orderId) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取订单详情失败', '[admin/orders/:orderId]') - } -}) - -router.get('/tasks', (req, res) => { - try { - const data = getAdminTasks(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取任务列表失败', '[admin/tasks]') - } -}) - -router.get('/tasks/:taskId', (req, res) => { - try { - const data = getAdminTaskDetail(req.params.taskId) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取任务详情失败', '[admin/tasks/:taskId]') - } -}) - -router.get('/tasks/:taskId/screenshot', (req, res) => { - try { - const screenshotPath = getAdminTaskScreenshotPath(req.params.taskId) - res.sendFile(screenshotPath) - } catch (error) { - sendRouteError(res, error, '读取任务截图失败', '[admin/tasks/:taskId/screenshot]') - } -}) - -router.post('/tasks/:taskId/retry', (req, res) => { - try { - const data = retryAdminTask(req.params.taskId) - res.json(buildSuccessPayload(data, '任务已重试')) - } catch (error) { - sendRouteError(res, error, '重试任务失败', '[admin/tasks/:taskId/retry]') - } -}) - -router.post('/tasks/:taskId/release-cdk', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = releaseAdminTaskCdk(req.params.taskId) - writeAdminAuditLog(req.adminSession, { - action: 'task_release_cdk', - targetType: 'task', - targetId: String(req.params.taskId), - data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - reservedCdkId: data.task.reservedCdkId, - }, - }) - res.json(buildSuccessPayload(data, 'CDK 已释放')) - } catch (error) { - sendRouteError(res, error, '释放 CDK 失败', '[admin/tasks/:taskId/release-cdk]') - } -}) - -router.post('/tasks/:taskId/regenerate-claim-link', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = regenerateAdminTaskClaimLink(req.params.taskId) - writeAdminAuditLog(req.adminSession, { - action: 'task_regenerate_claim_link', - targetType: 'task', - targetId: String(req.params.taskId), - data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - claimTokenId: data.task.claimTokenId, - }, - }) - res.json(buildSuccessPayload(data, '领取链接已重新生成')) - } catch (error) { - sendRouteError(res, error, '重新生成领取链接失败', '[admin/tasks/:taskId/regenerate-claim-link]') - } -}) - -router.post('/tasks/:taskId/close', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = closeAdminTask(req.params.taskId) - writeAdminAuditLog(req.adminSession, { - action: 'task_closed', - targetType: 'task', - targetId: String(req.params.taskId), - data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, - }, - }) - res.json(buildSuccessPayload(data, '任务已关闭')) - } catch (error) { - sendRouteError(res, error, '关闭任务失败', '[admin/tasks/:taskId/close]') - } -}) - -router.post('/tasks/:taskId/mark-manual-review', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = markAdminTaskManualReview(req.params.taskId) - writeAdminAuditLog(req.adminSession, { - action: 'task_mark_manual_review', - targetType: 'task', - targetId: String(req.params.taskId), - data: { - taskId: data.task.taskId, - taskNo: data.task.taskNo, - status: data.task.status, - }, - }) - res.json(buildSuccessPayload(data, '任务已转人工处理')) - } catch (error) { - sendRouteError(res, error, '标记人工处理失败', '[admin/tasks/:taskId/mark-manual-review]') - } -}) - -router.get('/cdks', (req, res) => { - try { - const data = getAdminCdks(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取 CDK 列表失败', '[admin/cdks]') - } -}) - -router.post('/cdks', (req, res) => { - try { - const data = createAdminCdk(req.body) - res.json(buildSuccessPayload(data, 'CDK 已新增')) - } catch (error) { - sendRouteError(res, error, '新增 CDK 失败', '[admin/cdks]') - } -}) - -router.post('/cdks/import', (req, res) => { - try { - const data = importAdminCdks(req.body) - res.json(buildSuccessPayload(data, '导入完成')) - } catch (error) { - sendRouteError(res, error, '导入 CDK 失败', '[admin/cdks/import]') - } -}) - -router.post('/cdks/:cdkId/release', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = releaseAdminInventoryCdk(req.params.cdkId) - writeAdminAuditLog(req.adminSession, { - action: 'inventory_cdk_released', - targetType: 'cdk', - targetId: String(req.params.cdkId), - data: { - cdkId: data.cdk?.cdkId, - skuCode: data.cdk?.skuCode, - cdkCode: data.cdk?.cdkCode, - }, - }) - res.json(buildSuccessPayload(data, 'CDK 已释放')) - } catch (error) { - sendRouteError(res, error, '释放库存 CDK 失败', '[admin/cdks/:cdkId/release]') - } -}) - -router.post('/cdks/:cdkId/invalidate', (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = invalidateAdminCdk(req.params.cdkId, req.body) - writeAdminAuditLog(req.adminSession, { - action: 'inventory_cdk_invalidated', - targetType: 'cdk', - targetId: String(req.params.cdkId), - data: { - cdkId: data.cdk?.cdkId, - skuCode: data.cdk?.skuCode, - invalidReason: data.cdk?.invalidReason, - }, - }) - res.json(buildSuccessPayload(data, 'CDK 已作废')) - } catch (error) { - sendRouteError(res, error, '作废 CDK 失败', '[admin/cdks/:cdkId/invalidate]') - } -}) - -router.get('/webhook-events', (req, res) => { - try { - const data = getAdminWebhookEvents(req.query) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取 webhook 列表失败', '[admin/webhook-events]') - } -}) - -router.get('/webhook-events/:eventId', (req, res) => { - try { - const data = getAdminWebhookEventDetail(req.params.eventId) - res.json(buildSuccessPayload(data, 'ok')) - } catch (error) { - sendRouteError(res, error, '读取 webhook 详情失败', '[admin/webhook-events/:eventId]') - } -}) - -router.post('/webhook-events/:eventId/replay', async (req, res) => { - try { - requireAdminRole(req.adminSession, ['admin']) - const data = await replayAdminWebhookEvent(req.params.eventId) - writeAdminAuditLog(req.adminSession, { - action: 'webhook_replayed', - targetType: 'webhook_event', - targetId: String(req.params.eventId), - data: { - eventId: data.eventId, - replayed: data.replayed, - }, - }) - res.json(buildSuccessPayload(data, 'Webhook 已重放')) - } catch (error) { - sendRouteError(res, error, '重放 webhook 失败', '[admin/webhook-events/:eventId/replay]') - } -}) +router.use(authRouter) +router.use(requireAdminSession) +router.use(dashboardRouter) +router.use(usersRouter) +router.use(auditLogsRouter) +router.use(platformConfigRouter) +router.use(ordersRouter) +router.use(tasksRouter) +router.use(cdksRouter) +router.use(webhookEventsRouter) router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) export default router - -function extractBearerToken(req) { - const authorization = String(req.headers.authorization || '').trim() - const matched = authorization.match(/^Bearer\s+(.+)$/i) - - if (!matched) { - throw createHttpError('未登录或登录已失效', { - statusCode: 401, - errorCode: 'admin_auth_required', - }) - } - - return matched[1] -} diff --git a/apps/backend/src/routes/admin/audit-logs.js b/apps/backend/src/routes/admin/audit-logs.js new file mode 100644 index 00000000..0928b28d --- /dev/null +++ b/apps/backend/src/routes/admin/audit-logs.js @@ -0,0 +1,19 @@ +import { Router } from 'express' + +import { getAdminAuditLogs } from '../../services/admin/admin-audit-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.use(requireAdminRoles(['admin'])) + +router.get('/audit-logs', createJsonHandler( + (req) => getAdminAuditLogs(req.query), + { + successMessage: 'ok', + errorMessage: '读取操作审计日志失败', + scope: '[admin/audit-logs]', + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/auth.js b/apps/backend/src/routes/admin/auth.js new file mode 100644 index 00000000..ed210f73 --- /dev/null +++ b/apps/backend/src/routes/admin/auth.js @@ -0,0 +1,35 @@ +import { Router } from 'express' + +import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js' +import { createJsonHandler, extractBearerToken } from './shared.js' + +const router = Router() + +router.post('/auth/login', createJsonHandler( + (req) => loginAdmin(req.body?.username, req.body?.password), + { + successMessage: '登录成功', + errorMessage: '后台登录失败', + scope: '[admin/auth/login]', + }, +)) + +router.get('/auth/session', createJsonHandler( + (req) => getAdminSessionSummary(extractBearerToken(req)), + { + successMessage: 'ok', + errorMessage: '读取后台登录态失败', + scope: '[admin/auth/session]', + }, +)) + +router.post('/auth/logout', createJsonHandler( + () => ({ success: true }), + { + successMessage: '已退出登录', + errorMessage: '后台退出失败', + scope: '[admin/auth/logout]', + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/cdks.js b/apps/backend/src/routes/admin/cdks.js new file mode 100644 index 00000000..39d96fb8 --- /dev/null +++ b/apps/backend/src/routes/admin/cdks.js @@ -0,0 +1,79 @@ +import { Router } from 'express' + +import { + createAdminCdk, + getAdminCdks, + importAdminCdks, + invalidateAdminCdk, + releaseAdminInventoryCdk, +} from '../../services/admin/admin-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.get('/cdks', createJsonHandler( + (req) => getAdminCdks(req.query), + { + successMessage: 'ok', + errorMessage: '读取 CDK 列表失败', + scope: '[admin/cdks]', + }, +)) + +router.post('/cdks', createJsonHandler( + (req) => createAdminCdk(req.body), + { + successMessage: 'CDK 已新增', + errorMessage: '新增 CDK 失败', + scope: '[admin/cdks]', + }, +)) + +router.post('/cdks/import', createJsonHandler( + (req) => importAdminCdks(req.body), + { + successMessage: '导入完成', + errorMessage: '导入 CDK 失败', + scope: '[admin/cdks/import]', + }, +)) + +router.post('/cdks/:cdkId/release', requireAdminRoles(['admin']), createJsonHandler( + (req) => releaseAdminInventoryCdk(req.params.cdkId), + { + successMessage: 'CDK 已释放', + errorMessage: '释放库存 CDK 失败', + scope: '[admin/cdks/:cdkId/release]', + audit: (req, data) => ({ + action: 'inventory_cdk_released', + targetType: 'cdk', + targetId: String(req.params.cdkId), + data: { + cdkId: data.cdk?.cdkId, + skuCode: data.cdk?.skuCode, + cdkCode: data.cdk?.cdkCode, + }, + }), + }, +)) + +router.post('/cdks/:cdkId/invalidate', requireAdminRoles(['admin']), createJsonHandler( + (req) => invalidateAdminCdk(req.params.cdkId, req.body), + { + successMessage: 'CDK 已作废', + errorMessage: '作废 CDK 失败', + scope: '[admin/cdks/:cdkId/invalidate]', + audit: (req, data) => ({ + action: 'inventory_cdk_invalidated', + targetType: 'cdk', + targetId: String(req.params.cdkId), + data: { + cdkId: data.cdk?.cdkId, + skuCode: data.cdk?.skuCode, + invalidReason: data.cdk?.invalidReason, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/dashboard.js b/apps/backend/src/routes/admin/dashboard.js new file mode 100644 index 00000000..688efd3f --- /dev/null +++ b/apps/backend/src/routes/admin/dashboard.js @@ -0,0 +1,17 @@ +import { Router } from 'express' + +import { getAdminDashboardSummary } from '../../services/admin/admin-service.js' +import { createJsonHandler } from './shared.js' + +const router = Router() + +router.get('/dashboard/summary', createJsonHandler( + () => getAdminDashboardSummary(), + { + successMessage: 'ok', + errorMessage: '读取后台概览失败', + scope: '[admin/dashboard/summary]', + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/orders.js b/apps/backend/src/routes/admin/orders.js new file mode 100644 index 00000000..ea565dd0 --- /dev/null +++ b/apps/backend/src/routes/admin/orders.js @@ -0,0 +1,26 @@ +import { Router } from 'express' + +import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-service.js' +import { createJsonHandler } from './shared.js' + +const router = Router() + +router.get('/orders', createJsonHandler( + (req) => getAdminOrders(req.query), + { + successMessage: 'ok', + errorMessage: '读取订单列表失败', + scope: '[admin/orders]', + }, +)) + +router.get('/orders/:orderId', createJsonHandler( + (req) => getAdminOrderDetail(req.params.orderId), + { + successMessage: 'ok', + errorMessage: '读取订单详情失败', + scope: '[admin/orders/:orderId]', + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js new file mode 100644 index 00000000..9d2603ec --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config.js @@ -0,0 +1,37 @@ +import { Router } from 'express' + +import { getAdminAgisoShopConfigs, updateAdminAgisoShopConfigs } from '../../services/admin/admin-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.use(requireAdminRoles(['admin'])) + +router.get('/platform-config/agiso-shops', createJsonHandler( + () => getAdminAgisoShopConfigs(), + { + successMessage: 'ok', + errorMessage: '读取 Agiso 店铺配置失败', + scope: '[admin/platform-config/agiso-shops]', + }, +)) + +router.post('/platform-config/agiso-shops', createJsonHandler( + (req) => updateAdminAgisoShopConfigs(req.body), + { + successMessage: 'Agiso 店铺配置已保存', + errorMessage: '保存 Agiso 店铺配置失败', + scope: '[admin/platform-config/agiso-shops]', + audit: (_req, data) => ({ + action: 'platform_shop_config_updated', + targetType: 'platform_config', + targetId: 'agiso_shops', + data: { + shopCount: data.shops.length, + filePath: data.filePath, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/shared.js b/apps/backend/src/routes/admin/shared.js new file mode 100644 index 00000000..8d139d48 --- /dev/null +++ b/apps/backend/src/routes/admin/shared.js @@ -0,0 +1,84 @@ +import { requireAdminRole, verifyAdminSessionToken } from '../../services/admin/admin-auth-service.js' +import { writeAdminAuditLog } from '../../services/admin/admin-audit-service.js' +import { buildSuccessPayload, createHttpError, sendRouteError } from '../../utils/http.js' +import { logWarn } from '../../utils/logger.js' + +export function createJsonHandler(action, { successMessage = 'ok', errorMessage, scope, audit } = {}) { + return async (req, res) => { + try { + const data = await action(req, res) + await recordAdminAudit(req.adminSession, req, data, audit) + res.json(buildSuccessPayload(data, successMessage)) + } catch (error) { + sendRouteError(res, error, errorMessage, scope) + } + } +} + +export function createFileHandler(action, { errorMessage, scope } = {}) { + return async (req, res) => { + try { + const filePath = await action(req, res) + res.sendFile(filePath) + } catch (error) { + sendRouteError(res, error, errorMessage, scope) + } + } +} + +export async function requireAdminSession(req, res, next) { + try { + req.adminSession = await verifyAdminSessionToken(extractBearerToken(req)) + next() + } catch (error) { + sendRouteError(res, error, '后台鉴权失败', '[admin/auth]') + } +} + +export function requireAdminRoles(allowedRoles) { + return (req, res, next) => { + try { + requireAdminRole(req.adminSession, allowedRoles) + next() + } catch (error) { + sendRouteError(res, error, '后台鉴权失败', '[admin/auth/role]') + } + } +} + +async function recordAdminAudit(session, req, data, audit) { + if (!audit) { + return + } + + const payload = typeof audit === 'function' ? audit(req, data) : audit + + if (!payload) { + return + } + + try { + await writeAdminAuditLog(session, payload) + } catch (error) { + logWarn('[admin/audit]', '后台审计日志写入失败,已跳过', { + action: payload.action, + targetType: payload.targetType, + targetId: payload.targetId, + error, + }) + } +} + +export function extractBearerToken(req) { + const authorization = String(req.headers.authorization || '').trim() + const matched = authorization.match(/^Bearer\s+(.+)$/i) + + if (!matched) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) + } + + return matched[1] +} diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js new file mode 100644 index 00000000..6f238518 --- /dev/null +++ b/apps/backend/src/routes/admin/tasks.js @@ -0,0 +1,128 @@ +import { Router } from 'express' + +import { + closeAdminTask, + getAdminTaskDetail, + getAdminTaskScreenshotPath, + getAdminTasks, + markAdminTaskManualReview, + regenerateAdminTaskClaimLink, + releaseAdminTaskCdk, + retryAdminTask, +} from '../../services/admin/admin-service.js' +import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.get('/tasks', createJsonHandler( + (req) => getAdminTasks(req.query), + { + successMessage: 'ok', + errorMessage: '读取任务列表失败', + scope: '[admin/tasks]', + }, +)) + +router.get('/tasks/:taskId', createJsonHandler( + (req) => getAdminTaskDetail(req.params.taskId), + { + successMessage: 'ok', + errorMessage: '读取任务详情失败', + scope: '[admin/tasks/:taskId]', + }, +)) + +router.get('/tasks/:taskId/screenshot', createFileHandler( + (req) => getAdminTaskScreenshotPath(req.params.taskId), + { + errorMessage: '读取任务截图失败', + scope: '[admin/tasks/:taskId/screenshot]', + }, +)) + +router.post('/tasks/:taskId/retry', createJsonHandler( + (req) => retryAdminTask(req.params.taskId), + { + successMessage: '任务已重试', + errorMessage: '重试任务失败', + scope: '[admin/tasks/:taskId/retry]', + }, +)) + +router.post('/tasks/:taskId/release-cdk', requireAdminRoles(['admin']), createJsonHandler( + (req) => releaseAdminTaskCdk(req.params.taskId), + { + successMessage: 'CDK 已释放', + errorMessage: '释放 CDK 失败', + scope: '[admin/tasks/:taskId/release-cdk]', + audit: (req, data) => ({ + action: 'task_release_cdk', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + inventoryItemId: data.task.inventoryItemId, + }, + }), + }, +)) + +router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']), createJsonHandler( + (req) => regenerateAdminTaskClaimLink(req.params.taskId), + { + successMessage: '领取链接已重新生成', + errorMessage: '重新生成领取链接失败', + scope: '[admin/tasks/:taskId/regenerate-claim-link]', + audit: (req, data) => ({ + action: 'task_regenerate_claim_link', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + claimTokenId: data.task.claimTokenId, + }, + }), + }, +)) + +router.post('/tasks/:taskId/close', requireAdminRoles(['admin']), createJsonHandler( + (req) => closeAdminTask(req.params.taskId), + { + successMessage: '任务已关闭', + errorMessage: '关闭任务失败', + scope: '[admin/tasks/:taskId/close]', + audit: (req, data) => ({ + action: 'task_closed', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + }, + }), + }, +)) + +router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), createJsonHandler( + (req) => markAdminTaskManualReview(req.params.taskId), + { + successMessage: '任务已转人工处理', + errorMessage: '标记人工处理失败', + scope: '[admin/tasks/:taskId/mark-manual-review]', + audit: (req, data) => ({ + action: 'task_mark_manual_review', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/users.js b/apps/backend/src/routes/admin/users.js new file mode 100644 index 00000000..5e5c265c --- /dev/null +++ b/apps/backend/src/routes/admin/users.js @@ -0,0 +1,97 @@ +import { Router } from 'express' + +import { + createManagedAdminUser, + getAdminUserList, + resetManagedAdminUserPassword, + updateManagedAdminUserRole, + updateManagedAdminUserStatus, +} from '../../services/admin/admin-auth-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.use(requireAdminRoles(['admin'])) + +router.get('/users', createJsonHandler( + (req) => getAdminUserList(req.query), + { + successMessage: 'ok', + errorMessage: '读取后台用户列表失败', + scope: '[admin/users]', + }, +)) + +router.post('/users', createJsonHandler( + (req) => createManagedAdminUser(req.body), + { + successMessage: '后台用户已创建', + errorMessage: '创建后台用户失败', + scope: '[admin/users:create]', + audit: (_req, data) => ({ + action: 'admin_user_created', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + role: data.user.role, + status: data.user.status, + }, + }), + }, +)) + +router.post('/users/:userId/role', createJsonHandler( + (req) => updateManagedAdminUserRole(req.params.userId, req.body, req.adminSession), + { + successMessage: '用户角色已更新', + errorMessage: '更新用户角色失败', + scope: '[admin/users/:userId/role]', + audit: (_req, data) => ({ + action: 'admin_user_role_updated', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + role: data.user.role, + }, + }), + }, +)) + +router.post('/users/:userId/status', createJsonHandler( + (req) => updateManagedAdminUserStatus(req.params.userId, req.body, req.adminSession), + { + successMessage: '用户状态已更新', + errorMessage: '更新用户状态失败', + scope: '[admin/users/:userId/status]', + audit: (_req, data) => ({ + action: 'admin_user_status_updated', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + status: data.user.status, + }, + }), + }, +)) + +router.post('/users/:userId/reset-password', createJsonHandler( + (req) => resetManagedAdminUserPassword(req.params.userId, req.body), + { + successMessage: '用户密码已重置', + errorMessage: '重置用户密码失败', + scope: '[admin/users/:userId/reset-password]', + audit: (_req, data) => ({ + action: 'admin_user_password_reset', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/webhook-events.js b/apps/backend/src/routes/admin/webhook-events.js new file mode 100644 index 00000000..6d757e42 --- /dev/null +++ b/apps/backend/src/routes/admin/webhook-events.js @@ -0,0 +1,48 @@ +import { Router } from 'express' + +import { + getAdminWebhookEventDetail, + getAdminWebhookEvents, + replayAdminWebhookEvent, +} from '../../services/admin/admin-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.get('/webhook-events', createJsonHandler( + (req) => getAdminWebhookEvents(req.query), + { + successMessage: 'ok', + errorMessage: '读取 webhook 列表失败', + scope: '[admin/webhook-events]', + }, +)) + +router.get('/webhook-events/:eventId', createJsonHandler( + (req) => getAdminWebhookEventDetail(req.params.eventId), + { + successMessage: 'ok', + errorMessage: '读取 webhook 详情失败', + scope: '[admin/webhook-events/:eventId]', + }, +)) + +router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( + (req) => replayAdminWebhookEvent(req.params.eventId), + { + successMessage: 'Webhook 已重放', + errorMessage: '重放 webhook 失败', + scope: '[admin/webhook-events/:eventId/replay]', + audit: (req, data) => ({ + action: 'webhook_replayed', + targetType: 'webhook_event', + targetId: String(req.params.eventId), + data: { + eventId: data.eventId, + replayed: data.replayed, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/services/admin/admin-audit-service.js b/apps/backend/src/services/admin/admin-audit-service.js index 965e320e..3ed2a660 100644 --- a/apps/backend/src/services/admin/admin-audit-service.js +++ b/apps/backend/src/services/admin/admin-audit-service.js @@ -1,5 +1,6 @@ import { createAdminAuditLog, listAdminAuditLogs } from '../../repositories/admin-audit-log-repo.js' import { nowIso } from '../../utils/time.js' +import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' export async function writeAdminAuditLog(session, payload = {}) { if (!session?.userId) { @@ -46,38 +47,3 @@ export async function getAdminAuditLogs(query = {}) { pagination: { page, pageSize, total }, } } - -function normalizePage(rawValue) { - const parsed = Number(rawValue) - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 -} - -function normalizePageSize(rawValue) { - const parsed = Number(rawValue) - if (!Number.isFinite(parsed) || parsed <= 0) { - return 20 - } - return Math.min(100, Math.floor(parsed)) -} - -function normalizeDateQuery(rawValue, endOfDay = false) { - const normalized = String(rawValue || '').trim() - - if (!normalized) { - return '' - } - - if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { - return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z` - } - - return normalized -} - -function safeParseJson(value) { - try { - return typeof value === 'string' ? JSON.parse(value) : value || {} - } catch { - return {} - } -} diff --git a/apps/backend/src/services/admin/admin-auth-service.js b/apps/backend/src/services/admin/admin-auth-service.js index 696ea66d..3b2543f7 100644 --- a/apps/backend/src/services/admin/admin-auth-service.js +++ b/apps/backend/src/services/admin/admin-auth-service.js @@ -11,6 +11,7 @@ import { } from '../../repositories/admin-user-repo.js' import { addHours, nowIso } from '../../utils/time.js' import { createHttpError } from '../../utils/http.js' +import { normalizePage, normalizePageSize } from './admin-query-utils.js' export async function ensureAdminUsersBootstrapped() { ensureAdminAuthConfigured() @@ -360,19 +361,6 @@ function safeCompare(input, expected) { return crypto.timingSafeEqual(left, right) } -function normalizePage(rawValue) { - const parsed = Number(rawValue) - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 -} - -function normalizePageSize(rawValue) { - const parsed = Number(rawValue) - if (!Number.isFinite(parsed) || parsed <= 0) { - return 20 - } - return Math.min(100, Math.floor(parsed)) -} - function normalizeRoleQuery(role) { const normalized = String(role || '').trim().toLowerCase() return ['admin', 'operator'].includes(normalized) ? normalized : '' diff --git a/apps/backend/src/services/admin/admin-query-utils.js b/apps/backend/src/services/admin/admin-query-utils.js new file mode 100644 index 00000000..a67a47d6 --- /dev/null +++ b/apps/backend/src/services/admin/admin-query-utils.js @@ -0,0 +1,38 @@ +export function normalizePage(rawValue) { + const parsed = Number(rawValue) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 +} + +export function normalizePageSize(rawValue) { + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 20 + } + return Math.min(100, Math.floor(parsed)) +} + +export function normalizeDateQuery(rawValue, endOfDay = false) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return '' + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z` + } + + return normalized +} + +export function safeParseJson(rawText) { + if (rawText && typeof rawText === 'object') { + return rawText + } + + try { + return JSON.parse(String(rawText || '{}')) + } catch { + return {} + } +} diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index 93e9e98c..c658f310 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -1,5 +1,5 @@ import { runtimeConfig } from '../../config/runtime.js' -import { getDb } from '../../db/client.js' +import { query } from '../../db/client.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { getAgisoShopConfig, @@ -18,19 +18,45 @@ import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/m import { nowIso } from '../../utils/time.js' import { reserveCdkForTask } from '../order/cdk-service.js' import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' +import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' -export function getAdminDashboardSummary() { - const db = getDb() +export async function getAdminDashboardSummary() { const todayPrefix = nowIso().slice(0, 10) - const summary = db.prepare(` - SELECT - (SELECT COUNT(*) FROM orders WHERE substr(created_at, 1, 10) = ?) AS today_orders, - (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('paid', 'cdk_reserved', 'link_generated')) AS paid_pending_claim, - (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')) AS claiming_tasks, - (SELECT COUNT(*) FROM delivery_tasks WHERE task_status = 'redeemed' AND substr(updated_at, 1, 10) = ?) AS redeemed_today, - (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')) AS abnormal_tasks, - (SELECT COUNT(DISTINCT sku_code) FROM cdk_inventory WHERE status = 'available') AS sku_with_inventory - `).get(todayPrefix, todayPrefix) + 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), @@ -42,10 +68,10 @@ export function getAdminDashboardSummary() { } } -export function getAdminOrders(query = {}) { +export async function getAdminOrders(query = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const { items, total } = listOrders({ + const { items, total } = await listOrders({ page, pageSize, platformOrderId: String(query.platformOrderId || '').trim(), @@ -56,13 +82,13 @@ export function getAdminOrders(query = {}) { }) return { - items: items.map(mapAdminOrderListItem), + items: await Promise.all(items.map((item) => mapAdminOrderListItem(item))), pagination: { page, pageSize, total }, } } -export function getAdminOrderDetail(orderId) { - const order = getOrderById(Number(orderId)) +export async function getAdminOrderDetail(orderId) { + const order = await getOrderById(Number(orderId)) if (!order) { throw createHttpError('订单不存在', { @@ -71,9 +97,11 @@ export function getAdminOrderDetail(orderId) { }) } - const items = listOrderItemsByOrderId(order.id) - const tasks = listTasksByOrderId(order.id) - const webhookEvents = listWebhookEventsByOrderId(order.id) + const [items, tasks, webhookEvents] = await Promise.all([ + listOrderItemsByOrderId(order.id), + listTasksByOrderId(order.id), + listWebhookEventsByOrderId(order.id), + ]) const itemSummary = summarizeOrderItems(items) return { @@ -105,7 +133,7 @@ export function getAdminOrderDetail(orderId) { skuName: item.sku_name, itemTitle: resolveOrderItemTitle(item), quantity: item.quantity, - deliveryMode: item.delivery_mode, + deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), spec: safeParseJson(item.spec_json), })), tasks: tasks.map(mapAdminTaskSummary), @@ -125,10 +153,10 @@ export function getAdminOrderDetail(orderId) { } } -export function getAdminTasks(query = {}) { +export async function getAdminTasks(query = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const { items, total } = listTasks({ + const { items, total } = await listTasks({ page, pageSize, status: String(query.status || '').trim(), @@ -146,8 +174,8 @@ export function getAdminTasks(query = {}) { } } -export function getAdminTaskDetail(taskId) { - const task = getTaskById(Number(taskId)) +export async function getAdminTaskDetail(taskId) { + const task = await getTaskById(Number(taskId)) if (!task) { throw createHttpError('任务不存在', { @@ -156,10 +184,13 @@ export function getAdminTaskDetail(taskId) { }) } - const order = getOrderById(task.order_id) - const orderItem = order ? listOrderItemsByOrderId(order.id).find((item) => item.id === task.order_item_id) || null : null - const claimToken = task.claim_token_id ? getClaimTokenById(task.claim_token_id) : null - const cdk = task.reserved_cdk_id ? getCdkById(task.reserved_cdk_id) : null + const [order, claimToken, cdk] = await Promise.all([ + getOrderById(task.order_id), + getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null), + getTaskPrimaryInventoryItemId(task) ? getCdkById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), + ]) + const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] + const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null return { task: mapAdminTaskListItem({ @@ -210,7 +241,7 @@ export function getAdminTaskDetail(taskId) { screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '', operations: { canRetry: ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), - canReleaseCdk: Boolean(task.reserved_cdk_id && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)), + canReleaseCdk: Boolean(getTaskPrimaryInventoryItemId(task) && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)), canRegenerateClaimLink: ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status), canClose: !['redeemed', 'closed'].includes(task.task_status), canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status), @@ -218,8 +249,8 @@ export function getAdminTaskDetail(taskId) { } } -export function getAdminTaskScreenshotPath(taskId) { - const task = getRequiredTask(taskId) +export async function getAdminTaskScreenshotPath(taskId) { + const task = await getRequiredTask(taskId) if (!task.screenshot_path) { throw createHttpError('当前任务还没有截图', { @@ -231,10 +262,10 @@ export function getAdminTaskScreenshotPath(taskId) { return task.screenshot_path } -export function getAdminCdks(query = {}) { +export async function getAdminCdks(query = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const { items, total } = listCdks({ + const { items, total } = await listCdks({ page, pageSize, skuCode: String(query.skuCode || '').trim(), @@ -243,15 +274,16 @@ export function getAdminCdks(query = {}) { }) return { - items: items.map(mapAdminCdkListItem), + items: await Promise.all(items.map((item) => mapAdminCdkListItem(item))), pagination: { page, pageSize, total }, } } -export function createAdminCdk(payload = {}) { +export async function createAdminCdk(payload = {}) { const skuCode = String(payload.skuCode || '').trim() const cdkCode = String(payload.cdkCode || '').trim() const batchNo = String(payload.batchNo || '').trim() + const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' if (!skuCode || !cdkCode) { throw createHttpError('缺少 skuCode 或 cdkCode', { @@ -261,11 +293,12 @@ export function createAdminCdk(payload = {}) { } const now = nowIso() - const created = createCdks([ + const created = await createCdks([ { skuCode, cdkCode, batchNo, + credentialType, createdAt: now, updatedAt: now, }, @@ -278,7 +311,7 @@ export function createAdminCdk(payload = {}) { }) } - const { items } = listCdks({ + const { items } = await listCdks({ page: 1, pageSize: 1, skuCode, @@ -286,11 +319,11 @@ export function createAdminCdk(payload = {}) { const createdItem = items.find((item) => item.cdk_code === cdkCode) || null return { - cdk: createdItem ? mapAdminCdkListItem(createdItem) : null, + cdk: createdItem ? await mapAdminCdkListItem(createdItem) : null, } } -export function importAdminCdks(payload = {}) { +export async function importAdminCdks(payload = {}) { const rows = normalizeCdkImportRows(payload) if (rows.length === 0) { @@ -305,11 +338,12 @@ export function importAdminCdks(payload = {}) { skuCode: row.skuCode, batchNo: row.batchNo, cdkCode: row.cdkCode, + credentialType: row.credentialType, createdAt: now, updatedAt: now, })) - const created = createCdks(normalizedRows) + const created = await createCdks(normalizedRows) return { total: normalizedRows.length, @@ -318,8 +352,8 @@ export function importAdminCdks(payload = {}) { } } -export function releaseAdminInventoryCdk(cdkId) { - const cdk = getRequiredCdk(cdkId) +export async function releaseAdminInventoryCdk(cdkId) { + const cdk = await getRequiredCdk(cdkId) if (cdk.status !== 'reserved') { throw createHttpError('当前 CDK 不是预占状态,不能释放', { @@ -328,15 +362,15 @@ export function releaseAdminInventoryCdk(cdkId) { }) } - const updated = releaseReservedCdk(cdk.id, nowIso()) + const updated = await releaseReservedCdk(cdk.id, nowIso()) return { - cdk: mapAdminCdkListItem(updated), + cdk: await mapAdminCdkListItem(updated), } } -export function invalidateAdminCdk(cdkId, payload = {}) { - const cdk = getRequiredCdk(cdkId) +export async function invalidateAdminCdk(cdkId, payload = {}) { + const cdk = await getRequiredCdk(cdkId) if (cdk.status !== 'available') { throw createHttpError('只有可用 CDK 才能作废,请先释放预占', { @@ -346,17 +380,17 @@ export function invalidateAdminCdk(cdkId, payload = {}) { } const reason = String(payload.reason || '').trim() || '后台手动作废' - const updated = invalidateCdk(cdk.id, reason, nowIso()) + const updated = await invalidateCdk(cdk.id, reason, nowIso()) return { - cdk: mapAdminCdkListItem(updated), + cdk: await mapAdminCdkListItem(updated), } } -export function getAdminWebhookEvents(query = {}) { +export async function getAdminWebhookEvents(query = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const { items, total } = listWebhookEvents({ + const { items, total } = await listWebhookEvents({ page, pageSize, provider: String(query.provider || '').trim(), @@ -368,13 +402,13 @@ export function getAdminWebhookEvents(query = {}) { }) return { - items: items.map((item) => mapAdminWebhookEvent(item)), + items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))), pagination: { page, pageSize, total }, } } -export function getAdminWebhookEventDetail(eventId) { - const event = getWebhookEventById(Number(eventId)) +export async function getAdminWebhookEventDetail(eventId) { + const event = await getWebhookEventById(Number(eventId)) if (!event) { throw createHttpError('Webhook 事件不存在', { @@ -387,7 +421,7 @@ export function getAdminWebhookEventDetail(eventId) { } export async function replayAdminWebhookEvent(eventId) { - const event = getWebhookEventById(Number(eventId)) + const event = await getWebhookEventById(Number(eventId)) if (!event) { throw createHttpError('Webhook 事件不存在', { @@ -412,19 +446,22 @@ export async function replayAdminWebhookEvent(eventId) { } } -export function getAdminAgisoShopConfigs() { +export async function getAdminAgisoShopConfigs() { const configMap = getAgisoShopConfigMap() - const rows = getDb().prepare(` - 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(*) 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 - `).all() + 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(), @@ -519,8 +556,8 @@ export function updateAdminAgisoShopConfigs(payload = {}) { } } -export function retryAdminTask(taskId) { - const task = getRequiredTask(taskId) +export async function retryAdminTask(taskId) { + const task = await getRequiredTask(taskId) const now = nowIso() if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { @@ -530,39 +567,47 @@ export function retryAdminTask(taskId) { }) } - const orderItem = listOrderItemsByOrderId(task.order_id).find((item) => item.id === task.order_item_id) || null - let reservedCdkId = task.reserved_cdk_id - let claimTokenId = task.claim_token_id + 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 expiresAt = task.expires_at + let claimExpiresAt = getTaskClaimExpiresAt(task) let claimUrl = '' let token = '' - if (!reservedCdkId) { - const reserved = reserveCdkForTask(orderItem?.sku_code || '', task.id) + if (!reservedInventoryItemId) { + const reserved = await reserveCdkForTask({ + skuCode: orderItem?.sku_code || '', + taskId: task.id, + credentialType: primaryRequirement?.credentialType || 'tencent_code', + roleKey: primaryRequirement?.roleKey || 'primary_code', + }) if (!reserved) { nextStatus = 'waiting_inventory' lastError = '库存不足,等待可用 CDK' } else { - reservedCdkId = reserved.id + reservedInventoryItemId = reserved.id } } if (nextStatus === 'link_generated' && !claimTokenId) { - const claimToken = createTaskClaimToken(task.id) + const claimToken = await createTaskClaimToken(task.id) claimTokenId = claimToken.id - expiresAt = claimToken.expired_at + claimExpiresAt = claimToken.expired_at claimUrl = claimToken.claimUrl token = claimToken.token } - const updatedTask = updateTask(task.id, { + const updatedTask = await updateTask(task.id, { task_status: nextStatus, - reserved_cdk_id: reservedCdkId, - claim_token_id: claimTokenId, - expires_at: expiresAt, + inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', + claim_token: token || task.claim_token || '', + claim_expires_at: claimExpiresAt, last_error: lastError, updated_at: now, }) @@ -579,10 +624,11 @@ export function retryAdminTask(taskId) { return payload } -export function releaseAdminTaskCdk(taskId) { - const task = getRequiredTask(taskId) +export async function releaseAdminTaskCdk(taskId) { + const task = await getRequiredTask(taskId) + const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) - if (!task.reserved_cdk_id) { + if (!primaryInventoryItemId) { throw createHttpError('当前任务没有预占 CDK', { statusCode: 409, errorCode: 'admin_task_no_reserved_cdk', @@ -596,10 +642,10 @@ export function releaseAdminTaskCdk(taskId) { }) } - releaseReservedCdk(task.reserved_cdk_id, nowIso()) - const updatedTask = updateTask(task.id, { - reserved_cdk_id: null, + await releaseReservedCdk(primaryInventoryItemId, nowIso()) + const updatedTask = await updateTask(task.id, { task_status: 'waiting_inventory', + inventory_status: 'pending', last_error: '已手动释放预占 CDK', updated_at: nowIso(), }) @@ -609,8 +655,9 @@ export function releaseAdminTaskCdk(taskId) { } } -export function regenerateAdminTaskClaimLink(taskId) { - const task = getRequiredTask(taskId) +export async function regenerateAdminTaskClaimLink(taskId) { + const task = await getRequiredTask(taskId) + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) if (['redeemed', 'closed'].includes(task.task_status)) { throw createHttpError('当前任务状态不允许重新生成领取链接', { @@ -619,17 +666,17 @@ export function regenerateAdminTaskClaimLink(taskId) { }) } - if (task.claim_token_id) { - updateClaimToken(task.claim_token_id, { + if (primaryClaimTokenId) { + await updateClaimToken(primaryClaimTokenId, { status: 'revoked', updated_at: nowIso(), }) } - const claimToken = createTaskClaimToken(task.id) - const updatedTask = updateTask(task.id, { - claim_token_id: claimToken.id, - expires_at: claimToken.expired_at, + 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(), @@ -642,8 +689,8 @@ export function regenerateAdminTaskClaimLink(taskId) { } } -export function closeAdminTask(taskId) { - const task = getRequiredTask(taskId) +export async function closeAdminTask(taskId) { + const task = await getRequiredTask(taskId) if (task.task_status === 'redeemed') { throw createHttpError('已兑换任务不能关闭', { @@ -652,8 +699,9 @@ export function closeAdminTask(taskId) { }) } - const updatedTask = updateTask(task.id, { + const updatedTask = await updateTask(task.id, { task_status: 'closed', + delivery_status: 'closed', last_error: task.last_error || '已手动关闭任务', updated_at: nowIso(), }) @@ -663,9 +711,9 @@ export function closeAdminTask(taskId) { } } -export function markAdminTaskManualReview(taskId) { - const task = getRequiredTask(taskId) - const updatedTask = updateTask(task.id, { +export async function markAdminTaskManualReview(taskId) { + const task = await getRequiredTask(taskId) + const updatedTask = await updateTask(task.id, { task_status: 'manual_review', last_error: task.last_error || '已转人工处理', updated_at: nowIso(), @@ -691,13 +739,13 @@ function mapAdminTaskSummary(task) { roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, lastError: task.last_error, - retryCount: task.retry_count, + retryCount: getTaskRetryCount(task), createdAt: task.created_at, updatedAt: task.updated_at, } } -function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { +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)) @@ -716,7 +764,7 @@ function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { payload.buyAmount, ]) const totalAmountFen = parseAmountToFen(rawAmount) - const taskCount = item.related_order_id ? listTasksByOrderId(item.related_order_id).length : 0 + const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0 const mapped = { eventId: item.id, @@ -835,9 +883,9 @@ function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { } } -function mapAdminCdkListItem(item) { - const task = item.reserved_by_task_id ? getTaskById(item.reserved_by_task_id) : null - const order = task?.order_id ? getOrderById(task.order_id) : null +async function mapAdminCdkListItem(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 { @@ -845,12 +893,13 @@ function mapAdminCdkListItem(item) { skuCode: item.sku_code, batchNo: item.batch_no, cdkCode: item.cdk_code, + credentialType: item.credential_type || 'tencent_code', status: item.status, reservedByTaskId: item.reserved_by_task_id, reservedByTaskNo: task?.task_no || '', platformOrderId: order?.platform_order_id || '', - systemBindingStatus: item.status === 'delivered' ? 'system_bound' : binding.systemBindingStatus, - userBindingStatus: item.status === 'delivered' ? 'binding_completed' : binding.userBindingStatus, + 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, @@ -859,19 +908,23 @@ function mapAdminCdkListItem(item) { } function mapTaskActionPayload(task) { + const inventoryItemId = getTaskPrimaryInventoryItemId(task) + const claimTokenId = getTaskPrimaryClaimTokenId(task) + return { taskId: task.id, taskNo: task.task_no, status: task.task_status, - reservedCdkId: task.reserved_cdk_id, - claimTokenId: task.claim_token_id, + inventoryItemId, + claimTokenId, + reservedCdkId: inventoryItemId, lastError: task.last_error, updatedAt: task.updated_at, } } -function getRequiredTask(taskId) { - const task = getTaskById(Number(taskId)) +async function getRequiredTask(taskId) { + const task = await getTaskById(Number(taskId)) if (!task) { throw createHttpError('任务不存在', { @@ -883,8 +936,8 @@ function getRequiredTask(taskId) { return task } -function getRequiredCdk(cdkId) { - const cdk = getCdkById(Number(cdkId)) +async function getRequiredCdk(cdkId) { + const cdk = await getCdkById(Number(cdkId)) if (!cdk) { throw createHttpError('CDK 不存在', { @@ -915,19 +968,21 @@ function mapAdminTaskListItem(task) { claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, - retryCount: task.retry_count, + retryCount: getTaskRetryCount(task), lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, - reservedCdkCodeMasked: maskCode(task.cdk_code), - claimToken: task.claim_token || '', + reservedCdkCodeMasked: maskCode(task.primary_inventory_display_value), + claimToken: task.primary_claim_token || task.claim_token || '', screenshotPath: task.screenshot_path || '', } } -function mapAdminOrderListItem(item) { - const tasks = listTasksByOrderId(item.id) - const orderItems = listOrderItemsByOrderId(item.id) +async function mapAdminOrderListItem(item) { + const [tasks, orderItems] = await Promise.all([ + listTasksByOrderId(item.id), + listOrderItemsByOrderId(item.id), + ]) const bindingSummary = buildOrderBindingSummary(tasks) const itemSummary = summarizeOrderItems(orderItems) @@ -997,6 +1052,24 @@ function resolveOrderItemTitle(item) { ]) } +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) { const normalizedTasks = Array.isArray(tasks) ? tasks : [] const totalTaskCount = normalizedTasks.length @@ -1110,7 +1183,33 @@ function buildTaskBindingState(task) { } function isTaskSystemBound(task) { - return Boolean(task && (task.reserved_cdk_id || task.claim_token_id)) + return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) +} + +function parseTaskContext(task) { + try { + return JSON.parse(String(task?.context_json || '{}')) + } catch { + return {} + } +} + +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 resolveDisplayShopName(provider, shopId, shopName) { @@ -1144,18 +1243,20 @@ function normalizeCdkImportRows(payload) { const skuCode = String(row?.skuCode || '').trim() const cdkCode = String(row?.cdkCode || '').trim() const batchNo = String(row?.batchNo || '').trim() + const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' if (!skuCode || !cdkCode) { continue } - rows.push({ skuCode, cdkCode, batchNo }) + rows.push({ skuCode, cdkCode, 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', { @@ -1169,7 +1270,7 @@ function normalizeCdkImportRows(payload) { if (!cdkCode) { continue } - rows.push({ skuCode, cdkCode, batchNo }) + rows.push({ skuCode, cdkCode, batchNo, credentialType }) } } @@ -1181,7 +1282,7 @@ function dedupeRows(rows) { const output = [] for (const row of rows) { - const key = `${row.skuCode}::${row.cdkCode}` + const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.cdkCode}` if (seen.has(key)) { continue } @@ -1192,41 +1293,6 @@ function dedupeRows(rows) { return output } -function normalizePage(rawValue) { - const parsed = Number(rawValue) - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 -} - -function normalizePageSize(rawValue) { - const parsed = Number(rawValue) - if (!Number.isFinite(parsed) || parsed <= 0) { - return 20 - } - return Math.min(100, Math.floor(parsed)) -} - -function normalizeDateQuery(rawValue, endOfDay = false) { - const normalized = String(rawValue || '').trim() - - if (!normalized) { - return '' - } - - if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { - return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z` - } - - return normalized -} - -function safeParseJson(rawText) { - try { - return JSON.parse(String(rawText || '{}')) - } catch { - return {} - } -} - function maskSecret(value) { const normalized = String(value || '').trim() if (!normalized) { diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js index 0b7f3e3b..55a0792b 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js @@ -64,10 +64,7 @@ export async function ensureFulfillmentCatalogBootstrapped() { } } - const configuredBindings = normalizeConfiguredBindings(runtimeConfig.orders?.fulfillmentBindings) - const bindingsToApply = configuredBindings.length > 0 - ? configuredBindings - : inferDefaultBindingsFromSkuMappings(runtimeConfig.orders?.skuMappings) + const bindingsToApply = normalizeConfiguredBindings(runtimeConfig.orders?.fulfillmentBindings) for (const binding of bindingsToApply) { const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey) @@ -108,19 +105,3 @@ function normalizeConfiguredBindings(bindings) { })) .filter((binding) => binding.skuCode) } - -function inferDefaultBindingsFromSkuMappings(skuMappings) { - const values = Object.values(skuMappings || {}) - const uniqueSkuCodes = [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))] - - return uniqueSkuCodes.map((skuCode) => ({ - skuCode, - provider: '', - platform: '', - shopId: '', - profileKey: 'tencent_claim_redeem', - enabled: true, - priority: 100, - config: {}, - })) -} diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 72edf7ee..aecae0e1 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -1,6 +1,7 @@ import { createTencentBrowserSession, getTencentBrowserSession, + getTencentBrowserSessionSummary, getTencentBrowserSessionScreenshotPath, redeemTencentBrowserSession, } from '../session/session.js' @@ -36,7 +37,7 @@ export async function createClaimSession(token, payload = {}) { if (context.task.browser_session_id) { const existingSession = await getTencentBrowserSession(context.task.browser_session_id) - const syncedTask = syncTaskWithSession(context.task, existingSession) + const syncedTask = await syncTaskWithSession(context.task, existingSession) return buildClaimDetailPayload({ claimToken: context.claimToken, @@ -50,10 +51,11 @@ export async function createClaimSession(token, payload = {}) { const session = await createTencentBrowserSession({ loginType: payload.loginType, }) - const updatedTask = updateTask(context.task.id, { + const updatedTask = await updateTask(context.task.id, { task_status: 'claimed', browser_session_id: session.sessionId, login_type: session.loginType, + user_action_status: 'claimed', claimed_at: context.task.claimed_at || nowIso(), updated_at: nowIso(), }) @@ -78,7 +80,7 @@ export async function getClaimSessionSummary(token) { } const session = await getTencentBrowserSession(context.task.browser_session_id) - const syncedTask = syncTaskWithSession(context.task, session) + const syncedTask = await syncTaskWithSession(context.task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, @@ -109,13 +111,14 @@ export async function confirmClaimRole(token) { }) } - const updatedTask = updateTask(context.task.id, { + const updatedTask = await updateTask(context.task.id, { task_status: 'role_confirmed', nickname: String(activityInfo.nickname || ''), role_id: String(activityInfo.role.roleId || ''), role_name: String(activityInfo.role.roleName || ''), area: String(activityInfo.role.area || ''), partition_name: String(activityInfo.role.partition || ''), + user_action_status: 'role_confirmed', role_confirmed_at: nowIso(), updated_at: nowIso(), last_error: '', @@ -148,7 +151,7 @@ export async function redeemClaimTask(token) { }) } - const cdk = context.task.reserved_cdk_id ? getCdkById(context.task.reserved_cdk_id) : null + const cdk = context.task.primary_inventory_item_id ? await getCdkById(context.task.primary_inventory_item_id) : null if (!cdk || !String(cdk.cdk_code || '').trim()) { throw createHttpError('当前任务没有可用的预占 CDK', { @@ -157,8 +160,9 @@ export async function redeemClaimTask(token) { }) } - updateTask(context.task.id, { + await updateTask(context.task.id, { task_status: 'redeeming', + delivery_status: 'processing', updated_at: nowIso(), last_error: '', }) @@ -168,8 +172,10 @@ export async function redeemClaimTask(token) { code: cdk.cdk_code, }) const finalRedeem = session.redeem?.final?.redeem || null - const updatedTask = updateTask(context.task.id, { + const updatedTask = await updateTask(context.task.id, { task_status: 'redeemed', + inventory_status: 'consumed', + delivery_status: 'delivered', result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'), result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'), screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', @@ -179,7 +185,7 @@ export async function redeemClaimTask(token) { last_error: '', }) - markCdkDelivered(cdk.id, nowIso()) + await markCdkDelivered(cdk.id, nowIso()) return buildClaimDetailPayload({ claimToken: context.claimToken, @@ -189,10 +195,11 @@ export async function redeemClaimTask(token) { session, }) } catch (error) { - const nextRetryCount = Number(context.task.retry_count || 0) + 1 - const failedTask = updateTask(context.task.id, { + const nextRetryCount = Number(context.task.attempt_count || 0) + 1 + const failedTask = await updateTask(context.task.id, { task_status: 'retry_pending', - retry_count: nextRetryCount, + delivery_status: 'pending', + attempt_count: nextRetryCount, last_error: error instanceof Error ? error.message : String(error || ''), updated_at: nowIso(), }) @@ -230,7 +237,7 @@ async function getClaimContext(token) { }) } - const claimToken = findClaimTokenByToken(normalized) + const claimToken = await findClaimTokenByToken(normalized) if (!claimToken) { throw createHttpError('领取链接无效或不存在', { @@ -239,7 +246,7 @@ async function getClaimContext(token) { }) } - const task = findTaskByClaimTokenId(claimToken.id) + const task = await findTaskByClaimTokenId(claimToken.id) if (!task) { throw createHttpError('领取任务不存在', { @@ -256,7 +263,7 @@ async function getClaimContext(token) { } if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { - const expiredContext = expireClaimContext(claimToken, task) + const expiredContext = await expireClaimContext(claimToken, task) throw createHttpError('领取链接已过期', { statusCode: 410, @@ -265,8 +272,10 @@ async function getClaimContext(token) { }) } - const order = getOrderById(task.order_id) - const orderItem = getOrderItemById(task.order_item_id) + const [order, orderItem] = await Promise.all([ + getOrderById(task.order_id), + getOrderItemById(task.order_item_id), + ]) if (!order || !orderItem) { throw createHttpError('领取任务关联订单不完整', { @@ -292,9 +301,9 @@ function assertTaskCanProceed(task) { } } -function expireClaimContext(claimToken, task) { +async function expireClaimContext(claimToken, task) { const now = nowIso() - const nextClaimToken = updateClaimToken(claimToken.id, { + const nextClaimToken = await updateClaimToken(claimToken.id, { status: 'expired', updated_at: now, }) @@ -302,13 +311,14 @@ function expireClaimContext(claimToken, task) { let nextTask = task if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { - if (task.reserved_cdk_id) { - releaseReservedCdk(task.reserved_cdk_id, now) + if (task.primary_inventory_item_id) { + await releaseReservedCdk(task.primary_inventory_item_id, now) } - nextTask = updateTask(task.id, { + nextTask = await updateTask(task.id, { task_status: 'expired', - reserved_cdk_id: null, + inventory_status: 'pending', + user_action_status: 'expired', last_error: '领取链接已过期,预占 CDK 已释放', updated_at: now, }) @@ -330,7 +340,7 @@ async function loadTaskSession(task, { includeQrImage = false } = {}) { : getTencentBrowserSessionSummary(task.browser_session_id) } -function syncTaskWithSession(task, session) { +async function syncTaskWithSession(task, session) { const activityInfo = session.activityInfo || null const patch = { login_type: String(session.loginType || task.login_type || ''), @@ -350,6 +360,7 @@ function syncTaskWithSession(task, session) { if (task.task_status === 'link_generated') { patch.task_status = 'claimed' + patch.user_action_status = 'claimed' patch.claimed_at = task.claimed_at || nowIso() } @@ -372,7 +383,7 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } taskId: task.id, taskNo: task.task_no, status: task.task_status, - expiresAt: task.expires_at, + expiresAt: claimToken.expired_at, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 9b600c77..2a3dae99 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -1,11 +1,16 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' +import { + getFulfillmentProfileByKey, + listFulfillmentProfileRequirements, + resolveFulfillmentBinding, +} from '../../repositories/fulfillment-profile-repo.js' import { reserveCdkForTask } from './cdk-service.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' -export function syncDeliveryTasksForOrder(order, orderItems) { - const existingTasks = listTasksByOrderId(order.id) +export async function syncDeliveryTasksForOrder(order, orderItems) { + const existingTasks = await listTasksByOrderId(order.id) if (existingTasks.length > 0) { if (order.pay_status !== 'paid') { @@ -13,47 +18,72 @@ export function syncDeliveryTasksForOrder(order, orderItems) { } const itemMap = new Map(orderItems.map((item) => [item.id, item])) - return existingTasks.map((task) => preparePaidTask({ + return Promise.all(existingTasks.map((task) => preparePaidTask({ ...task, skuCode: itemMap.get(task.order_item_id)?.sku_code || '', skuName: itemMap.get(task.order_item_id)?.sku_name || '', - })) + }))) } const tasks = [] for (const item of orderItems) { + const binding = await resolveFulfillmentBinding({ + skuCode: item.sku_code, + provider: order.provider, + platform: order.platform, + shopId: order.shop_id, + }) + const profile = binding || await getFulfillmentProfileByKey('manual_review') + if (!profile) { + continue + } + const requirements = await listFulfillmentProfileRequirements(profile.profile_id || profile.id) + const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null const quantity = Math.max(1, Number(item.quantity || 1)) for (let index = 0; index < quantity; index += 1) { const createdAt = nowIso() - const initialStatus = order.pay_status === 'paid' ? 'paid' : 'pending_payment' + const initialStatus = order.pay_status === 'paid' + ? resolvePaidTaskStatus(profile) + : 'pending_payment' - const task = createTask({ + const task = await createTask({ orderId: order.id, orderItemId: item.id, + unitIndex: index + 1, + provider: order.provider, + platform: order.platform, + shopId: order.shop_id, + shopName: order.shop_name, platformOrderId: order.platform_order_id, taskNo: randomId('DT'), + profileId: Number(profile.profile_id || profile.id), + executorKey: String(profile.executor_key || 'manual_dispatch'), taskStatus: initialStatus, - loginType: '', - claimTokenId: null, - reservedCdkId: null, - browserSessionId: '', - nickname: '', - roleId: '', - roleName: '', - area: '', - partitionName: '', + inventoryStatus: profile.requires_claim ? 'pending' : 'not_required', + deliveryStatus: initialStatus === 'redeemed' ? 'delivered' : 'pending', resultCode: '', resultMessage: '', - screenshotPath: '', - artifactsJson: '{}', + claimToken: '', + claimExpiresAt: null, + automationMode: profile.auto_dispatch ? 'automatic' : 'manual', + requiresClaim: Boolean(profile.requires_claim), + userActionStatus: profile.requires_claim ? 'pending_claim' : 'not_required', + attemptCount: 0, lastError: '', - retryCount: 0, - expiresAt: null, - claimedAt: null, - roleConfirmedAt: null, - redeemedAt: null, + contextJson: JSON.stringify({ + profileKey: String(profile.profile_key || ''), + profileName: String(profile.profile_name || profile.name || ''), + skuCode: item.sku_code, + skuName: item.sku_name, + primaryRequirement: primaryRequirement + ? { + roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'), + credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'tencent_code'), + } + : null, + }), createdAt, updatedAt: createdAt, }) @@ -70,16 +100,28 @@ export function syncDeliveryTasksForOrder(order, orderItems) { return tasks } - return tasks.map((task) => preparePaidTask(task)) + return Promise.all(tasks.map((task) => preparePaidTask(task))) } -function preparePaidTask(task) { +async function preparePaidTask(task) { const now = nowIso() + const taskContext = parseTaskContext(task) + const primaryRequirement = taskContext.primaryRequirement || null if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) { return task } + if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { + return updateTask(task.id, { + task_status: 'manual_review', + inventory_status: 'not_required', + user_action_status: 'not_required', + last_error: task.last_error || '当前任务需要人工履约处理', + updated_at: now, + }) + } + if (!task.skuCode) { return updateTask(task.id, { task_status: 'manual_review', @@ -88,7 +130,7 @@ function preparePaidTask(task) { }) } - if (task.reserved_cdk_id && task.claim_token_id) { + if (task.primary_inventory_item_id && task.primary_claim_token_id) { return updateTask(task.id, { task_status: 'link_generated', last_error: '', @@ -96,24 +138,59 @@ function preparePaidTask(task) { }) } - const reserved = reserveCdkForTask(task.skuCode, task.id) + if (!primaryRequirement?.credentialType) { + return updateTask(task.id, { + task_status: 'manual_review', + last_error: '履约档案未配置库存要求,无法自动分配库存', + updated_at: now, + }) + } + + const reserved = await reserveCdkForTask({ + skuCode: task.skuCode, + taskId: task.id, + credentialType: primaryRequirement.credentialType, + roleKey: primaryRequirement.roleKey || 'primary_code', + }) if (!reserved) { return updateTask(task.id, { task_status: 'waiting_inventory', + inventory_status: 'pending', last_error: '库存不足,等待可用 CDK', updated_at: now, }) } - const claimToken = createTaskClaimToken(task.id) + const claimToken = await createTaskClaimToken(task.id) return updateTask(task.id, { task_status: 'link_generated', - reserved_cdk_id: reserved.id, - claim_token_id: claimToken.id, + inventory_status: 'reserved', + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + user_action_status: 'pending_claim', last_error: '', - expires_at: claimToken.expired_at, updated_at: now, }) } + +function resolvePaidTaskStatus(profile) { + if (Boolean(profile?.requires_claim)) { + return 'paid' + } + + if (String(profile?.executor_key || '').trim() === 'manual_dispatch') { + return 'manual_review' + } + + return 'paid' +} + +function parseTaskContext(task) { + try { + return JSON.parse(String(task?.context_json || '{}')) + } catch { + return {} + } +} diff --git a/apps/backend/src/services/order/order-repair-service.js b/apps/backend/src/services/order/order-repair-service.js deleted file mode 100644 index cd7fdb8b..00000000 --- a/apps/backend/src/services/order/order-repair-service.js +++ /dev/null @@ -1,222 +0,0 @@ -import { getDb, runInTransaction } from '../../db/client.js' -import { listOrderItemsByOrderId, replaceOrderItems } from '../../repositories/order-item-repo.js' -import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' -import { logInfo } from '../../utils/logger.js' -import { nowIso } from '../../utils/time.js' -import { parseJsonObject } from '../../utils/json.js' - -export async function repairAgisoOrderData() { - const rows = getDb().prepare(` - SELECT - o.id, - o.provider, - o.platform, - o.shop_id, - o.shop_name, - o.platform_order_id, - o.total_amount, - o.raw_payload_json, - o.paid_at, - o.buyer_id, - o.buyer_name, - o.receiver_contact, - ( - SELECT we.body_json - FROM webhook_events we - WHERE we.related_order_id = o.id - ORDER BY we.id DESC - LIMIT 1 - ) AS latest_body_json - FROM orders o - WHERE o.provider = 'agiso' AND o.platform = 'xianyu' - ORDER BY o.id ASC - `).all() - - if (rows.length === 0) { - return - } - - let repairedOrderIdCount = 0 - let repairedAmountCount = 0 - let repairedOrderItemCount = 0 - - for (const row of rows) { - const latestRequestBody = parseJsonObject(row.latest_body_json) - const rawJson = String(latestRequestBody.json || '').trim() - const webhookPayload = rawJson ? parseJsonObject(rawJson, { preserveLargeIntegers: true }) : {} - const rawPayload = parseJsonObject(row.raw_payload_json, { preserveLargeIntegers: true }) - const expectedOrderId = pickFirstNonEmpty([ - webhookPayload.biz_order_id, - webhookPayload.order_id, - webhookPayload.orderId, - rawPayload.biz_order_id, - rawPayload.order_id, - rawPayload.orderId, - ]) - - const parsed = { - provider: row.provider, - platform: row.platform, - shopId: String(row.shop_id || '').trim(), - shopName: String(row.shop_name || '').trim(), - platformOrderId: expectedOrderId || String(row.platform_order_id || '').trim(), - totalAmount: Number(row.total_amount || 0), - buyerId: pickFirstNonEmpty([rawPayload.buyer_id, rawPayload.buyerId, rawPayload.BuyerId, row.buyer_id]), - buyerName: pickFirstNonEmpty([rawPayload.buyer_name, rawPayload.buyerName, rawPayload.BuyerName, row.buyer_name]), - receiverContact: pickFirstNonEmpty([ - rawPayload.receiver_contact, - rawPayload.receiverContact, - rawPayload.receiver_mobile, - rawPayload.receiverMobile, - rawPayload.mobile, - rawPayload.phone, - row.receiver_contact, - ]), - paidAt: row.paid_at, - rawPayload: Object.keys(webhookPayload).length > 0 ? webhookPayload : rawPayload, - items: [], - } - - const detailResult = await enrichAgisoXianyuTradeOrder(parsed, { - requestId: `repair-order-${row.id}`, - }) - - const nextOrderId = pickFirstNonEmpty([ - detailResult.parsed.platformOrderId, - expectedOrderId, - row.platform_order_id, - ]) - const nextAmount = Number(detailResult.parsed.totalAmount || 0) - const nextRawPayloadJson = JSON.stringify(detailResult.parsed.rawPayload || rawPayload || {}) - const nextShopName = pickFirstNonEmpty([detailResult.parsed.shopName, row.shop_name]) - const nextBuyerId = pickFirstNonEmpty([detailResult.parsed.buyerId, row.buyer_id]) - const nextBuyerName = pickFirstNonEmpty([detailResult.parsed.buyerName, row.buyer_name]) - const nextReceiverContact = pickFirstNonEmpty([detailResult.parsed.receiverContact, row.receiver_contact]) - const nextPaidAt = detailResult.parsed.paidAt || row.paid_at - const currentOrderItems = listOrderItemsByOrderId(row.id) - const repairedItems = Array.isArray(detailResult.parsed.items) ? detailResult.parsed.items : [] - const shouldRepairOrderItems = repairedItems.length > 0 - && (currentOrderItems.length === 0 || currentOrderItems.length === repairedItems.length) - && hasOrderItemChanges(currentOrderItems, repairedItems) - - const orderIdChanged = nextOrderId && nextOrderId !== String(row.platform_order_id || '') - const amountChanged = nextAmount > 0 && nextAmount !== Number(row.total_amount || 0) - const payloadChanged = nextRawPayloadJson !== String(row.raw_payload_json || '{}') - const metadataChanged = nextShopName !== String(row.shop_name || '') - || nextBuyerId !== String(row.buyer_id || '') - || nextBuyerName !== String(row.buyer_name || '') - || nextReceiverContact !== String(row.receiver_contact || '') - || nextPaidAt !== row.paid_at - - if (!orderIdChanged && !amountChanged && !payloadChanged && !metadataChanged && !shouldRepairOrderItems) { - continue - } - - runInTransaction((db) => { - db.prepare(` - UPDATE orders - SET - shop_name = ?, - platform_order_id = ?, - buyer_id = ?, - buyer_name = ?, - receiver_contact = ?, - total_amount = ?, - raw_payload_json = ?, - paid_at = ?, - updated_at = ? - WHERE id = ? - `).run( - nextShopName, - nextOrderId, - nextBuyerId, - nextBuyerName, - nextReceiverContact, - nextAmount, - nextRawPayloadJson, - nextPaidAt, - nowIso(), - row.id, - ) - - if (shouldRepairOrderItems) { - const itemNow = nowIso() - replaceOrderItems( - row.id, - repairedItems.map((item) => ({ - skuCode: item.skuCode, - skuName: item.skuName, - quantity: item.quantity, - specJson: JSON.stringify(item.spec || {}), - deliveryMode: 'claim_link', - createdAt: itemNow, - updatedAt: itemNow, - })), - ) - } - - if (orderIdChanged) { - db.prepare('UPDATE delivery_tasks SET platform_order_id = ? WHERE order_id = ?').run(nextOrderId, row.id) - db.prepare(` - UPDATE message_deliveries - SET - platform_order_id = ?, - recipient_key = CASE - WHEN recipient_key = ? THEN ? - ELSE recipient_key - END, - updated_at = ? - WHERE order_id = ? - `).run(nextOrderId, row.platform_order_id, nextOrderId, nowIso(), row.id) - } - }) - - if (orderIdChanged) { - repairedOrderIdCount += 1 - } - - if (amountChanged) { - repairedAmountCount += 1 - } - - if (shouldRepairOrderItems) { - repairedOrderItemCount += 1 - } - } - - if (repairedOrderIdCount > 0 || repairedAmountCount > 0 || repairedOrderItemCount > 0) { - logInfo('[startup]', '已自动修复历史 Agiso 咸鱼订单数据', { - repairedOrderIdCount, - repairedAmountCount, - repairedOrderItemCount, - }) - } -} - -function hasOrderItemChanges(currentItems, repairedItems) { - if (!Array.isArray(currentItems) || !Array.isArray(repairedItems) || currentItems.length !== repairedItems.length) { - return repairedItems.length > 0 - } - - return repairedItems.some((item, index) => { - const current = currentItems[index] - const currentSpec = parseJsonObject(current?.spec_json, { preserveLargeIntegers: true }) - const nextSpec = item?.spec || {} - - return String(current?.sku_code || '').trim() !== String(item?.skuCode || '').trim() - || String(current?.sku_name || '').trim() !== String(item?.skuName || '').trim() - || Number(current?.quantity || 0) !== Number(item?.quantity || 0) - || JSON.stringify(currentSpec) !== JSON.stringify(nextSpec) - }) -} - -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/order/order-service.js b/apps/backend/src/services/order/order-service.js index 43f7e6f3..60d57b56 100644 --- a/apps/backend/src/services/order/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -9,7 +9,7 @@ import { logWebhook } from '../../utils/logger.js' export async function upsertOrderFromWebhook(event) { const now = nowIso() - const existing = findOrderByPlatformOrderId({ + const existing = await findOrderByPlatformOrderId({ provider: event.provider, platform: event.platform, shopId: event.shopId, @@ -43,30 +43,30 @@ export async function upsertOrderFromWebhook(event) { } const order = existing - ? updateOrder(existing.id, { + ? await updateOrder(existing.id, { ...basePayload, updatedAt: now, }) - : createOrder({ + : await createOrder({ ...basePayload, createdAt: now, updatedAt: now, }) - const orderItems = replaceOrderItems( + const orderItems = await replaceOrderItems( order.id, event.items.map((item) => ({ skuCode: item.skuCode, skuName: item.skuName, quantity: item.quantity, specJson: JSON.stringify(item.spec || {}), - deliveryMode: 'claim_link', + itemSnapshotJson: JSON.stringify(item.snapshot || item.spec || {}), createdAt: now, updatedAt: now, })), ) - const tasks = syncDeliveryTasksForOrder(order, orderItems) + const tasks = await syncDeliveryTasksForOrder(order, orderItems) const messageDeliveries = [] logWebhook('[order-service]', 'Webhook 订单 upsert 完成', { @@ -85,12 +85,12 @@ export async function upsertOrderFromWebhook(event) { event.provider !== 'agiso' || event.platform !== 'xianyu' || String(task.task_status || '') !== 'link_generated' - || !task.claim_token_id + || !task.primary_claim_token_id ) { continue } - const claimToken = getClaimTokenById(task.claim_token_id) + const claimToken = await getClaimTokenById(task.primary_claim_token_id) if (!claimToken) { continue } diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index 603001ca..438b75ed 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -741,23 +741,8 @@ function normalizeProviderDateTime(value) { } function resolveSkuCode(rawKey) { - const mappings = runtimeConfig.orders.skuMappings const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] - if (mappings && typeof mappings === 'object') { - for (const candidate of candidates) { - const normalizedCandidate = String(candidate || '').trim() - if (!normalizedCandidate) { - continue - } - - const direct = mappings[normalizedCandidate] - if (typeof direct === 'string' && direct.trim()) { - return direct.trim() - } - } - } - for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim()) { return candidate.trim() diff --git a/apps/backend/方案A-订单编排与自动兑换设计.md b/apps/backend/方案A-订单编排与自动兑换设计.md deleted file mode 100644 index 656b1c92..00000000 --- a/apps/backend/方案A-订单编排与自动兑换设计.md +++ /dev/null @@ -1,1077 +0,0 @@ -# 方案 A:订单编排与自动兑换设计 - -## 1. 目标 - -基于现有两个项目: - -- 后端 [order-site-backend](/Users/yml/codes/order-site-backend) -- 前端 [order-site-rewrite](/Users/yml/codes/order-site-rewrite) - -增加一条面向真实订单的自动化交付链路: - -1. 第三方平台通过订单创建成功或支付成功通知,将订单数据推送到我方后端。 -2. 后端根据订单和商品信息匹配需要发放的 CDK,并创建交付任务。 -3. 后端生成一个面向用户的领取链接,并通过可用渠道发送给用户。 -4. 用户打开链接后,通过 QQ 或 WX 完成角色绑定。 -5. 后端复用现有 Playwright 自动化能力,自动提交 CDK、执行绑定、生成截图。 -6. 前端实时展示绑定状态、兑换状态和最终截图。 - -该方案采用“订单编排层 + 浏览器自动化执行层”两层结构,不直接把现有浏览器会话工具当作订单系统使用。 - -## 2. 当前系统现状 - -### 2.1 后端现状 - -当前后端主线是浏览器会话兑换工具,而不是订单系统。 - -- Express 入口在 [src/index.js](/Users/yml/codes/order-site-backend/src/index.js:1) -- 现有路由只覆盖腾讯浏览器会话接口,在 [src/routes/tencent.js](/Users/yml/codes/order-site-backend/src/routes/tencent.js:1) -- 浏览器会话创建、刷新、关闭、兑换都集中在 [src/services/session.js](/Users/yml/codes/order-site-backend/src/services/session.js:80) -- 验证码识别与兑换提交流程在 [src/services/session-redeem.js](/Users/yml/codes/order-site-backend/src/services/session-redeem.js:3) -- 截图、结果 JSON、证明产物生成在 [src/services/session-proof.js](/Users/yml/codes/order-site-backend/src/services/session-proof.js:16) -- OCR 子服务使用 `uv` 启动,在 [src/services/ocr.js](/Users/yml/codes/order-site-backend/src/services/ocr.js:83) - -当前系统没有以下关键能力: - -- 没有订单表 -- 没有 CDK 库存表 -- 没有 webhook 接收和签名校验 -- 没有任务状态机 -- 没有用户领取链接管理 -- 没有消息投递模块 -- 没有浏览器会话与订单任务的绑定关系 - -### 2.2 前端现状 - -当前前端是一个单页腾讯兑换工具。 - -- 路由只包含 `/tx/browser`,在 [src/router/index.ts](/Users/yml/codes/order-site-rewrite/src/router/index.ts:1) -- 主页面在 [src/views/tx/TencentBrowserView.vue](/Users/yml/codes/order-site-rewrite/src/views/tx/TencentBrowserView.vue:1) -- 页面编排在 [src/composables/useTencentBrowserSessionPage.ts](/Users/yml/codes/order-site-rewrite/src/composables/useTencentBrowserSessionPage.ts:1) -- 状态轮询在 [src/composables/tencent/useTencentBrowserSessionPolling.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPolling.ts:1) -- 兑换前角色确认、兑换按钮、结果展示已经具备基础 UI,可复用 - -当前前端没有以下能力: - -- 没有订单领取页 -- 没有 token 校验页 -- 没有订单详情页 -- 没有任务状态驱动的前端展示 -- 没有“订单上下文中的浏览器会话”概念 - -## 3. 总体架构 - -### 3.1 分层设计 - -建议分成 4 层: - -1. 平台接入层 - 负责接收订单创建成功、支付成功、售后等通知,完成签名校验和原始数据落库。 -2. 订单编排层 - 负责订单幂等、商品解析、CDK 预占、任务创建、链接生成、状态流转。 -3. 浏览器自动化执行层 - 复用现有腾讯浏览器会话、角色识别、验证码识别、兑换提交、截图生成能力。 -4. 用户交互层 - 用户通过领取链接进入前端页面,完成 QQ/WX 登录、角色确认、查看结果。 - -### 3.2 核心原则 - -- 订单与浏览器会话解耦 -- CDK 先预占、后核销 -- 所有平台通知必须幂等 -- 用户访问链接使用 token,不直接使用订单号鉴权 -- 浏览器自动化失败必须可重试、可人工接管 -- 所有关键节点都要保留审计数据 - -## 4. 业务流程 - -### 4.1 主流程 - -1. 平台发送订单通知到后端 webhook。 -2. 后端校验签名,按平台订单号幂等写入 `orders`。 -3. 根据订单商品、规格、渠道信息生成一个或多个 `delivery_tasks`。 -4. 系统从 `cdk_inventory` 中为任务预占可用 CDK。 -5. 系统生成 `claim_token`,构造领取链接。 -6. 系统通过站内消息、短信、客服人工转发或支付成功页展示,把链接给用户。 -7. 用户打开链接,前端调用后端校验 token 和任务状态。 -8. 用户选择 QQ 或 WX 登录,后端创建浏览器 session,并绑定到 `delivery_task`。 -9. 用户扫码后,后端识别角色、大区、昵称等信息。 -10. 前端展示识别结果,用户确认角色无误。 -11. 后端自动执行 CDK 提交、验证码识别、截图生成。 -12. 成功后任务进入 `redeemed`,CDK 进入 `delivered`。 -13. 前端展示截图、结果消息、订单号、角色信息。 - -### 4.2 异常流程 - -- webhook 重复推送:只更新状态,不重复创建任务 -- 商品无法匹配 CDK:任务进入 `manual_review` -- CDK 库存不足:任务进入 `waiting_inventory` -- token 过期:提示用户重新获取领取链接 -- 浏览器自动化失败:任务进入 `retry_pending` 或 `manual_review` -- 用户长时间未领取:任务进入 `expired`,释放预占 CDK - -## 5. 数据模型设计 - -建议先上 SQLite,跑通后如有并发压力再切 PostgreSQL。当前项目还没有数据库依赖,先用 SQLite 可以把复杂度压低。 - -### 5.1 `orders` - -订单主表。 - -字段建议: - -- `id` -- `platform` -- `platform_order_id` -- `order_status` -- `pay_status` -- `buyer_id` -- `buyer_name` -- `raw_payload_json` -- `created_at` -- `updated_at` -- `paid_at` - -约束建议: - -- `platform + platform_order_id` 唯一 - -### 5.2 `order_items` - -订单子项表,用于一单多商品。 - -字段建议: - -- `id` -- `order_id` -- `sku_code` -- `sku_name` -- `quantity` -- `spec_json` -- `delivery_mode` -- `created_at` -- `updated_at` - -### 5.3 `cdk_inventory` - -CDK 库存表。 - -字段建议: - -- `id` -- `batch_no` -- `sku_code` -- `cdk_code` -- `status` -- `reserved_by_task_id` -- `delivered_at` -- `invalid_reason` -- `created_at` -- `updated_at` - -状态建议: - -- `available` -- `reserved` -- `delivered` -- `invalid` - -### 5.4 `delivery_tasks` - -交付任务表,是整个方案的核心。 - -字段建议: - -- `id` -- `order_id` -- `order_item_id` -- `platform_order_id` -- `task_no` -- `task_status` -- `login_type` -- `claim_token_id` -- `reserved_cdk_id` -- `browser_session_id` -- `role_id` -- `role_name` -- `area` -- `partition` -- `nickname` -- `result_code` -- `result_message` -- `screenshot_path` -- `artifacts_json` -- `last_error` -- `retry_count` -- `expires_at` -- `claimed_at` -- `redeemed_at` -- `created_at` -- `updated_at` - -状态建议: - -- `pending_payment` -- `paid` -- `waiting_inventory` -- `cdk_reserved` -- `link_generated` -- `claimed` -- `role_confirmed` -- `redeeming` -- `redeemed` -- `retry_pending` -- `manual_review` -- `expired` -- `closed` - -### 5.5 `claim_tokens` - -领取链接表。 - -字段建议: - -- `id` -- `task_id` -- `token` -- `status` -- `expired_at` -- `used_at` -- `max_use_count` -- `used_count` -- `created_at` -- `updated_at` - -状态建议: - -- `active` -- `used` -- `expired` -- `revoked` - -### 5.6 `webhook_events` - -保留原始通知,便于审计和重放。 - -字段建议: - -- `id` -- `platform` -- `event_type` -- `event_key` -- `signature_valid` -- `headers_json` -- `query_json` -- `body_json` -- `processed` -- `process_error` -- `created_at` - -## 6. 后端模块拆分 - -建议在现有后端上新增以下模块。 - -### 6.1 路由层 - -新增路由目录建议: - -- `src/routes/webhooks.js` -- `src/routes/orders.js` -- `src/routes/claims.js` -- `src/routes/admin.js` - -说明: - -- `webhooks.js` 接平台推送 -- `orders.js` 给内部系统或管理端查订单和任务 -- `claims.js` 给用户领取页调用 -- `admin.js` 做手工补单、重试、失效处理 - -### 6.2 服务层 - -新增服务建议: - -- `src/services/order-service.js` -- `src/services/order-item-service.js` -- `src/services/delivery-task-service.js` -- `src/services/cdk-service.js` -- `src/services/claim-service.js` -- `src/services/webhook-service.js` -- `src/services/message-service.js` -- `src/services/redeem-executor-service.js` - -职责划分建议: - -- `webhook-service` - 负责签名校验、原始通知记录、事件分发 -- `order-service` - 负责订单幂等写入和状态更新 -- `delivery-task-service` - 负责任务状态机 -- `cdk-service` - 负责 CDK 分配、预占、释放、核销 -- `claim-service` - 负责 token 生成、校验、过期 -- `message-service` - 负责发链接 -- `redeem-executor-service` - 负责把任务桥接到现有腾讯浏览器会话执行器 - -### 6.3 持久化层 - -建议新增: - -- `src/db/` -- `src/repositories/` - -推荐目录: - -- `src/db/client.js` -- `src/repositories/order-repo.js` -- `src/repositories/task-repo.js` -- `src/repositories/cdk-repo.js` -- `src/repositories/claim-repo.js` -- `src/repositories/webhook-repo.js` - -## 7. 对现有浏览器自动化能力的复用方式 - -现有后端能力不废弃,而是作为执行器继续使用。 - -### 7.1 可直接复用的能力 - -- 创建浏览器会话:`createTencentBrowserSession` -- 轮询会话状态:`getTencentBrowserSession`、`getTencentBrowserSessionSummary` -- 扫码登录后识别角色信息:`session.js` 内已有活动页信息抽取逻辑 -- 执行兑换:`redeemTencentBrowserSession` -- 生成截图和结果 JSON:`saveRedeemArtifacts` -- OCR worker:继续使用当前 `uv run ocr-worker worker` - -### 7.2 需要补的一层包装 - -不要让订单系统直接操作裸 `sessionId`,应通过 `redeem-executor-service` 管理: - -1. 根据 `taskId` 创建浏览器 session -2. 把 `taskId <-> sessionId` 关系落库 -3. 用户扫码完成后,把识别到的角色信息同步回任务 -4. 用户确认角色后,再触发兑换 -5. 兑换完成后,把截图路径和最终结果回写到任务 - -## 8. API 设计 - -### 8.1 平台 webhook - -`POST /api/v1/webhooks/agiso/trade` - -职责: - -- 接收订单创建成功通知 -- 接收支付成功通知 -- 验签 -- 幂等入库 -- 推进任务状态 - -### 8.2 用户领取页相关 - -`GET /api/v1/claim/:token` - -返回: - -- token 是否有效 -- 订单号 -- 商品摘要 -- 当前任务状态 -- 是否已绑定角色 -- 是否已有截图 - -`POST /api/v1/claim/:token/session` - -入参: - -- `loginType: qq | wx` - -职责: - -- 校验 token -- 创建腾讯浏览器 session -- 将 session 绑定到任务 -- 返回二维码和 session 摘要 - -`GET /api/v1/claim/:token/session/summary` - -职责: - -- 轮询当前领取任务下的 session 状态 -- 返回角色信息、二维码状态、截图状态 - -`POST /api/v1/claim/:token/confirm-role` - -职责: - -- 用户确认当前角色无误 -- 把任务推进到 `role_confirmed` - -`POST /api/v1/claim/:token/redeem` - -职责: - -- 后端读取预占 CDK -- 调用执行器自动提交 -- 回写结果 - -`GET /api/v1/claim/:token/screenshot` - -职责: - -- 返回最终截图 - -### 8.3 管理后台或内部接口 - -`POST /api/v1/admin/tasks/:taskId/retry` - -`POST /api/v1/admin/tasks/:taskId/release-cdk` - -`POST /api/v1/admin/tasks/:taskId/close` - -`GET /api/v1/admin/orders/:orderId` - -`GET /api/v1/admin/tasks/:taskId` - -## 9. 运营后台设计 - -### 9.1 是否需要后台 - -需要,而且应作为正式模块纳入方案。 - -原因: - -- 订单通知会重复、失败或延迟,必须能人工排查 -- CDK 库存需要日常导入、校验、释放、核销 -- 自动化链路不是 100% 成功,必须有失败任务处理入口 -- 用户咨询时,需要快速看到订单、任务、角色、截图和错误原因 -- 后续一定会出现补发、重试、关闭任务、重新生成链接等运营动作 - -如果没有后台,系统上线后所有异常都会退化成查数据库、查日志、手改数据,维护成本会快速失控。 - -### 9.2 更稳的做法 - -更稳的做法不是把管理页面直接混进用户领取页,而是拆成两套入口: - -1. 用户前台 - 继续服务用户领取、扫码登录、角色确认、结果查看。 -2. 运营后台 - 专门服务订单排查、CDK 管理、任务重试、异常处理。 - -建议形式: - -- 用户前台继续使用 [order-site-rewrite](/Users/yml/codes/order-site-rewrite) -- 运营后台先作为同仓库下独立路由模块实现,例如 `/admin` -- 后端单独提供 `/api/v1/admin/*` 接口 - -这样做的好处: - -- 用户链路和运营链路隔离 -- 权限边界更清楚 -- 页面状态不会互相污染 -- 以后即使单独拆管理后台,也不会影响用户前台 - -### 9.3 后台第一版目标 - -第一版后台不追求复杂权限系统和漂亮界面,目标只有一个: - -让你能稳定运营这条自动交付链路。 - -第一版最少应覆盖: - -1. 查订单 -2. 查交付任务 -3. 导入和维护 CDK -4. 查看 webhook 日志 -5. 重试失败任务 -6. 释放预占 CDK -7. 重新生成领取链接 - -### 9.4 后台页面模块建议 - -#### 1. 概览页 - -展示核心数字: - -- 今日订单数 -- 已支付待领取数 -- 领取中任务数 -- 兑换成功数 -- 异常任务数 -- 库存不足的 SKU 数 - -这页主要用来让你快速判断系统是否在健康运行。 - -#### 2. 订单列表页 - -展示字段建议: - -- 订单号 -- 平台 -- 商品摘要 -- 支付状态 -- 订单状态 -- 创建时间 -- 更新时间 -- 对应任务数 - -支持筛选: - -- 平台订单号 -- 支付状态 -- 时间范围 -- 商品 SKU - -进入详情后可查看: - -- 原始订单通知 -- 子项列表 -- 对应交付任务 - -#### 3. 交付任务列表页 - -这是后台最核心的页面。 - -展示字段建议: - -- `task_no` -- 平台订单号 -- 商品 SKU -- 当前任务状态 -- 预占 CDK -- 登录方式 -- 角色名 -- 角色 ID -- 浏览器 sessionId -- 重试次数 -- 最后错误 -- 创建时间 -- 更新时间 - -支持操作: - -- 查看详情 -- 重新生成链接 -- 重试任务 -- 释放 CDK -- 关闭任务 -- 标记人工处理 - -#### 4. CDK 管理页 - -这是第二核心页面。 - -应支持: - -- 单条新增 -- 批量导入 -- 按 SKU 查看库存 -- 查看状态分布 -- 查看预占任务 -- 手动失效 -- 手动释放 - -展示字段建议: - -- `sku_code` -- `batch_no` -- `cdk_code` -- `status` -- `reserved_by_task_id` -- `created_at` -- `updated_at` - -导入方式建议至少支持: - -- 文本框多行粘贴导入 -- CSV 文件导入 - -导入校验建议: - -- 去重 -- 空值校验 -- SKU 必填 -- 批次号可选但建议保留 - -#### 5. webhook 日志页 - -展示: - -- 平台 -- 事件类型 -- 平台订单号 -- 验签是否通过 -- 是否处理成功 -- 错误信息 -- 到达时间 - -支持查看: - -- headers -- query -- body -- 对应生成的订单和任务 - -可选操作: - -- 手动重放处理 - -#### 6. 任务详情页 - -任务详情页建议整合以下信息: - -- 订单信息 -- 商品和 SKU -- claim token 状态 -- 浏览器 session 状态 -- 角色识别结果 -- 兑换结果 -- 截图预览 -- 原始错误日志 -- 操作按钮 - -这个页面会成为你处理异常单的主工作台。 - -### 9.5 后台最小接口集 - -建议新增后台接口: - -#### 订单 - -- `GET /api/v1/admin/orders` -- `GET /api/v1/admin/orders/:orderId` - -#### 任务 - -- `GET /api/v1/admin/tasks` -- `GET /api/v1/admin/tasks/:taskId` -- `POST /api/v1/admin/tasks/:taskId/retry` -- `POST /api/v1/admin/tasks/:taskId/close` -- `POST /api/v1/admin/tasks/:taskId/release-cdk` -- `POST /api/v1/admin/tasks/:taskId/regenerate-claim-link` -- `POST /api/v1/admin/tasks/:taskId/mark-manual-review` - -#### CDK - -- `GET /api/v1/admin/cdks` -- `POST /api/v1/admin/cdks` -- `POST /api/v1/admin/cdks/import` -- `POST /api/v1/admin/cdks/:cdkId/invalidate` -- `POST /api/v1/admin/cdks/:cdkId/release` - -#### webhook - -- `GET /api/v1/admin/webhook-events` -- `GET /api/v1/admin/webhook-events/:eventId` -- `POST /api/v1/admin/webhook-events/:eventId/replay` - -#### 概览 - -- `GET /api/v1/admin/dashboard/summary` - -### 9.6 后台权限建议 - -第一版建议至少分两层: - -1. `admin` - 可导入 CDK、改任务状态、重试任务、关闭任务、重放 webhook。 -2. `operator` - 可查订单、查任务、看截图、看日志,但不能做高风险修改。 - -如果第一版先不做完整账号体系,也至少要做一个最简单的后台鉴权,例如: - -- 独立后台口令 -- 基础登录态 -- 后端校验管理接口访问权限 - -不要把后台接口直接裸露给公网无鉴权访问。 - -### 9.7 后台与前台的边界 - -建议明确边界: - -- 前台只处理用户领取和结果查看 -- 后台只处理运营和异常处置 - -前台不要出现: - -- 手动改任务状态 -- 手动改 CDK -- 看其他订单 - -后台不要承担: - -- 用户扫码登录入口 -- 用户领取页展示逻辑 - -这样职责清晰,后续维护成本最低。 - -### 9.8 后台的落地顺序 - -建议放在整体计划的阶段 4 中作为正式内容,但有两个能力应提前做: - -1. CDK 导入 -2. 任务列表与详情查看 - -原因: - -- 没有 CDK 导入能力,阶段 1 根本跑不顺 -- 没有任务查看能力,阶段 2 和阶段 3 出问题时无法排查 - -因此更合适的执行顺序是: - -- 阶段 1 完成订单骨架后,先补一个极简后台 -- 阶段 2 和 3 继续完善业务闭环 -- 阶段 4 再补全 webhook 重放、统计面板、权限细化 - -### 9.9 后台最小可行版本 - -后台 MVP 建议包含: - -1. 登录页 -2. 概览页 -3. 订单列表页 -4. 任务列表页 -5. 任务详情页 -6. CDK 导入页 -7. webhook 日志页 - -这已经足够支撑第一版系统上线和日常运营。 - -### 9.10 结论 - -后端侧需要一个管理界面,而且应当作为正式模块设计,不建议临时拼接。 - -更稳的做法是: - -- 用户前台与运营后台分离 -- 后台先做最小可行版本 -- 优先覆盖查单、查任务、导入 CDK、重试异常 -- 后台接口独立鉴权 - -## 10. 前端设计 - -### 9.1 路由设计 - -现有前端只有 `/tx/browser`,建议新增: - -- `/claim/:token` - -这个页面才是正式业务入口。 - -`/tx/browser` 保留为内部调试工具页,不作为正式交付页面。 - -### 9.2 页面结构 - -建议页面拆成 4 个区域: - -1. 订单摘要区 - 展示订单号、商品名、状态说明 -2. 登录绑定区 - 复用现有二维码卡片能力 -3. 角色确认区 - 复用现有角色展示和确认能力 -4. 结果展示区 - 复用现有截图和结果卡片能力 - -### 9.3 可复用的前端模块 - -可以复用: - -- [TencentAuthCard.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentAuthCard.vue:1) -- [TencentRedeemPanel.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentRedeemPanel.vue:1) -- [TencentResultPanel.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentResultPanel.vue:1) -- [useTencentBrowserSessionPolling.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPolling.ts:1) -- [useTencentBrowserSessionPresentation.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPresentation.ts:1) - -但需要改造: - -- 不再让前端自己输入兑换码 -- 不再让前端自己决定是否创建裸 session -- 页面所有操作都基于 `claim token` - -### 9.4 前端状态设计 - -建议页面状态围绕任务状态展开,而不是围绕裸 session: - -- `tokenValid` -- `taskStatus` -- `orderSummary` -- `session` -- `activityInfo` -- `roleConfirmed` -- `redeemResult` -- `screenshotUrl` - -## 11. 状态机设计 - -### 10.1 任务状态流转 - -主状态机建议如下: - -1. `pending_payment` -2. `paid` -3. `waiting_inventory` -4. `cdk_reserved` -5. `link_generated` -6. `claimed` -7. `role_confirmed` -8. `redeeming` -9. `redeemed` - -异常分支: - -- `retry_pending` -- `manual_review` -- `expired` -- `closed` - -### 10.2 CDK 状态流转 - -1. `available` -2. `reserved` -3. `delivered` - -异常分支: - -- `invalid` - -### 10.3 token 状态流转 - -1. `active` -2. `used` - -异常分支: - -- `expired` -- `revoked` - -## 12. 安全与风控 - -### 11.1 token 安全 - -- token 必须使用高熵随机串 -- 不要把订单号作为唯一访问凭证 -- token 要支持过期 -- token 要支持撤销 -- token 校验失败不要泄露订单是否存在 - -### 11.2 webhook 安全 - -- 验签必须在业务处理前完成 -- 保存原始 query、body、headers -- 根据平台订单号和事件类型做幂等 -- 对重复通知返回成功,避免平台无限重试 - -### 11.3 自动化风险 - -- 登录状态和验证码都可能失败 -- 活动页 DOM 变更会导致自动化失效 -- 平台风控弹窗可能导致流程卡死 -- 必须保留重试和人工兜底入口 - -## 13. 失败补偿机制 - -### 12.1 自动重试 - -可自动重试的场景: - -- OCR 识别失败 -- 页面刷新失败 -- 临时网络超时 -- 可恢复的验证码错误 - -不建议自动无限重试,应限制次数并回写错误。 - -### 12.2 人工接管 - -以下场景建议直接转人工: - -- 角色识别异常 -- 商品和 CDK 映射无法确认 -- 自动化连续失败 -- CDK 已疑似提交但返回结果不明确 - -### 12.3 过期处理 - -- 领取链接超时未使用,任务转 `expired` -- 任务过期后释放 `reserved` 状态的 CDK -- 如已创建浏览器 session,应主动清理 - -## 14. 技术选型建议 - -### 13.1 后端 - -当前后端是 Node.js + Express + Playwright,建议保持不变。 - -新增建议: - -- 数据库先用 SQLite -- 数据访问先用简单 repository 封装 -- 后续需要时再引入 ORM - -原因: - -- 先把主流程跑通 -- 当前系统规模小,没必要过早引入太重的基础设施 -- 浏览器自动化已经在 Node 侧,订单编排继续留在同一进程更简单 - -### 13.2 前端 - -当前前端是 Vue 3 + Element Plus,建议保持不变。 - -只需要增加: - -- claim 页面 -- claim 相关 API service -- task 视图模型 - -### 13.3 Python 相关 - -当前 Python 仅用于 OCR worker,继续使用 `uv` 管理即可。 - -如果后续需要: - -- 图像增强 -- 模板匹配 -- 截图后处理 - -仍然应放在独立 Python 子服务里,并统一通过 `uv` 维护依赖。 - -## 15. 推荐执行顺序 - -建议分 4 个阶段实施。 - -### 阶段 1:后端订单骨架 - -目标: - -- 接 webhook -- 落订单 -- 建任务 -- 预占 CDK -- 生成领取链接 - -要做的事: - -1. 增加数据库和表结构 -2. 实现 webhook 验签和幂等 -3. 实现订单、任务、CDK、token 的 repository 和 service -4. 实现任务状态机基础逻辑 -5. 实现领取链接生成 - -阶段产出: - -- 支付成功后,系统能自动生成领取任务和领取链接 - -### 阶段 2:极简后台 + 前端领取页 - -目标: - -- 有基本运营能力 -- 用户通过 token 打开页面 -- 看见订单信息 -- 创建登录 session -- 扫码并确认角色 - -要做的事: - -1. 新增 `/admin` 路由和极简后台骨架 -2. 实现订单列表、任务列表、任务详情、CDK 导入 -3. 新增 `/claim/:token` 路由 -4. 新增 claim 页面和 API service -5. 复用当前二维码、轮询、角色确认组件 -6. 页面状态从 `session 驱动` 改成 `task + session 双驱动` - -阶段产出: - -- 运营能查任务和导入 CDK,用户能进入正式领取页面并完成角色绑定 - -### 阶段 3:自动兑换闭环 - -目标: - -- 用户确认角色后自动提交 CDK -- 生成截图并前端展示 - -要做的事: - -1. 封装 `redeem-executor-service` -2. 将任务与 session 建立绑定关系 -3. 后端从预占 CDK 读取兑换码,不再由前端输入 -4. 回写截图路径、业务返回码、错误信息 - -阶段产出: - -- 从订单通知到最终截图形成完整闭环 - -### 阶段 4:补偿和完整运营能力 - -目标: - -- 可重试 -- 可过期释放 -- 可人工处理 -- 有更完整的后台能力 - -要做的事: - -1. 增加任务重试接口 -2. 增加过期清理任务 -3. 增加 webhook 重放 -4. 增加概览统计和异常筛选 -5. 增加后台权限细化 -6. 增加关键日志和审计落库 - -阶段产出: - -- 系统具备基本上线可运维性 - -## 16. 最小可行版本定义 - -第一版上线目标不建议追求“完全无人值守”,建议定义为: - -- 平台支付成功后自动创建领取任务 -- 自动预占 CDK -- 自动生成领取链接 -- 用户进入页面后自行扫码登录 -- 用户确认角色后系统自动提交 CDK -- 成功后展示截图 -- 运营可在后台查看订单、任务、库存和 webhook 日志 -- 失败后进入重试或人工处理 - -这是当前代码基础上最稳、最容易落地、售后风险最低的版本。 - -## 17. 关键决策结论 - -- 采用方案 A 是合理的 -- 不建议直接用订单号做访问凭证 -- 不建议前端继续保留“手输 CDK”的正式业务模式 -- 不建议一开始就做全自动零确认绑定 -- 建议先做订单编排层,再复用现有浏览器自动化执行层 -- 建议优先做 SQLite 版本跑通链路 -- 建议把 `/tx/browser` 保留为内部调试页,把 `/claim/:token` 做成正式业务入口 -- 建议把运营后台作为正式模块纳入,而不是后补工具页 - -## 18. 下一步落地建议 - -下一步建议直接进入“阶段 1 实施设计”,输出更细的开发清单: - -1. 数据库表结构 SQL -2. 后端目录和文件改造清单 -3. API 请求和响应示例 -4. 前台 `/claim/:token` 页面状态图 -5. 后台 `/admin` 页面结构与接口契约 -6. 任务状态机枚举定义 - -如果继续推进,我建议下一步先把: - -- 表结构 -- 后端 API 草案 -- 前端 `/claim/:token` 页面接口契约 -- 后台 `/admin` 核心接口契约 - -这三部分先定死,然后再开始编码。 diff --git a/apps/backend/阶段1-4实施规格说明.md b/apps/backend/阶段1-4实施规格说明.md deleted file mode 100644 index ade398c7..00000000 --- a/apps/backend/阶段1-4实施规格说明.md +++ /dev/null @@ -1,1111 +0,0 @@ -# 阶段 1-4 实施规格说明 - -## 1. 目标 - -本文件把《方案A-订单编排与自动兑换设计》收敛成可直接编码的规格,覆盖: - -1. SQLite 表结构 SQL -2. 后端 API 契约 -3. 前台 `/claim/:token` 页面接口契约 -4. 后台 `/admin` 页面结构与接口契约 - -默认约束: - -- 后端仍使用 Node.js + Express -- 浏览器自动化仍复用现有 `src/services/session.js` -- Python 子服务继续使用 `uv` -- 第一版数据库使用 SQLite - -## 2. SQLite 表结构 - -建议新建目录: - -- `src/db/` -- `src/db/migrations/` - -建议首个 migration 文件: - -- `src/db/migrations/001_init.sql` - -以下 SQL 为第一版建议结构。 - -```sql -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - platform_order_id TEXT NOT NULL, - order_status TEXT NOT NULL DEFAULT 'created', - pay_status TEXT NOT NULL DEFAULT 'unpaid', - buyer_id TEXT NOT NULL DEFAULT '', - buyer_name TEXT NOT NULL DEFAULT '', - receiver_contact TEXT NOT NULL DEFAULT '', - total_amount INTEGER NOT NULL DEFAULT 0, - currency TEXT NOT NULL DEFAULT 'CNY', - raw_payload_json TEXT NOT NULL DEFAULT '{}', - paid_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(platform, platform_order_id) -); - -CREATE TABLE IF NOT EXISTS order_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - sku_code TEXT NOT NULL, - sku_name TEXT NOT NULL DEFAULT '', - quantity INTEGER NOT NULL DEFAULT 1, - spec_json TEXT NOT NULL DEFAULT '{}', - delivery_mode TEXT NOT NULL DEFAULT 'claim_link', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); -CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code); - -CREATE TABLE IF NOT EXISTS cdk_inventory ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - batch_no TEXT NOT NULL DEFAULT '', - sku_code TEXT NOT NULL, - cdk_code TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'available', - reserved_by_task_id INTEGER, - invalid_reason TEXT NOT NULL DEFAULT '', - delivered_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(sku_code, cdk_code) -); - -CREATE INDEX IF NOT EXISTS idx_cdk_inventory_sku_code ON cdk_inventory(sku_code); -CREATE INDEX IF NOT EXISTS idx_cdk_inventory_status ON cdk_inventory(status); -CREATE INDEX IF NOT EXISTS idx_cdk_inventory_reserved_task ON cdk_inventory(reserved_by_task_id); - -CREATE TABLE IF NOT EXISTS delivery_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - order_item_id INTEGER NOT NULL, - platform_order_id TEXT NOT NULL, - task_no TEXT NOT NULL, - task_status TEXT NOT NULL DEFAULT 'pending_payment', - login_type TEXT NOT NULL DEFAULT '', - claim_token_id INTEGER, - reserved_cdk_id INTEGER, - browser_session_id TEXT NOT NULL DEFAULT '', - nickname TEXT NOT NULL DEFAULT '', - role_id TEXT NOT NULL DEFAULT '', - role_name TEXT NOT NULL DEFAULT '', - area TEXT NOT NULL DEFAULT '', - partition_name TEXT NOT NULL DEFAULT '', - result_code TEXT NOT NULL DEFAULT '', - result_message TEXT NOT NULL DEFAULT '', - screenshot_path TEXT NOT NULL DEFAULT '', - artifacts_json TEXT NOT NULL DEFAULT '{}', - last_error TEXT NOT NULL DEFAULT '', - retry_count INTEGER NOT NULL DEFAULT 0, - expires_at TEXT, - claimed_at TEXT, - role_confirmed_at TEXT, - redeemed_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE, - FOREIGN KEY(order_item_id) REFERENCES order_items(id) ON DELETE CASCADE, - FOREIGN KEY(reserved_cdk_id) REFERENCES cdk_inventory(id) ON DELETE SET NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_tasks_task_no ON delivery_tasks(task_no); -CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_id ON delivery_tasks(order_id); -CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_item_id ON delivery_tasks(order_item_id); -CREATE INDEX IF NOT EXISTS idx_delivery_tasks_status ON delivery_tasks(task_status); -CREATE INDEX IF NOT EXISTS idx_delivery_tasks_platform_order_id ON delivery_tasks(platform_order_id); - -CREATE TABLE IF NOT EXISTS claim_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - token TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - expired_at TEXT NOT NULL, - used_at TEXT, - max_use_count INTEGER NOT NULL DEFAULT 1, - used_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES delivery_tasks(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_tokens_token ON claim_tokens(token); -CREATE INDEX IF NOT EXISTS idx_claim_tokens_task_id ON claim_tokens(task_id); -CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status); - -CREATE TABLE IF NOT EXISTS webhook_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - event_type TEXT NOT NULL, - event_key TEXT NOT NULL, - signature_valid INTEGER NOT NULL DEFAULT 0, - headers_json TEXT NOT NULL DEFAULT '{}', - query_json TEXT NOT NULL DEFAULT '{}', - body_json TEXT NOT NULL DEFAULT '{}', - processed INTEGER NOT NULL DEFAULT 0, - process_error TEXT NOT NULL DEFAULT '', - related_order_id INTEGER, - created_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_webhook_events_platform_key ON webhook_events(platform, event_key); -CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed); -CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at); -``` - -## 3. 枚举建议 - -### 3.1 `order_status` - -- `created` -- `paid` -- `closed` -- `refunding` -- `refunded` - -### 3.2 `pay_status` - -- `unpaid` -- `paid` -- `failed` -- `refunded` - -### 3.3 `task_status` - -- `pending_payment` -- `paid` -- `waiting_inventory` -- `cdk_reserved` -- `link_generated` -- `claimed` -- `role_confirmed` -- `redeeming` -- `redeemed` -- `retry_pending` -- `manual_review` -- `expired` -- `closed` - -### 3.4 `cdk_inventory.status` - -- `available` -- `reserved` -- `delivered` -- `invalid` - -### 3.5 `claim_tokens.status` - -- `active` -- `used` -- `expired` -- `revoked` - -## 4. 目录改造建议 - -建议后端增加以下文件: - -```text -src/ - db/ - client.js - migrate.js - migrations/ - 001_init.sql - repositories/ - order-repo.js - order-item-repo.js - task-repo.js - cdk-repo.js - claim-token-repo.js - webhook-event-repo.js - routes/ - webhooks.js - claims.js - admin.js - services/ - webhook-service.js - order-service.js - delivery-task-service.js - cdk-service.js - claim-service.js - redeem-executor-service.js - admin-service.js - utils/ - time.js - random.js - pagination.js -``` - -## 5. 后端 API 契约 - -统一响应建议: - -```json -{ - "code": 0, - "msg": "ok", - "data": {} -} -``` - -失败响应建议: - -```json -{ - "code": 40001, - "msg": "token 无效", - "data": null -} -``` - ---- - -## 6. webhook 接口 - -### 6.1 接收订单/支付通知 - -`POST /api/v1/webhooks/agiso/trade` - -说明: - -- 接收平台订单创建成功或支付成功通知 -- query 中可能带 `timestamp`、`sign`、`aopic` -- body 中可能带 `json` -- 具体字段以平台最终联调为准,但内部处理应统一为标准事件结构 - -处理步骤: - -1. 保存原始请求 -2. 验签 -3. 解析出平台订单号、事件类型、订单数据 -4. 按 `platform + platform_order_id + event_type` 做幂等 -5. 更新订单与交付任务 - -成功响应: - -```json -{ - "code": 0, - "msg": "success", - "data": { - "accepted": true, - "platformOrderId": "592823138", - "eventType": "payment_success" - } -} -``` - ---- - -## 7. 前台领取链路接口 - -### 7.1 校验领取链接 - -`GET /api/v1/claim/:token` - -用途: - -- 页面首次打开时获取任务和订单摘要 -- 校验 token 是否有效 - -响应示例: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "tokenStatus": "active", - "task": { - "taskId": 12, - "taskNo": "DT202604070001", - "status": "link_generated", - "expiresAt": "2026-04-08T12:00:00.000Z", - "claimedAt": null, - "roleConfirmedAt": null, - "redeemedAt": null, - "lastError": "" - }, - "order": { - "platformOrderId": "592823138", - "skuCode": "dnf-cdk-a", - "skuName": "DNF 活动礼包", - "quantity": 1 - }, - "session": null, - "result": null - } -} -``` - -失败场景: - -- token 不存在 -- token 已过期 -- token 已撤销 -- task 已关闭 - -### 7.2 创建领取会话 - -`POST /api/v1/claim/:token/session` - -请求体: - -```json -{ - "loginType": "qq" -} -``` - -说明: - -- 创建腾讯浏览器 session -- 将 session 与 task 绑定 -- task 状态推进到 `claimed` - -成功响应: - -```json -{ - "code": 0, - "msg": "浏览器会话已创建", - "data": { - "task": { - "taskId": 12, - "taskNo": "DT202604070001", - "status": "claimed" - }, - "session": { - "sessionId": "txbs-ab12cd34ef56", - "status": "waiting_scan", - "loginType": "qq", - "notice": "请使用QQ扫描二维码", - "qrImageBase64": "base64...", - "updatedAt": "2026-04-07T12:00:00.000Z", - "activityInfo": { - "nickname": "", - "role": { - "ready": false, - "roleName": "", - "roleId": "" - } - } - } - } -} -``` - -### 7.3 轮询领取会话状态 - -`GET /api/v1/claim/:token/session/summary` - -说明: - -- 轮询 task 绑定的 session -- 同时返回 task 最新状态 - -成功响应: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "task": { - "taskId": 12, - "taskNo": "DT202604070001", - "status": "claimed", - "lastError": "" - }, - "session": { - "sessionId": "txbs-ab12cd34ef56", - "status": "ready_to_redeem", - "loginType": "qq", - "notice": "登录已完成,当前角色 阿修罗", - "updatedAt": "2026-04-07T12:03:00.000Z", - "activityInfo": { - "nickname": "xxx", - "role": { - "ready": true, - "roleName": "阿修罗", - "roleId": "123456", - "area": "36", - "partition": "跨五" - } - }, - "artifacts": { - "hasScreenshot": false - } - } - } -} -``` - -### 7.4 确认角色 - -`POST /api/v1/claim/:token/confirm-role` - -请求体: - -```json -{ - "confirm": true -} -``` - -说明: - -- 只有当后端已识别到角色时才能确认 -- task 状态推进到 `role_confirmed` - -成功响应: - -```json -{ - "code": 0, - "msg": "角色已确认", - "data": { - "task": { - "taskId": 12, - "taskNo": "DT202604070001", - "status": "role_confirmed", - "roleConfirmedAt": "2026-04-07T12:05:00.000Z" - } - } -} -``` - -### 7.5 执行兑换 - -`POST /api/v1/claim/:token/redeem` - -请求体: - -```json -{} -``` - -说明: - -- 不再由前端传 CDK -- 后端从 `reserved_cdk_id` 读取对应兑换码 -- 执行成功后更新 task、cdk、截图路径 - -成功响应: - -```json -{ - "code": 0, - "msg": "兑换完成", - "data": { - "task": { - "taskId": 12, - "taskNo": "DT202604070001", - "status": "redeemed", - "resultCode": "0", - "resultMessage": "兑换成功", - "redeemedAt": "2026-04-07T12:06:00.000Z", - "screenshotReady": true - }, - "session": { - "sessionId": "txbs-ab12cd34ef56", - "status": "redeemed", - "notice": "兑换成功", - "artifacts": { - "hasScreenshot": true - } - } - } -} -``` - -失败响应建议: - -- `409` 当前状态不允许兑换 -- `400` 未确认角色 -- `409` 未预占 CDK -- `500` 自动化执行失败 - -### 7.6 获取结果截图 - -`GET /api/v1/claim/:token/screenshot` - -说明: - -- 返回图片流 -- 内部根据 task 取 `screenshot_path` - ---- - -## 8. 后台接口契约 - -### 8.1 登录 - -第一版可以先做简单登录: - -`POST /api/v1/admin/auth/login` - -请求体: - -```json -{ - "username": "admin", - "password": "******" -} -``` - -响应: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "token": "admin-session-token", - "role": "admin" - } -} -``` - -### 8.2 仪表盘 - -`GET /api/v1/admin/dashboard/summary` - -响应示例: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "todayOrders": 23, - "paidPendingClaim": 7, - "claimingTasks": 5, - "redeemedToday": 14, - "abnormalTasks": 2, - "lowInventorySkuCount": 1 - } -} -``` - -### 8.3 订单列表 - -`GET /api/v1/admin/orders` - -查询参数建议: - -- `page` -- `pageSize` -- `platformOrderId` -- `payStatus` -- `skuCode` -- `dateFrom` -- `dateTo` - -响应示例: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "items": [ - { - "orderId": 1, - "platform": "agiso", - "platformOrderId": "592823138", - "orderStatus": "paid", - "payStatus": "paid", - "buyerName": "", - "totalAmount": 300, - "createdAt": "2026-04-07T11:00:00.000Z", - "updatedAt": "2026-04-07T11:02:00.000Z", - "taskCount": 1 - } - ], - "pagination": { - "page": 1, - "pageSize": 20, - "total": 1 - } - } -} -``` - -### 8.4 订单详情 - -`GET /api/v1/admin/orders/:orderId` - -返回: - -- 订单主信息 -- 子项信息 -- 对应任务列表 -- webhook 摘要 - -### 8.5 任务列表 - -`GET /api/v1/admin/tasks` - -查询参数建议: - -- `page` -- `pageSize` -- `status` -- `platformOrderId` -- `taskNo` -- `skuCode` -- `roleId` -- `dateFrom` -- `dateTo` - -响应示例: - -```json -{ - "code": 0, - "msg": "ok", - "data": { - "items": [ - { - "taskId": 12, - "taskNo": "DT202604070001", - "platformOrderId": "592823138", - "skuCode": "dnf-cdk-a", - "status": "retry_pending", - "reservedCdkCodeMasked": "ABCD****WXYZ", - "loginType": "qq", - "roleName": "阿修罗", - "roleId": "123456", - "browserSessionId": "txbs-ab12cd34ef56", - "retryCount": 1, - "lastError": "OCR 识别失败", - "createdAt": "2026-04-07T11:00:00.000Z", - "updatedAt": "2026-04-07T11:06:00.000Z" - } - ], - "pagination": { - "page": 1, - "pageSize": 20, - "total": 1 - } - } -} -``` - -### 8.6 任务详情 - -`GET /api/v1/admin/tasks/:taskId` - -返回内容建议: - -- 任务基础信息 -- 订单摘要 -- token 信息 -- 预占 CDK 信息 -- 浏览器 session 摘要 -- 角色信息 -- 兑换结果 -- 截图 URL -- 操作按钮可用状态 - -### 8.7 重试任务 - -`POST /api/v1/admin/tasks/:taskId/retry` - -用途: - -- 对 `retry_pending` 或 `manual_review` 的任务发起重试 - -请求体: - -```json -{ - "reason": "manual retry" -} -``` - -### 8.8 重新生成领取链接 - -`POST /api/v1/admin/tasks/:taskId/regenerate-claim-link` - -说明: - -- 作废旧 token -- 创建新 token - -响应示例: - -```json -{ - "code": 0, - "msg": "领取链接已重新生成", - "data": { - "taskId": 12, - "claimUrl": "https://your-domain.com/#/claim/xxxxxxxx" - } -} -``` - -### 8.9 释放 CDK - -`POST /api/v1/admin/tasks/:taskId/release-cdk` - -说明: - -- 将任务绑定的 `reserved` CDK 释放回库存 -- 一般只允许未成功兑换任务执行 - -### 8.10 关闭任务 - -`POST /api/v1/admin/tasks/:taskId/close` - -用途: - -- 人工确认无需继续执行 - -### 8.11 CDK 列表 - -`GET /api/v1/admin/cdks` - -查询参数建议: - -- `page` -- `pageSize` -- `skuCode` -- `status` -- `batchNo` - -### 8.12 单条新增 CDK - -`POST /api/v1/admin/cdks` - -请求体: - -```json -{ - "skuCode": "dnf-cdk-a", - "batchNo": "20260407", - "cdkCode": "ABCDEFGH12345678" -} -``` - -### 8.13 批量导入 CDK - -`POST /api/v1/admin/cdks/import` - -请求体建议支持两种形式: - -形式一: - -```json -{ - "skuCode": "dnf-cdk-a", - "batchNo": "20260407", - "codes": [ - "AAAA-BBBB-CCCC", - "DDDD-EEEE-FFFF" - ] -} -``` - -形式二: - -```json -{ - "rows": [ - { - "skuCode": "dnf-cdk-a", - "batchNo": "20260407", - "cdkCode": "AAAA-BBBB-CCCC" - }, - { - "skuCode": "dnf-cdk-b", - "batchNo": "20260407", - "cdkCode": "DDDD-EEEE-FFFF" - } - ] -} -``` - -响应示例: - -```json -{ - "code": 0, - "msg": "导入完成", - "data": { - "total": 10, - "created": 8, - "duplicated": 2, - "invalid": 0 - } -} -``` - -### 8.14 webhook 列表 - -`GET /api/v1/admin/webhook-events` - -### 8.15 webhook 详情 - -`GET /api/v1/admin/webhook-events/:eventId` - -### 8.16 webhook 重放 - -`POST /api/v1/admin/webhook-events/:eventId/replay` - ---- - -## 9. 前台 `/claim/:token` 页面契约 - -### 9.1 路由 - -建议新增: - -- `/#/claim/:token` - -### 9.2 页面状态机 - -前台页面建议使用以下视图状态: - -- `loading` -- `invalid_token` -- `ready_to_start` -- `waiting_scan` -- `waiting_confirm_login` -- `waiting_role_ready` -- `waiting_role_confirm` -- `redeeming` -- `redeemed` -- `failed` -- `expired` - -### 9.3 页面数据模型 - -建议前端整理成: - -```ts -type ClaimPageModel = { - tokenStatus: 'active' | 'expired' | 'revoked' | 'used' - task: { - taskId: number - taskNo: string - status: string - lastError: string - expiresAt: string | null - claimedAt: string | null - roleConfirmedAt: string | null - redeemedAt: string | null - } - order: { - platformOrderId: string - skuCode: string - skuName: string - quantity: number - } - session: null | { - sessionId: string - status: string - loginType: 'qq' | 'wx' - notice: string - qrImageBase64?: string - updatedAt: string - activityInfo?: { - nickname: string - role: { - ready: boolean - roleName: string - roleId: string - area?: string - partition?: string - } - } - artifacts?: { - hasScreenshot: boolean - } - } - result: null | { - resultCode: string - resultMessage: string - screenshotUrl: string - } -} -``` - -### 9.4 页面动作 - -前台页面仅保留 4 个核心动作: - -1. 选择 QQ / WX 并创建 session -2. 轮询 session 状态 -3. 确认角色 -4. 发起兑换 - -前台不允许: - -- 手输 CDK -- 修改任务状态 -- 查看其他订单 - -### 9.5 页面展示区块 - -建议拆成: - -1. 订单摘要卡片 -2. 登录二维码卡片 -3. 角色确认卡片 -4. 结果卡片 - -现有可复用组件: - -- `TencentAuthCard.vue` -- `TencentRedeemPanel.vue` -- `TencentResultPanel.vue` - -### 9.6 轮询规则 - -轮询间隔建议继续沿用当前 2500ms。 - -停止轮询条件: - -- session 进入 `redeemed` -- task 进入 `redeemed` -- task 进入 `expired` -- task 进入 `closed` -- 连续失败达到阈值 - -## 10. 后台 `/admin` 页面契约 - -### 10.1 路由建议 - -- `/#/admin/login` -- `/#/admin/dashboard` -- `/#/admin/orders` -- `/#/admin/orders/:orderId` -- `/#/admin/tasks` -- `/#/admin/tasks/:taskId` -- `/#/admin/cdks` -- `/#/admin/webhook-events` -- `/#/admin/webhook-events/:eventId` - -### 10.2 页面说明 - -#### Dashboard - -展示: - -- 今日订单数 -- 待领取数 -- 兑换成功数 -- 异常任务数 -- 库存告警 - -#### Orders - -功能: - -- 筛选订单 -- 查看订单详情 -- 跳转任务 - -#### Tasks - -功能: - -- 筛选任务状态 -- 查看错误 -- 跳转详情 -- 快速执行重试、释放、关闭、重发链接 - -#### Task Detail - -功能: - -- 统一查看任务全量信息 -- 预览截图 -- 人工操作入口 - -#### CDKs - -功能: - -- 单条新增 -- 批量导入 -- 状态筛选 -- 查看预占关系 - -#### Webhook Events - -功能: - -- 查看原始推送 -- 查看处理结果 -- 重放处理 - -### 10.3 后台前端基础能力 - -后台第一版建议至少补: - -- 登录鉴权 -- 基础分页 -- 列表筛选 -- 操作确认弹窗 -- 错误提示 - -## 11. 实施顺序 - -### 11.1 第一批 - -先做: - -1. SQLite 接入 -2. migration -3. repository 层 -4. webhook 接口骨架 -5. 订单、任务、CDK、token 基础 service - -### 11.2 第二批 - -再做: - -1. `/claim/:token` 前台页 -2. claim 相关接口 -3. session 与 task 绑定 - -### 11.3 第三批 - -再做: - -1. 后端自动兑换桥接 -2. 兑换结果回写 -3. 截图读取 - -### 11.4 第四批 - -最后做: - -1. `/admin` 后台页 -2. 订单/任务/CDK/webhook 查询接口 -3. 重试、释放、重放 - -## 12. 建议的下一步 - -如果直接开始编码,建议按下面顺序落地: - -1. 先实现 `001_init.sql` -2. 实现 `src/db/client.js` -3. 实现 repository 层 -4. 接 `POST /api/v1/webhooks/agiso/trade` -5. 打通“支付成功 -> 建订单 -> 建任务 -> 预占 CDK -> 生成 token” -6. 再开始前台领取页 - -这条顺序最稳,返工也最少。 diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index f424d9da..ec3f2f03 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -140,7 +140,16 @@ export interface AdminTaskOperations { } export interface AdminTaskActionResponse { - task: Record + task: { + taskId: number + taskNo: string + status: string + inventoryItemId: number | null + claimTokenId: number | null + reservedCdkId?: number | null + lastError: string + updatedAt: string + } claimUrl?: string token?: string } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2b2ab341..d64ce6e9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,22 @@ services: + postgres: + image: postgres:16-bookworm + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + POSTGRES_DB: ${POSTGRES_DB:-order_site} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-order_site}"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - postgres_dev_data:/var/lib/postgresql/data + backend: build: context: . @@ -11,10 +29,15 @@ services: "npm install --no-fund --no-audit && python3 -m pip install --no-cache-dir --break-system-packages -e /app/subservices/ocr-worker && npm run dev" + depends_on: + postgres: + condition: service_healthy environment: TZ: ${TZ:-Asia/Shanghai} PORT: ${BACKEND_PORT:-3000} - DATABASE_FILE_PATH: /app/data/order-site.db + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} + DATABASE_SSL: ${DATABASE_SSL:-false} + DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10} OCR_PROJECT_ROOT: /app/subservices/ocr-worker CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-basic} @@ -24,7 +47,7 @@ services: TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} - ORDER_SKU_MAPPINGS_JSON: ${ORDER_SKU_MAPPINGS_JSON} + ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]} AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} AGISO_APP_ID: ${AGISO_APP_ID:-} @@ -72,5 +95,6 @@ services: - ./deploy/caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro volumes: + postgres_dev_data: backend_node_modules: frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 5479908f..702a739f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,20 @@ services: + postgres: + image: postgres:16-bookworm + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + POSTGRES_DB: ${POSTGRES_DB:-order_site} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-order_site}"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - postgres_data:/var/lib/postgresql/data + backend: build: context: . @@ -6,10 +22,15 @@ services: restart: unless-stopped env_file: - .env + depends_on: + postgres: + condition: service_healthy environment: TZ: ${TZ:-Asia/Shanghai} PORT: ${BACKEND_PORT:-3000} - DATABASE_FILE_PATH: /app/data/order-site.db + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} + DATABASE_SSL: ${DATABASE_SSL:-false} + DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20} OCR_PROJECT_ROOT: /app/subservices/ocr-worker CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-basic} @@ -19,7 +40,7 @@ services: TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} - ORDER_SKU_MAPPINGS_JSON: ${ORDER_SKU_MAPPINGS_JSON} + ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]} AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} AGISO_APP_ID: ${AGISO_APP_ID:-} @@ -51,5 +72,6 @@ services: volumes: backend_data: + postgres_data: caddy_data: caddy_config: diff --git a/docs/backend-refactor-status.md b/docs/backend-refactor-status.md new file mode 100644 index 00000000..407c6e6d --- /dev/null +++ b/docs/backend-refactor-status.md @@ -0,0 +1,154 @@ +# Backend Refactor Status + +最后更新:2026-04-09(已切换到“测试数据可丢弃,不做历史迁移兼容”前提) + +## 当前结论 + +后端正在从“单一腾讯领取兑换工具”迁移到“通用订单履约系统”。 + +当前代码已经完成了下面两件大事: + +1. 数据层已经切到 PostgreSQL,并引入了新的通用履约模型。 +2. 仓库结构已经分出 `db / repositories / services / routes / frontend / deploy` 等清晰边界。 + +当前仍处于新旧架构并存阶段,但已经明确采用下面的推进原则: + +- 现有数据库数据视为测试数据,可直接清空 +- 不再为了旧测试数据保留历史迁移修复逻辑 +- 新配置只认当前履约模型,不再从旧映射自动推导 + +当前主要表现为: + +- 新 schema 已经使用 `fulfillment_* / inventory_items / task_inventory_bindings` +- 核心 service 主链路已经开始直接使用“主 token / 主库存绑定”语义 +- 边角脚本、前端命名和少量后台返回结构仍处于过渡期 + +## 当前真实架构 + +从工程实现视角,当前后端可以按 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. 通用履约模型已经落库 + +当前迁移文件已经不再围绕旧式单 CDK 模型设计,而是引入了: + +- `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 +- cdks +- 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 层表面传值。 + +## 当前未完成部分 + +### 1. 新 schema 和部分 service 仍然错位 + +当前最主要的问题不是“没功能”,而是“新数据模型已经落地,但应用层还没有完全跟上”。 + +典型现象: + +- 前后端接口命名还留着一部分旧 CDK 心智 +- 局部 service 仍需要继续统一 async/await 与新仓库接口写法 + +### 2. 多凭据任务能力还没有完全上浮到应用层 + +底层已经支持一个任务绑定多个库存项,但上层展示和操作仍偏向: + +- 一个任务只看一个预占 CDK +- 一个任务只暴露一个领取 token +- 后台仍以单库存项视角管理任务 + +### 3. 手动发货 profile 还没有形成完整闭环 + +当前模型和 bootstrap 已有 `manual_dispatch` 方向,但完整的手动履约执行链路还没有完全落到 service、admin 操作和结果回写上。 + +### 4. repository 与 admin API 之间仍有一层过渡期命名 + +虽然内部主链路已经开始使用 `primary_*` 字段,但部分 admin 返回 payload 仍保留 `reservedCdkId / claimTokenId` 这样的旧命名,以避免前端同时大面积改动。 + +## 现在最该继续做的事 + +1. 让 admin 层完全对齐新的 `fulfillment_tasks / inventory_items` 语义 +2. 把“单个 reserved CDK”心智继续升级为“任务库存绑定” +3. 把 `manual_review / manual_dispatch` 从 profile 扩展成完整可执行链路 +4. 补一套基于 PostgreSQL 和新 schema 的清库/种子数据脚本 + +当前这件事已经完成一半: + +- 旧 SQLite 思路的 `scripts/cleanup-dev-data.js` 已经替换为基于 PostgreSQL 新 schema 的清库脚本 +- 但种子数据脚本还没补,bootstrap 目前主要负责 profile/catalog,不负责完整测试数据生成 + +## 文档说明 + +以下旧文档已删除,不再作为事实来源: + +- `apps/backend/方案A-订单编排与自动兑换设计.md` +- `apps/backend/阶段1-4实施规格说明.md` + +后续请以代码、迁移文件和本状态文档为准。