From db22ff691b89f6c652c342e558156e546d7cdb73 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 20:42:27 +0800 Subject: [PATCH 01/73] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=BF=AB=E6=89=8B?= =?UTF-8?q?=E8=BD=BB=E9=87=8F=E5=90=8E=E7=AB=AF=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++ apps/backend/package.json | 4 + apps/backend/package.kuaishou.json | 22 +++ apps/backend/src/app-kuaishou.ts | 103 +++++++++++ apps/backend/src/index-kuaishou.ts | 71 ++++++++ apps/backend/src/routes/admin-kuaishou.ts | 34 ++++ .../src/routes/admin/inventory-kuaishou.ts | 149 ++++++++++++++++ .../src/routes/admin/tasks-kuaishou.ts | 162 ++++++++++++++++++ apps/backend/src/routes/admin/tasks.ts | 4 +- .../routes/admin/webhook-events-kuaishou.ts | 77 +++++++++ apps/backend/src/routes/claims-kuaishou.ts | 71 ++++++++ .../src/services/admin/admin-read-service.ts | 5 - .../admin/admin-task-screenshot-service.ts | 28 +++ .../claim/kuaishou-cloud-claim-service.ts | 29 +++- .../backend/src/startup/bootstrap-kuaishou.ts | 71 ++++++++ apps/backend/src/startup/shutdown-kuaishou.ts | 44 +++++ apps/backend/tsconfig.kuaishou.build.json | 10 ++ apps/backend/tsconfig.kuaishou.json | 13 ++ apps/frontend/src/router/index.ts | 4 +- deploy/docker/backend-kuaishou.Dockerfile | 62 +++++++ deploy/docker/frontend.Dockerfile | 2 + docker-compose.kuaishou.dev.yml | 100 +++++++++++ docker-compose.kuaishou.yml | 79 +++++++++ 23 files changed, 1146 insertions(+), 13 deletions(-) create mode 100644 apps/backend/package.kuaishou.json create mode 100644 apps/backend/src/app-kuaishou.ts create mode 100644 apps/backend/src/index-kuaishou.ts create mode 100644 apps/backend/src/routes/admin-kuaishou.ts create mode 100644 apps/backend/src/routes/admin/inventory-kuaishou.ts create mode 100644 apps/backend/src/routes/admin/tasks-kuaishou.ts create mode 100644 apps/backend/src/routes/admin/webhook-events-kuaishou.ts create mode 100644 apps/backend/src/routes/claims-kuaishou.ts create mode 100644 apps/backend/src/services/admin/admin-task-screenshot-service.ts create mode 100644 apps/backend/src/startup/bootstrap-kuaishou.ts create mode 100644 apps/backend/src/startup/shutdown-kuaishou.ts create mode 100644 apps/backend/tsconfig.kuaishou.build.json create mode 100644 apps/backend/tsconfig.kuaishou.json create mode 100644 deploy/docker/backend-kuaishou.Dockerfile create mode 100644 docker-compose.kuaishou.dev.yml create mode 100644 docker-compose.kuaishou.yml diff --git a/README.md b/README.md index 682eefdf..a2dfdb6f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ cp .env.mac-docker.example .env docker compose -f docker-compose.dev.yml up -d --build ``` +如果只运行快手 Cloud / 91 卡券 / 快手小店核销链路,可以使用轻量 Compose。这个模式不会启动 OCR,也不会安装或预热 Playwright 浏览器: + +```bash +docker compose -f docker-compose.kuaishou.dev.yml up -d --build +``` + 启动后常用入口: - 前端、后台、领取页:`http://localhost` @@ -45,6 +51,8 @@ docker compose -f docker-compose.dev.yml up -d --build - OCR worker:`http://127.0.0.1:8100/health` - noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html` +快手轻量开发模式没有 noVNC 入口,前端首页默认进入后台。 + 查看容器状态: ```bash @@ -153,6 +161,13 @@ mkdir -p deploy/data/backend docker compose up -d --build ``` +只部署快手轻量服务时使用: + +```bash +mkdir -p deploy/data/backend-kuaishou +docker compose -f docker-compose.kuaishou.yml up -d --build +``` + 生产入口: - 前端与领取页:`https://你的域名/` diff --git a/apps/backend/package.json b/apps/backend/package.json index 050fab74..22bff426 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -10,11 +10,15 @@ "cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts", "seed:dev-data": "tsx scripts/seed-dev-data.ts", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", + "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "db:migrate": "tsx src/db/migrate.ts", "dev": "tsx watch --clear-screen=false src/index.ts", + "dev:kuaishou": "tsx watch --clear-screen=false src/index-kuaishou.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit", "start": "node dist/index.js", + "start:kuaishou": "node dist/index-kuaishou.js", "start:src": "tsx src/index.ts" }, "dependencies": { diff --git a/apps/backend/package.kuaishou.json b/apps/backend/package.kuaishou.json new file mode 100644 index 00000000..10d71dfb --- /dev/null +++ b/apps/backend/package.kuaishou.json @@ -0,0 +1,22 @@ +{ + "name": "order-site-backend-kuaishou", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", + "db:migrate": "tsx src/db/migrate.ts", + "start:kuaishou": "node dist/index-kuaishou.js" + }, + "dependencies": { + "express": "^5.1.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", + "tsx": "^4.22.3", + "typescript": "^6.0.2" + } +} diff --git a/apps/backend/src/app-kuaishou.ts b/apps/backend/src/app-kuaishou.ts new file mode 100644 index 00000000..0848ceec --- /dev/null +++ b/apps/backend/src/app-kuaishou.ts @@ -0,0 +1,103 @@ +import type { NextFunction, Request, Response } from "express"; +import express from "express"; +import process from "node:process"; + +import adminRouter from "./routes/admin-kuaishou.js"; +import claimsRouter from "./routes/claims-kuaishou.js"; +import open91Router from "./routes/open-91.js"; +import webhooksRouter from "./routes/webhooks.js"; +import { accessLogMiddleware } from "./middleware/access-log.js"; +import { createCorsMiddleware } from "./middleware/cors.js"; +import { buildHealthPayload, type StartupState } from "./startup/state.js"; +import type { RuntimeConfig } from "./types/runtime-config.js"; +import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; + +type CreateKuaishouAppOptions = { + startupState: StartupState; + isShutdownStarted: () => boolean; + config: RuntimeConfig; +}; + +export function createKuaishouApp({ + startupState, + isShutdownStarted, + config, +}: CreateKuaishouAppOptions) { + const app = express(); + + app.use(accessLogMiddleware); + app.use(createCorsMiddleware(config)); + app.use(express.json({ limit: "2mb" })); + app.use(express.urlencoded({ extended: true })); + + app.get("/health", (_req, res) => { + res.json( + buildSuccessPayload( + buildHealthPayload(startupState, isShutdownStarted()), + startupState.core.ready ? "ready" : "starting" + ) + ); + }); + + app.get("/health/live", (_req, res) => { + res.json( + buildSuccessPayload({ + status: isShutdownStarted() ? "shutting_down" : "alive", + pid: process.pid, + mode: "kuaishou-lite", + }) + ); + }); + + app.get("/health/ready", (_req, res) => { + if (startupState.core.ready) { + res.json( + buildSuccessPayload( + buildHealthPayload(startupState, isShutdownStarted()), + "ready" + ) + ); + return; + } + + res.status(503).json({ + code: 1, + msg: startupState.core.lastError || "服务启动中,请稍后重试", + errorCode: "service_not_ready", + time: Math.floor(Date.now() / 1000), + data: buildHealthPayload(startupState, isShutdownStarted()), + }); + }); + + app.use((req, res, next) => { + if (startupState.core.ready) { + next(); + return; + } + + res.status(503).json({ + code: 1, + msg: startupState.core.lastError || "服务启动中,请稍后重试", + errorCode: "service_not_ready", + time: Math.floor(Date.now() / 1000), + data: { + startup: { + phase: startupState.phase, + attemptCount: startupState.core.attemptCount, + lastAttemptAt: startupState.core.lastAttemptAt, + }, + }, + }); + }); + + app.use("/api/v1/open/91", open91Router); + app.use("/api/v1/webhooks", webhooksRouter); + app.use("/api/v1/claim", claimsRouter); + app.use("/api/v1/admin", adminRouter); + + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + sendRouteError(res, err, "服务内部错误", "[global:kuaishou]"); + }); + + return app; +} diff --git a/apps/backend/src/index-kuaishou.ts b/apps/backend/src/index-kuaishou.ts new file mode 100644 index 00000000..ec1ce6ea --- /dev/null +++ b/apps/backend/src/index-kuaishou.ts @@ -0,0 +1,71 @@ +import process from "node:process"; + +import { createKuaishouApp } from "./app-kuaishou.js"; +import { runtimeConfig } from "./config/runtime.js"; +import { assertRuntimeConfigValid } from "./config/runtime-validation.js"; +import { bootstrapKuaishouCoreServices } from "./startup/bootstrap-kuaishou.js"; +import { createKuaishouShutdownController } from "./startup/shutdown-kuaishou.js"; +import { createStartupState, formatStartupError } from "./startup/state.js"; +import { logError, logInfo } from "./utils/logger.js"; + +const port = Number(runtimeConfig.server.port || 3000); +const host = "0.0.0.0"; +const startupState = createStartupState(); + +let shutdownController: ReturnType< + typeof createKuaishouShutdownController +> | null = null; + +try { + assertRuntimeConfigValid(runtimeConfig); +} catch (error) { + logError("[startup:kuaishou]", "运行时配置校验失败,服务停止启动", error); + process.exit(1); +} + +const app = createKuaishouApp({ + startupState, + isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, + config: runtimeConfig, +}); + +const server = app.listen(port, host, () => { + logInfo( + "[startup:kuaishou]", + `order-site-backend kuaishou-lite listening on http://${host}:${port}` + ); + void bootstrapKuaishouCoreServices( + startupState, + () => shutdownController?.isShutdownStarted() || false + ); +}); + +shutdownController = createKuaishouShutdownController(server, startupState); + +server.on("error", (error) => { + logError("[startup:kuaishou]", "HTTP server failed", error); +}); + +process.on("SIGINT", () => { + void shutdownController?.shutdown("SIGINT"); +}); + +process.on("SIGTERM", () => { + void shutdownController?.shutdown("SIGTERM"); +}); + +process.on("unhandledRejection", (reason) => { + startupState.process.lastUnhandledRejection = { + time: new Date().toISOString(), + message: formatStartupError(reason), + }; + logError("[process:kuaishou]", "unhandled promise rejection", reason); +}); + +process.on("uncaughtException", (error) => { + startupState.process.lastUncaughtException = { + time: new Date().toISOString(), + message: formatStartupError(error), + }; + logError("[process:kuaishou]", "uncaught exception captured", error); +}); diff --git a/apps/backend/src/routes/admin-kuaishou.ts b/apps/backend/src/routes/admin-kuaishou.ts new file mode 100644 index 00000000..6f5a54b4 --- /dev/null +++ b/apps/backend/src/routes/admin-kuaishou.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; + +import authRouter from "./admin/auth.js"; +import auditLogsRouter from "./admin/audit-logs.js"; +import dashboardRouter from "./admin/dashboard.js"; +import inventoryRouter from "./admin/inventory-kuaishou.js"; +import messageDeliveriesRouter from "./admin/message-deliveries.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-kuaishou.js"; +import usersRouter from "./admin/users.js"; +import webhookEventsRouter from "./admin/webhook-events-kuaishou.js"; +import { buildNotFoundPayload } from "../utils/http.js"; + +const router = Router(); + +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(inventoryRouter); +router.use(messageDeliveriesRouter); +router.use(webhookEventsRouter); + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)); +}); + +export default router; diff --git a/apps/backend/src/routes/admin/inventory-kuaishou.ts b/apps/backend/src/routes/admin/inventory-kuaishou.ts new file mode 100644 index 00000000..652c7899 --- /dev/null +++ b/apps/backend/src/routes/admin/inventory-kuaishou.ts @@ -0,0 +1,149 @@ +import { Router } from "express"; + +import { + createAdminInventoryItem, + importAdminInventoryItems, + invalidateAdminInventoryItem, + releaseAdminInventoryItem, +} from "../../services/admin/write/inventory.js"; +import { + getAdminInventoryItems, + getAdminInventorySkuSuggestions, +} from "../../services/admin/admin-read-service.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; +import type { + AdminInventoryCreateRouteBody, + AdminInventoryImportRouteBody, + AdminInventoryInvalidateRouteBody, + AdminInventoryRouteParams, + AdminInventoryRouteQuery, + AdminInventorySkuSuggestionRouteQuery, +} from "../../types/admin-route-inputs.js"; +import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js"; + +const router = Router(); + +router.use("/inventory", requireAdminRoles(["admin", "operator"])); + +router.get( + "/inventory", + createJsonHandler( + (req) => + getAdminInventoryItems( + req.query as AdminInventoryRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取库存列表失败", + scope: "[admin-kuaishou/inventory]", + } + ) +); + +router.get( + "/inventory/sku-suggestions", + createJsonHandler( + (req) => + getAdminInventorySkuSuggestions( + req.query as AdminInventorySkuSuggestionRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取内部 SKU 建议失败", + scope: "[admin-kuaishou/inventory/sku-suggestions]", + } + ) +); + +router.post( + "/inventory", + createJsonHandler( + (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), + { + successMessage: "库存项已新增", + errorMessage: "新增库存项失败", + scope: "[admin-kuaishou/inventory]", + } + ) +); + +router.post( + "/inventory/import", + createJsonHandler( + (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), + { + successMessage: "导入完成", + errorMessage: "导入库存凭据失败", + scope: "[admin-kuaishou/inventory/import]", + } + ) +); + +router.post( + "/inventory/:inventoryItemId/release", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + releaseAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + { + successMessage: "库存项已释放", + errorMessage: "释放库存项失败", + scope: "[admin-kuaishou/inventory/:inventoryItemId/release]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; + + return { + action: "inventory_item_released", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + displayValue: result.inventoryItem?.displayValue, + }, + }; + }, + } + ) +); + +router.post( + "/inventory/:inventoryItemId/invalidate", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + invalidateAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId, + req.body as AdminInventoryInvalidateRouteBody + ), + { + successMessage: "库存项已作废", + errorMessage: "作废库存项失败", + scope: "[admin-kuaishou/inventory/:inventoryItemId/invalidate]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; + + return { + action: "inventory_item_invalidated", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + invalidReason: result.inventoryItem?.invalidReason, + }, + }; + }, + } + ) +); + +export default router; diff --git a/apps/backend/src/routes/admin/tasks-kuaishou.ts b/apps/backend/src/routes/admin/tasks-kuaishou.ts new file mode 100644 index 00000000..564b274f --- /dev/null +++ b/apps/backend/src/routes/admin/tasks-kuaishou.ts @@ -0,0 +1,162 @@ +import type { Request } from "express"; +import { Router } from "express"; + +import { + getAdminTaskDetail, + getAdminTasks, +} from "../../services/admin/admin-read-service.js"; +import { + dispatchAdminTaskKuaishouCloudFulfillment, + prepareAdminTaskKuaishouCloudFulfillment, + refreshAdminTaskKuaishouCloudRoleInfo, + returnNumberAdminTaskKuaishouCloudFulfillment, +} from "../../services/admin/write/kuaishou-cloud-actions.js"; +import { + createJsonHandler, + requireAdminRoles, +} from "./shared.js"; +import type { + AdminTaskKuaishouCloudDispatchRouteBody, + AdminTaskRouteParams, + AdminTaskRouteQuery, +} from "../../types/admin-route-inputs.js"; +import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; + +const router = Router(); + +function getTaskId(req: Request): string | undefined { + return (req.params as AdminTaskRouteParams).taskId; +} + +router.get( + "/tasks", + createJsonHandler( + (req) => + getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), + { + successMessage: "ok", + errorMessage: "读取任务列表失败", + scope: "[admin-kuaishou/tasks]", + } + ) +); + +router.get( + "/tasks/:taskId", + createJsonHandler( + (req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), + { + successMessage: "ok", + errorMessage: "读取任务详情失败", + scope: "[admin-kuaishou/tasks/:taskId]", + } + ) +); + +router.post( + "/tasks/:taskId/kuaishou-cloud/prepare", + requireAdminRoles(["admin", "operator"]), + createJsonHandler( + (req) => + prepareAdminTaskKuaishouCloudFulfillment( + getTaskId(req), + req.adminSession || null + ), + { + successMessage: "绑定资源已准备完成", + errorMessage: "准备绑定资源失败", + scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]", + audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), + } + ) +); + +router.post( + "/tasks/:taskId/kuaishou-cloud/refresh-role-info", + requireAdminRoles(["admin", "operator", "support"]), + createJsonHandler( + (req) => + refreshAdminTaskKuaishouCloudRoleInfo( + getTaskId(req), + req.adminSession || null + ), + { + successMessage: "角色信息已刷新", + errorMessage: "刷新角色信息失败", + scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]", + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), + } + ) +); + +router.post( + "/tasks/:taskId/kuaishou-cloud/dispatch", + requireAdminRoles(["admin", "operator"]), + createJsonHandler( + (req) => + dispatchAdminTaskKuaishouCloudFulfillment( + getTaskId(req), + req.body as AdminTaskKuaishouCloudDispatchRouteBody, + req.adminSession || null + ), + { + successMessage: "已完成绑定确认并发货", + errorMessage: "执行发货失败", + scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]", + audit: (req, data) => { + const result = data as AdminTaskActionResponse; + const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; + + return { + action: "task_kuaishou_cloud_dispatch", + targetType: "task", + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + deliveryStatus: result.task.deliveryStatus, + ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), + }, + }; + }, + } + ) +); + +router.post( + "/tasks/:taskId/kuaishou-cloud/return-number", + requireAdminRoles(["admin", "operator"]), + createJsonHandler( + (req) => + returnNumberAdminTaskKuaishouCloudFulfillment( + getTaskId(req), + req.adminSession || null + ), + { + successMessage: "号码已退还", + errorMessage: "退还号码失败", + scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]", + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_return_number", req, data), + } + ) +); + +function buildTaskAudit(action: string, req: Request, data: unknown) { + const result = data as AdminTaskActionResponse; + + return { + action, + targetType: "task", + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + }; +} + +export default router; diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index f60f7b1d..8a167c1c 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -17,9 +17,9 @@ import { } from "../../services/admin/admin-write-service.js"; import { getAdminTaskDetail, - getAdminTaskScreenshotPath, getAdminTasks, } from "../../services/admin/admin-read-service.js"; +import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js"; import { createFileHandler, createJsonHandler, @@ -77,7 +77,7 @@ router.get( "/tasks/:taskId/screenshot", createFileHandler( (req) => - getAdminTaskScreenshotPath(getTaskId(req), req.adminSession || null), + getAdminTaskScreenshotPathWithTencentFallback(getTaskId(req), req.adminSession || null), { errorMessage: "读取任务截图失败", scope: "[admin/tasks/:taskId/screenshot]", diff --git a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts new file mode 100644 index 00000000..1b71dea5 --- /dev/null +++ b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts @@ -0,0 +1,77 @@ +import { Router } from "express"; + +import { replayAdminWebhookEvent } from "../../services/admin/write/webhook-events.js"; +import { + getAdminWebhookEventDetail, + getAdminWebhookEvents, +} from "../../services/admin/admin-read-service.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; +import type { + AdminWebhookEventRouteParams, + AdminWebhookEventRouteQuery, +} from "../../types/admin-route-inputs.js"; +import type { AdminWebhookReplayResponse } from "../../types/admin-write-models.js"; + +const router = Router(); + +router.use("/webhook-events", requireAdminRoles(["admin", "operator"])); + +router.get( + "/webhook-events", + createJsonHandler( + (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), + { + successMessage: "ok", + errorMessage: "读取 webhook 列表失败", + scope: "[admin-kuaishou/webhook-events]", + } + ) +); + +router.get( + "/webhook-events/:eventId", + createJsonHandler( + (req) => + getAdminWebhookEventDetail( + (req.params as AdminWebhookEventRouteParams).eventId + ), + { + successMessage: "ok", + errorMessage: "读取 webhook 详情失败", + scope: "[admin-kuaishou/webhook-events/:eventId]", + } + ) +); + +router.post( + "/webhook-events/:eventId/replay", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + replayAdminWebhookEvent( + (req.params as AdminWebhookEventRouteParams).eventId + ), + { + successMessage: "Webhook 已重放", + errorMessage: "重放 webhook 失败", + scope: "[admin-kuaishou/webhook-events/:eventId/replay]", + audit: (req, data) => { + const result = data as AdminWebhookReplayResponse; + + return { + action: "webhook_replayed", + targetType: "webhook_event", + targetId: String( + (req.params as AdminWebhookEventRouteParams).eventId + ), + data: { + eventId: result.eventId, + replayed: result.replayed, + }, + }; + }, + } + ) +); + +export default router; diff --git a/apps/backend/src/routes/claims-kuaishou.ts b/apps/backend/src/routes/claims-kuaishou.ts new file mode 100644 index 00000000..7c2632b7 --- /dev/null +++ b/apps/backend/src/routes/claims-kuaishou.ts @@ -0,0 +1,71 @@ +import { Router } from "express"; + +import { + confirmKuaishouCloudClaimRole, + getKuaishouCloudClaimDetail, + getKuaishouCloudClaimGuideAssetPath, + redeemKuaishouCloudClaim, + verifyKuaishouCloudClaimTicket, +} from "../services/claim/kuaishou-cloud-claim-service.js"; +import { + buildNotFoundPayload, + createRouteFileHandler, + createRouteHandler, +} from "../utils/http.js"; + +const router = Router(); + +router.get( + "/:token", + createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { + errorMessage: "查询快手领取详情失败", + scope: "[claims-kuaishou/:token]", + }) +); + +router.post( + "/:token/kuaishou-cloud/verify-ticket", + createRouteHandler( + (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), + { + successMessage: "核销码校验成功", + errorMessage: "校验核销码失败", + scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]", + } + ) +); + +router.post( + "/:token/kuaishou-cloud/confirm-role", + createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { + successMessage: "角色已确认", + errorMessage: "确认角色失败", + scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]", + }) +); + +router.post( + "/:token/kuaishou-cloud/redeem", + createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { + successMessage: "兑换请求已提交", + errorMessage: "兑换失败", + scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]", + }) +); + +router.get( + "/assets/kuaishou-cloud/:filename", + createRouteFileHandler( + (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), + { + errorMessage: "读取指引图片失败", + scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]", + } + ) +); + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)); +}); + +export default router; diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 7569b79e..bdda4330 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -13,7 +13,6 @@ import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' -import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' import { canViewerCloseTask, @@ -322,10 +321,6 @@ export async function getAdminTaskScreenshotPath( return task.screenshot_path } - if (task.browser_session_id) { - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - throw createHttpError('当前任务还没有可查看截图', { statusCode: 404, errorCode: 'admin_task_screenshot_not_found', diff --git a/apps/backend/src/services/admin/admin-task-screenshot-service.ts b/apps/backend/src/services/admin/admin-task-screenshot-service.ts new file mode 100644 index 00000000..ffb2d698 --- /dev/null +++ b/apps/backend/src/services/admin/admin-task-screenshot-service.ts @@ -0,0 +1,28 @@ +import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' +import { createHttpError } from '../../utils/http.js' +import { getRequiredTask } from './admin-task-read-helpers.js' + +import type { + AdminEntityIdInput, + AdminViewerSessionInput, +} from '../../types/admin-read-inputs.js' + +export async function getAdminTaskScreenshotPathWithTencentFallback( + taskId: AdminEntityIdInput, + _session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + + if (task.screenshot_path) { + return task.screenshot_path + } + + if (task.browser_session_id) { + return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) + } + + throw createHttpError('当前任务还没有可查看截图', { + statusCode: 404, + errorCode: 'admin_task_screenshot_not_found', + }) +} diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index f31ab5af..ad535898 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -18,7 +18,9 @@ import { prepareKuaishouCloudFulfillmentTask, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud-task-service.js' -import { getClaimContext, getClaimDetail } from './claim-session-service.js' +import { buildClaimDetailPayload } from './session/shared.js' +import { getClaimContext } from './session/context.js' +import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) @@ -132,7 +134,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), }, now) - return getClaimDetail(token, { includeQrImage: false }) + return getKuaishouCloudClaimDetail(token) } export async function getKuaishouCloudClaimGuideAssetPath(filename) { @@ -155,6 +157,23 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename) { return filePath } +export async function getKuaishouCloudClaimDetail(token: unknown) { + const context = await getClaimContext(token) + let task = context.task + + if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { + task = await syncKuaishouCloudRoleInfo(task) + } + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task, + order: context.order, + orderItem: context.orderItem, + session: null, + }) +} + function parseTaskContext(task) { const rawValue = task?.context_json if (!rawValue) { @@ -184,7 +203,7 @@ export async function confirmKuaishouCloudClaimRole(token) { } if (['role_confirmed', 'dispatched_pending_return', 'completed'].includes(String(context.task.task_status || '').trim())) { - return getClaimDetail(token, { includeQrImage: false }) + return getKuaishouCloudClaimDetail(token) } const refreshed = await refreshKuaishouCloudTaskRoleInfo(context.task, { @@ -225,7 +244,7 @@ export async function confirmKuaishouCloudClaimRole(token) { roleId: flow.binding.roleId, }, now) - return getClaimDetail(token, { includeQrImage: false }) + return getKuaishouCloudClaimDetail(token) } export async function redeemKuaishouCloudClaim(token) { @@ -252,5 +271,5 @@ export async function redeemKuaishouCloudClaim(token) { autoFinalize: true, }) - return getClaimDetail(token, { includeQrImage: false }) + return getKuaishouCloudClaimDetail(token) } diff --git a/apps/backend/src/startup/bootstrap-kuaishou.ts b/apps/backend/src/startup/bootstrap-kuaishou.ts new file mode 100644 index 00000000..f14d45a0 --- /dev/null +++ b/apps/backend/src/startup/bootstrap-kuaishou.ts @@ -0,0 +1,71 @@ +import { runDatabaseMigrations } from "../db/migrate.js"; +import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; +import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; +import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; +import { logError, logInfo } from "../utils/logger.js"; +import { formatStartupError, type StartupState } from "./state.js"; + +const CORE_BOOT_RETRY_DELAY_MS = 5_000; + +export async function bootstrapKuaishouCoreServices( + startupState: StartupState, + isShutdownStarted: () => boolean +) { + if (startupState.core.running || isShutdownStarted()) { + return; + } + + startupState.core.running = true; + startupState.browser.status = "skipped"; + startupState.browser.message = "kuaishou lite mode"; + startupState.ocr.status = "skipped"; + startupState.ocr.message = "kuaishou lite mode"; + + while (!isShutdownStarted() && !startupState.core.ready) { + startupState.phase = + startupState.core.attemptCount === 0 ? "starting" : "retrying"; + startupState.core.attemptCount += 1; + startupState.core.lastAttemptAt = new Date().toISOString(); + + try { + logInfo("[startup:kuaishou]", "开始执行快手轻量核心启动步骤", { + attempt: startupState.core.attemptCount, + }); + + await runDatabaseMigrations(); + await ensureFulfillmentCatalogBootstrapped(); + await ensureAdminUsersBootstrapped(); + + startupState.core.ready = true; + startupState.core.lastError = ""; + startupState.phase = "ready"; + startupState.core.readyAt = new Date().toISOString(); + + logInfo("[startup:kuaishou]", "快手轻量核心启动步骤完成,服务已就绪", { + attempt: startupState.core.attemptCount, + }); + + startScheduledJobs(); + break; + } catch (error) { + const message = formatStartupError(error); + startupState.phase = "retrying"; + startupState.core.lastError = message; + logError("[startup:kuaishou]", "快手轻量核心启动步骤失败,将自动重试", { + attempt: startupState.core.attemptCount, + retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, + error, + }); + + await sleep(CORE_BOOT_RETRY_DELAY_MS); + } + } + + startupState.core.running = false; +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/backend/src/startup/shutdown-kuaishou.ts b/apps/backend/src/startup/shutdown-kuaishou.ts new file mode 100644 index 00000000..dd3f60e1 --- /dev/null +++ b/apps/backend/src/startup/shutdown-kuaishou.ts @@ -0,0 +1,44 @@ +import type { Server } from "node:http"; + +import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; +import { logError, logInfo } from "../utils/logger.js"; +import type { StartupState } from "./state.js"; + +export function createKuaishouShutdownController( + server: Server, + startupState: StartupState +) { + let shutdownStarted = false; + + async function shutdown(signal: string) { + if (shutdownStarted) { + return; + } + + shutdownStarted = true; + startupState.phase = "shutting_down"; + logInfo("[shutdown:kuaishou]", `received ${signal}, closing HTTP server`); + + stopScheduledJobs(); + + if (!server.listening) { + return; + } + + await new Promise((resolve) => { + server.close((error) => { + if (error) { + logError("[shutdown:kuaishou]", "failed to close HTTP server", error); + process.exitCode = 1; + } + + resolve(); + }); + }); + } + + return { + isShutdownStarted: () => shutdownStarted, + shutdown, + }; +} diff --git a/apps/backend/tsconfig.kuaishou.build.json b/apps/backend/tsconfig.kuaishou.build.json new file mode 100644 index 00000000..ed08e0e5 --- /dev/null +++ b/apps/backend/tsconfig.kuaishou.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.kuaishou.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true + } +} diff --git a/apps/backend/tsconfig.kuaishou.json b/apps/backend/tsconfig.kuaishou.json new file mode 100644 index 00000000..3dcacbb1 --- /dev/null +++ b/apps/backend/tsconfig.kuaishou.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "files": [ + "src/index-kuaishou.ts" + ], + "exclude": [ + "node_modules", + "data", + "dist", + "src/**/*.test.js", + "src/**/*.test.ts" + ] +} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 3d75e82f..db9adcc0 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -1,12 +1,14 @@ import { createRouter, createWebHashHistory } from 'vue-router' import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' +const isKuaishouMode = import.meta.env.VITE_APP_MODE === 'kuaishou' + const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', - redirect: '/tx/browser', + redirect: isKuaishouMode ? '/admin/dashboard' : '/tx/browser', }, { path: '/tx', diff --git a/deploy/docker/backend-kuaishou.Dockerfile b/deploy/docker/backend-kuaishou.Dockerfile new file mode 100644 index 00000000..dd1df17d --- /dev/null +++ b/deploy/docker/backend-kuaishou.Dockerfile @@ -0,0 +1,62 @@ +# 快手轻量后端镜像:不安装 Playwright / Chromium / OCR 依赖。 +FROM docker.m.daocloud.io/library/node:22-bookworm AS builder + +ARG DEBIAN_MIRROR=mirrors.cloud.tencent.com +ARG NPM_REGISTRY=https://registry.npmmirror.com + +ENV DEBIAN_FRONTEND=noninteractive +ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY} + +WORKDIR /app + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + sed -i "s|http://deb.debian.org/debian|http://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \ + && sed -i "s|http://security.debian.org/debian-security|http://${DEBIAN_MIRROR}/debian-security|g" /etc/apt/sources.list.d/debian.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + curl ca-certificates tzdata + +COPY apps/backend/package.kuaishou.json ./package.json +COPY apps/backend/package-lock.json ./package-lock.json +RUN --mount=type=cache,target=/root/.npm \ + npm install --package-lock-only --ignore-scripts \ + && npm ci + +COPY apps/backend/ ./ +COPY apps/backend/package.kuaishou.json ./package.json +RUN npm run build:kuaishou \ + && npm prune --omit=dev + +FROM docker.m.daocloud.io/library/node:22-bookworm-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=Asia/Shanghai +ENV NODE_ENV=production + +WORKDIR /app + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends \ + curl ca-certificates tzdata \ + && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && echo ${TZ} > /etc/timezone \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/dist ./dist + +RUN groupadd -g 1001 appuser \ + && useradd -m -u 1001 -g appuser appuser \ + && mkdir -p /app/data \ + && chown -R appuser:appuser /app/data + +USER appuser + +EXPOSE 3000 + +CMD ["npm", "run", "start:kuaishou"] diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile index d72fcaae..cd6c489d 100644 --- a/deploy/docker/frontend.Dockerfile +++ b/deploy/docker/frontend.Dockerfile @@ -1,9 +1,11 @@ FROM docker.m.daocloud.io/library/node:22-bookworm AS builder ARG NPM_REGISTRY=https://registry.npmmirror.com +ARG VITE_APP_MODE= ENV TZ=Asia/Shanghai ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY} +ENV VITE_APP_MODE=${VITE_APP_MODE} WORKDIR /app diff --git a/docker-compose.kuaishou.dev.yml b/docker-compose.kuaishou.dev.yml new file mode 100644 index 00000000..65818617 --- /dev/null +++ b/docker-compose.kuaishou.dev.yml @@ -0,0 +1,100 @@ +services: + postgres: + image: docker.m.daocloud.io/library/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_kuaishou_dev_data:/var/lib/postgresql/data + + backend: + image: docker.m.daocloud.io/library/node:22-bookworm-slim + working_dir: /app + restart: unless-stopped + env_file: + - .env + command: >- + sh -lc + "npm install --no-fund --no-audit; + npm run dev:kuaishou" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s + environment: + TZ: ${TZ:-Asia/Shanghai} + PORT: ${BACKEND_PORT:-3000} + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} + DATABASE_SSL: ${DATABASE_SSL:-false} + DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10} + DATA_ROOT: /app/data + CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} + ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} + AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} + AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + volumes: + - ./apps/backend:/app + - backend_kuaishou_node_modules:/app/node_modules + - ./apps/backend/data:/app/data + + frontend: + image: docker.m.daocloud.io/library/node:22-bookworm-slim + working_dir: /app + restart: unless-stopped + command: >- + sh -lc + "[ -f node_modules/.install_done ] || (npm install --no-fund --no-audit && touch node_modules/.install_done); + npm run dev -- --host 0.0.0.0 --port 5173" + environment: + TZ: ${TZ:-Asia/Shanghai} + CHOKIDAR_USEPOLLING: "true" + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + VITE_API_TARGET: http://backend:3000 + VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1} + VITE_APP_MODE: kuaishou + volumes: + - ./apps/frontend:/app + - frontend_kuaishou_node_modules:/app/node_modules + + web: + image: docker.m.daocloud.io/library/caddy:2-alpine + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + ports: + - "80:80" + - "443:443" + environment: + TZ: ${TZ:-Asia/Shanghai} + volumes: + - ./deploy/caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro + +volumes: + postgres_kuaishou_dev_data: + backend_kuaishou_node_modules: + frontend_kuaishou_node_modules: diff --git a/docker-compose.kuaishou.yml b/docker-compose.kuaishou.yml new file mode 100644 index 00000000..baddd318 --- /dev/null +++ b/docker-compose.kuaishou.yml @@ -0,0 +1,79 @@ +services: + postgres: + image: docker.m.daocloud.io/library/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_kuaishou_data:/var/lib/postgresql/data + + backend: + build: + context: . + dockerfile: deploy/docker/backend-kuaishou.Dockerfile + restart: unless-stopped + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s + environment: + TZ: ${TZ:-Asia/Shanghai} + PORT: ${BACKEND_PORT:-3000} + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} + DATABASE_SSL: ${DATABASE_SSL:-false} + DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20} + DATA_ROOT: /app/data + CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} + ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} + AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} + AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} + volumes: + - ./deploy/data/backend-kuaishou:/app/data + + web: + build: + context: . + dockerfile: deploy/docker/frontend.Dockerfile + args: + APP_DOMAIN: ${APP_DOMAIN:-localhost} + VITE_APP_MODE: kuaishou + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "80:80" + - "443:443" + environment: + TZ: ${TZ:-Asia/Shanghai} + APP_DOMAIN: ${APP_DOMAIN:-localhost} + CADDY_SITE_ADDR: ${CADDY_SITE_ADDR:-http://localhost} + volumes: + - caddy_kuaishou_data:/data + - caddy_kuaishou_config:/config + +volumes: + postgres_kuaishou_data: + caddy_kuaishou_data: + caddy_kuaishou_config: From dc099ae6f88f9e22740ab71bb7c334768c4014ad Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 20:59:57 +0800 Subject: [PATCH 02/73] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=BF=AB=E6=89=8B?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=97=A0=E7=94=A8=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/README.md | 68 +- apps/frontend/src/components.d.ts | 6 - .../components/tencent/TencentAuthCard.vue | 481 ------------ .../components/tencent/TencentRedeemPanel.vue | 288 ------- .../components/tencent/TencentResultPanel.vue | 158 ---- apps/frontend/src/composables/shared/index.ts | 17 - .../composables/shared/mergeSessionDetail.ts | 82 -- .../composables/shared/sessionConstants.ts | 7 - .../composables/shared/useSessionPolling.ts | 93 --- .../shared/useSessionPresentation.ts | 246 ------ .../composables/shared/useSessionQrDisplay.ts | 63 -- .../src/composables/tencent/session-errors.ts | 48 -- .../useTencentBrowserSessionPolling.ts | 193 ----- .../useTencentBrowserSessionPresentation.ts | 210 ------ .../composables/useAdminManualRedeemPage.ts | 714 ------------------ apps/frontend/src/composables/useClaimPage.ts | 451 ----------- .../useTencentBrowserSessionPage.ts | 229 ------ apps/frontend/src/router/index.ts | 16 +- apps/frontend/src/services/admin/index.ts | 1 - .../src/services/admin/manual-redeem.ts | 56 -- .../services/admin/platform-config/agiso.ts | 26 - .../platform-config/fulfillment-bindings.ts | 35 - .../services/admin/platform-config/index.ts | 2 - apps/frontend/src/services/claim.ts | 34 +- .../src/services/tencent/artifacts.ts | 4 - apps/frontend/src/services/tencent/redeem.ts | 17 - apps/frontend/src/services/tencent/session.ts | 35 - .../src/styles/admin-config-pages.css | 2 +- apps/frontend/src/types/admin/fulfillment.ts | 27 - apps/frontend/src/types/admin/index.ts | 15 - .../frontend/src/types/admin/manual-redeem.ts | 9 - .../src/types/admin/platform-config/agiso.ts | 26 - .../platform-config/fulfillment-bindings.ts | 36 - .../src/types/admin/platform-config/index.ts | 11 - apps/frontend/src/types/claim.ts | 6 +- apps/frontend/src/types/tencent/activity.ts | 51 -- apps/frontend/src/types/tencent/redeem.ts | 12 - apps/frontend/src/types/tencent/session.ts | 45 -- apps/frontend/src/views/NotFoundView.vue | 4 +- .../AdminFulfillmentConfigHubView.vue | 51 +- .../bindings/AdminFulfillmentBindings.css | 290 ------- .../bindings/AdminFulfillmentBindingsView.vue | 73 -- .../components/AdminBindingRuleCard.vue | 213 ------ .../components/AdminBindingsLookupSection.vue | 156 ---- .../AdminBindingsObservedSection.vue | 90 --- .../AdminBindingsOverviewSection.vue | 48 -- .../components/AdminBindingsRuleSection.vue | 106 --- .../fulfillment/bindings/composables/types.ts | 66 -- .../composables/useFulfillmentBindings.ts | 453 ----------- .../useFulfillmentBindingsLookup.ts | 70 -- .../src/views/admin/layout/AdminLayout.vue | 6 +- .../manual-redeem/AdminManualRedeemView.vue | 275 ------- .../AdminManualRedeemCreateCard.vue | 117 --- .../AdminManualRedeemReviewCard.vue | 87 --- .../AdminManualRedeemSummaryCard.vue | 56 -- .../platform-shops/AdminPlatformShopsView.vue | 156 ++-- .../components/AdminPlatformAgisoSection.vue | 264 ------- .../admin/platform-shops/composables/types.ts | 16 - .../composables/useAdminAgisoPlatform.ts | 203 ----- .../src/views/claim/ClaimTencentView.vue | 357 --------- apps/frontend/src/views/claim/ClaimView.vue | 81 +- .../src/views/tx/TencentBrowserView.vue | 303 -------- deploy/docker/frontend.Dockerfile | 2 - docker-compose.kuaishou.dev.yml | 1 - docker-compose.kuaishou.yml | 1 - 65 files changed, 86 insertions(+), 7279 deletions(-) delete mode 100644 apps/frontend/src/components/tencent/TencentAuthCard.vue delete mode 100644 apps/frontend/src/components/tencent/TencentRedeemPanel.vue delete mode 100644 apps/frontend/src/components/tencent/TencentResultPanel.vue delete mode 100644 apps/frontend/src/composables/shared/index.ts delete mode 100644 apps/frontend/src/composables/shared/mergeSessionDetail.ts delete mode 100644 apps/frontend/src/composables/shared/sessionConstants.ts delete mode 100644 apps/frontend/src/composables/shared/useSessionPolling.ts delete mode 100644 apps/frontend/src/composables/shared/useSessionPresentation.ts delete mode 100644 apps/frontend/src/composables/shared/useSessionQrDisplay.ts delete mode 100644 apps/frontend/src/composables/tencent/session-errors.ts delete mode 100644 apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts delete mode 100644 apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts delete mode 100644 apps/frontend/src/composables/useAdminManualRedeemPage.ts delete mode 100644 apps/frontend/src/composables/useClaimPage.ts delete mode 100644 apps/frontend/src/composables/useTencentBrowserSessionPage.ts delete mode 100644 apps/frontend/src/services/admin/manual-redeem.ts delete mode 100644 apps/frontend/src/services/admin/platform-config/agiso.ts delete mode 100644 apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts delete mode 100644 apps/frontend/src/services/tencent/artifacts.ts delete mode 100644 apps/frontend/src/services/tencent/redeem.ts delete mode 100644 apps/frontend/src/services/tencent/session.ts delete mode 100644 apps/frontend/src/types/admin/fulfillment.ts delete mode 100644 apps/frontend/src/types/admin/manual-redeem.ts delete mode 100644 apps/frontend/src/types/admin/platform-config/agiso.ts delete mode 100644 apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts delete mode 100644 apps/frontend/src/types/tencent/activity.ts delete mode 100644 apps/frontend/src/types/tencent/redeem.ts delete mode 100644 apps/frontend/src/types/tencent/session.ts delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts delete mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts delete mode 100644 apps/frontend/src/views/admin/manual-redeem/AdminManualRedeemView.vue delete mode 100644 apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemCreateCard.vue delete mode 100644 apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemReviewCard.vue delete mode 100644 apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemSummaryCard.vue delete mode 100644 apps/frontend/src/views/admin/platform-shops/components/AdminPlatformAgisoSection.vue delete mode 100644 apps/frontend/src/views/admin/platform-shops/composables/useAdminAgisoPlatform.ts delete mode 100644 apps/frontend/src/views/claim/ClaimTencentView.vue delete mode 100644 apps/frontend/src/views/tx/TencentBrowserView.vue diff --git a/apps/frontend/README.md b/apps/frontend/README.md index b9501501..c86531da 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,64 +1,40 @@ -# 腾讯浏览器兑换前端 +# 快手轻量前端 -这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责。 +这是快手自动发货拆分后的 Vue 3 前端,只保留快手领取和运营后台相关入口。 ## 功能范围 -- 提供 `/tx/browser` 单页入口 -- 支持 QQ / 微信扫码登录切换 -- 支持浏览器会话创建、状态轮询、角色确认、兑换提交 -- 支持结果信息展示,以及在后端产物存在时预览截图 +- `/claim/:token` 快手 Cloud 领取页 +- `/admin` 运营后台 +- 91 卡券订单补全与任务生成 +- 快手小店核销配置 +- Cloudtentacles 履约配置与调试 +- 内部通知和定时任务配置 -## 项目原则 +## 已移除内容 -- 保持页面职责单一,只服务腾讯浏览器兑换链路 -- 与本地后端接口保持一致,优先保证稳定性和可维护性 -- 页面层尽量薄,状态编排、轮询、展示映射分别收口 +- 腾讯浏览器调试入口 `/tx/browser` +- 腾讯领取页 fallback +- 人工兑换页面 +- 旧履约规则配置页 +- Agiso 店铺消息配置前端入口 ## 本地开发 -安装依赖并启动开发环境: - ```bash -cd /Users/yml/codes/order-site-rewrite npm install npm run dev ``` -构建生产包: +开发环境默认通过 `VITE_API_TARGET` 代理后端: ```bash +VITE_API_TARGET=http://localhost:3000 npm run dev +``` + +## 验证 + +```bash +npm run typecheck npm run build ``` - -开发环境默认代理到本地后端: - -```bash -http://localhost:3000 -``` - -如需切换后端地址,启动前设置: - -```bash -VITE_API_TARGET=http://你的后端地址 npm run dev -``` - -## 结构说明 - -```text -src/ - lib/ HTTP 基础设施 - router/ 路由 - services/ 腾讯接口调用 - types/ 腾讯会话 / 活动 / 兑换类型 - composables/ 页面编排、轮询、展示映射 - components/ 腾讯页面子组件 - views/ 页面壳 - styles/ 全局样式 -``` - -## 当前状态 - -- 主页面已经拆成扫码卡片、兑换面板、结果面板三个组件 -- 会话轮询、错误提示、展示文案已分别从页面模板中抽离 -- 前端当前没有全局 store,所有状态都围绕单页流程局部管理 diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index a872235d..58b3b015 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -25,7 +25,6 @@ declare module 'vue' { ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] - ElDialog: typeof import('element-plus/es')['ElDialog'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] @@ -33,7 +32,6 @@ declare module 'vue' { ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] - ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMenu: typeof import('element-plus/es')['ElMenu'] @@ -47,7 +45,6 @@ declare module 'vue' { ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSpace: typeof import('element-plus/es')['ElSpace'] - ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] @@ -57,9 +54,6 @@ declare module 'vue' { ElTooltip: typeof import('element-plus/es')['ElTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - TencentAuthCard: typeof import('./components/tencent/TencentAuthCard.vue')['default'] - TencentRedeemPanel: typeof import('./components/tencent/TencentRedeemPanel.vue')['default'] - TencentResultPanel: typeof import('./components/tencent/TencentResultPanel.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue deleted file mode 100644 index 3d0d106b..00000000 --- a/apps/frontend/src/components/tencent/TencentAuthCard.vue +++ /dev/null @@ -1,481 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue deleted file mode 100644 index 0a2774d0..00000000 --- a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue +++ /dev/null @@ -1,288 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/tencent/TencentResultPanel.vue b/apps/frontend/src/components/tencent/TencentResultPanel.vue deleted file mode 100644 index 1585aad4..00000000 --- a/apps/frontend/src/components/tencent/TencentResultPanel.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - - - diff --git a/apps/frontend/src/composables/shared/index.ts b/apps/frontend/src/composables/shared/index.ts deleted file mode 100644 index ab934b7f..00000000 --- a/apps/frontend/src/composables/shared/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { useSessionPolling } from './useSessionPolling' -export type { UseSessionPollingOptions } from './useSessionPolling' - -export { useSessionQrDisplay } from './useSessionQrDisplay' - -export { - useSessionRoleFacts, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, -} from './useSessionPresentation' -export type { TaskStatusLabelContext, FactItem, UseSessionPresentationOptions, UseSessionResultFactsOptions } from './useSessionPresentation' - -export { mergeSessionDetail, shouldRefreshQrImage, shouldKeepPolling } from './mergeSessionDetail' - -export { DEFAULT_LOGIN_TYPE, POLL_INTERVAL_MS, POLL_FAILURE_LIMIT, ACTIVE_TASK_STATUSES } from './sessionConstants' diff --git a/apps/frontend/src/composables/shared/mergeSessionDetail.ts b/apps/frontend/src/composables/shared/mergeSessionDetail.ts deleted file mode 100644 index d476a9df..00000000 --- a/apps/frontend/src/composables/shared/mergeSessionDetail.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ClaimDetailData } from '@/types/claim' -import type { - TencentBrowserSessionData, - TencentBrowserSessionSummaryData, -} from '@/types/tencent/session' - -import type { ClaimTaskStatus } from '@/types/claim' -import { ACTIVE_TASK_STATUSES } from './sessionConstants' - -/** - * Generic merge of session detail data, preserving QR image and other - * fields that may be absent in summary responses. - * - * The `extraSessionFields` callback lets callers inject domain-specific - * session merge logic (e.g. `review` for admin, `redeem` for claim). - */ -export function mergeSessionDetail( - current: T | null, - next: T, - extraSessionFields?: ( - currentSession: NonNullable, - nextSession: NonNullable, - ) => Partial>, -): T { - if (next.session === null) { - return { - ...next, - session: null, - } as T - } - - if (!current?.session) { - return next - } - - const sessionBase = { - ...current.session, - ...next.session, - qrImageBase64: - typeof next.session.qrImageBase64 === 'string' - ? next.session.qrImageBase64 - : current.session.qrImageBase64, - activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null, - redeem: next.session.redeem ?? current.session.redeem ?? null, - artifacts: next.session.artifacts || current.session.artifacts, - } as NonNullable - - const extras = extraSessionFields - ? extraSessionFields(current.session as NonNullable, next.session as NonNullable) - : {} - - return { - ...next, - session: { - ...sessionBase, - ...extras, - }, - } as T -} - -export function shouldRefreshQrImage( - current: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null, - next: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null, -): boolean { - if (!current || !next || !next.artifacts?.hasQrImage) { - return false - } - - if (!current.qrImageBase64) { - return true - } - - return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt) -} - -export function shouldKeepPolling(detail: T | null): boolean { - if (!detail?.session?.sessionId) { - return false - } - - return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus) -} diff --git a/apps/frontend/src/composables/shared/sessionConstants.ts b/apps/frontend/src/composables/shared/sessionConstants.ts deleted file mode 100644 index b328b05a..00000000 --- a/apps/frontend/src/composables/shared/sessionConstants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TencentLoginType } from '@/types/tencent/session' -import type { ClaimTaskStatus } from '@/types/claim' - -export const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' -export const POLL_INTERVAL_MS = 2_500 -export const POLL_FAILURE_LIMIT = 3 -export const ACTIVE_TASK_STATUSES = new Set(['claimed', 'role_confirmed', 'redeeming']) diff --git a/apps/frontend/src/composables/shared/useSessionPolling.ts b/apps/frontend/src/composables/shared/useSessionPolling.ts deleted file mode 100644 index ed8ae613..00000000 --- a/apps/frontend/src/composables/shared/useSessionPolling.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { computed, ref, type ComputedRef } from 'vue' - -import { POLL_INTERVAL_MS, POLL_FAILURE_LIMIT } from './sessionConstants' - -export interface UseSessionPollingOptions { - /** Whether there is an active session – used as a guard inside the poll loop. */ - hasSession: ComputedRef - /** Called on each poll tick (typically refreshSessionSummary). */ - onPollTick: () => Promise - /** Optional: determines whether polling should continue. Defaults to checking hasSession. */ - shouldKeepPolling?: ComputedRef - /** Context-specific warning message prefix when polling fails repeatedly. */ - pollWarningPrefix: string -} - -export function useSessionPolling(options: UseSessionPollingOptions) { - const { hasSession, onPollTick, pollWarningPrefix } = options - - const pollWarningMessage = ref('') - - let pollTimer: ReturnType | null = null - let pollToken = 0 - let pollFailureCount = 0 - - const sessionNotice = computed(() => pollWarningMessage.value) - - function startPolling() { - const tokenId = ++pollToken - - const loop = async () => { - if (tokenId !== pollToken || !hasSession.value) { - return - } - - await onPollTick() - - if (tokenId !== pollToken) { - return - } - - const keepPolling = options.shouldKeepPolling - ? options.shouldKeepPolling.value - : hasSession.value - - if (!keepPolling) { - return - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - function resetPolling() { - pollToken += 1 - - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - } - } - - function resetPollingWarning() { - pollFailureCount = 0 - pollWarningMessage.value = '' - } - - function handleSilentPollingError(error: unknown) { - console.error(error) - pollFailureCount += 1 - - if (pollFailureCount < POLL_FAILURE_LIMIT) { - return - } - - pollWarningMessage.value = `${pollWarningPrefix},已暂停自动轮询,请手动刷新。` - resetPolling() - } - - return { - pollWarningMessage, - sessionNotice, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } -} diff --git a/apps/frontend/src/composables/shared/useSessionPresentation.ts b/apps/frontend/src/composables/shared/useSessionPresentation.ts deleted file mode 100644 index 57cdde8e..00000000 --- a/apps/frontend/src/composables/shared/useSessionPresentation.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { computed, type ComputedRef, type Ref } from 'vue' - -import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' - -import { DEFAULT_LOGIN_TYPE } from './sessionConstants' - -type ReadableRef = Pick, 'value'> - -// --------------------------------------------------------------------------- -// syncLoginTypeFromDetail -// --------------------------------------------------------------------------- - -export function syncLoginTypeFromDetail( - detail: T, - fallback: TencentLoginType = DEFAULT_LOGIN_TYPE, -): TencentLoginType { - const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim() - if (nextLoginType === 'wx') { - return 'wx' - } - - if (nextLoginType === 'qq') { - return 'qq' - } - - return fallback -} - -// --------------------------------------------------------------------------- -// resolveTaskStatusLabel -// --------------------------------------------------------------------------- - -export type TaskStatusLabelContext = 'claim' | 'admin' - -/** - * Resolves a human-readable task status label. - * - * The `context` parameter controls wording differences between the - * consumer-facing claim page and the admin manual-redeem page. - */ -export function resolveTaskStatusLabel( - taskStatus: string | undefined, - sessionStatus: string | undefined, - context: TaskStatusLabelContext, -): string { - switch (taskStatus) { - case 'link_generated': - return context === 'admin' ? '待生成二维码' : '等待开始领取' - case 'claimed': - if (sessionStatus === 'scanned') { - return context === 'admin' ? '客户待确认' : '确认中' - } - if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') { - return context === 'admin' ? '已登录待复核' : '已登录' - } - return context === 'admin' ? '等待客户登录' : '领取中' - case 'role_confirmed': - return '角色已确认' - case 'redeeming': - return '正在兑换' - case 'redeemed': - return context === 'admin' ? '兑换完成' : '兑换成功' - case 'waiting_inventory': - return '等待库存' - case 'retry_pending': - return '等待重试' - case 'manual_review': - return '等待人工处理' - case 'expired': - return '链接已过期' - case 'closed': - return '任务已关闭' - default: - return sessionStatus || taskStatus || '等待中' - } -} - -// --------------------------------------------------------------------------- -// roleFacts / resultFacts helpers -// --------------------------------------------------------------------------- - -export interface FactItem { - label: string - value: string - accent?: boolean -} - -export interface UseSessionPresentationOptions { - activityInfo: ComputedRef<{ - nickname?: string - role?: { - roleId?: string - roleName?: string - area?: string - ready?: boolean - } | null - } | null> - statusLabel: ComputedRef - /** Context-specific default values for roleFacts */ - roleFactDefaults: { - nickname: string - } -} - -export function useSessionRoleFacts(options: UseSessionPresentationOptions) { - const { activityInfo, roleFactDefaults } = options - - const roleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || roleFactDefaults.nickname, - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '所在大区', - value: activityInfo.value?.role?.area || '未识别', - }, - ]) - - return { roleFacts } -} - -export interface UseSessionResultFactsOptions { - order: ComputedRef<{ platformOrderId?: string } | null> - orderItem: ComputedRef<{ skuName?: string } | null> - result: ComputedRef<{ resultCode?: string; resultMessage?: string } | null> - /** Admin context has manualRequest.proofValue as an alternative first label */ - resultFactOverrides?: { - firstLabel?: string - firstValue?: ComputedRef - } -} - -export function useSessionResultFacts(options: UseSessionResultFactsOptions) { - const { order, orderItem, result, resultFactOverrides } = options - - const resultFacts = computed(() => [ - { - label: resultFactOverrides?.firstLabel || '订单号', - value: resultFactOverrides?.firstValue?.value || order.value?.platformOrderId || '-', - }, - { - label: '商品', - value: orderItem.value?.skuName || '-', - }, - { - label: '业务返回码', - value: result.value?.resultCode || '-', - }, - { - label: '业务消息', - value: result.value?.resultMessage || '尚未兑换', - }, - ]) - - return { resultFacts } -} - -// --------------------------------------------------------------------------- -// redeemBlockedReason — builds the "why can't I redeem" explanation -// --------------------------------------------------------------------------- - -export function buildRedeemBlockedReason(options: { - detail: ReadableRef - hasSession: ReadableRef - loginTypeLabel: ReadableRef - redeemLoading: ReadableRef - roleReady: ReadableRef - roleConfirmed: ReadableRef - sessionNotice: ReadableRef - context: 'claim' | 'admin' - /** Claim-specific fields */ - tokenStatus?: ReadableRef - requiresSupportReview?: ReadableRef -}): string { - const { - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context, - tokenStatus, - requiresSupportReview, - } = options - - if (context === 'claim') { - if (!detail.value) { - return '正在加载领取信息' - } - if (tokenStatus && tokenStatus.value !== 'active') { - return '当前领取链接不可用' - } - } else { - // admin context - if (!detail.value) { - return '请先创建人工兑换任务并预占库存' - } - } - - if (!hasSession.value) { - return context === 'claim' - ? `请先选择${loginTypeLabel.value}并初始化登录会话` - : `请先生成${loginTypeLabel.value}二维码并等待客户登录` - } - - // Claim-specific: support review flow - if (context === 'claim' && requiresSupportReview?.value) { - if (!roleReady.value) { - return '扫码成功后,系统会自动同步登录信息,准备发给客服复核' - } - return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续' - } - - if (redeemLoading.value) { - return '兑换任务正在执行中' - } - - if (!roleReady.value) { - return ( - sessionNotice.value || - (context === 'claim' - ? '扫码成功后,后端正在同步角色和大区信息' - : '客户登录后,系统会自动同步角色和大区信息') - ) - } - - if (!roleConfirmed.value) { - return context === 'claim' - ? '请先确认当前角色与大区无误,再开始兑换' - : '请先让客户确认角色截图,再点击确认当前角色' - } - - return '' -} diff --git a/apps/frontend/src/composables/shared/useSessionQrDisplay.ts b/apps/frontend/src/composables/shared/useSessionQrDisplay.ts deleted file mode 100644 index 802d805e..00000000 --- a/apps/frontend/src/composables/shared/useSessionQrDisplay.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { computed, ref, watch, type ComputedRef } from 'vue' - -/** - * Shared QR code display logic: renders the base64 QR image, tracks its - * natural width after load, and derives display/preview widths. - */ -export function useSessionQrDisplay(options: { - session: ComputedRef<{ qrImageBase64?: string } | null> -}) { - const { session } = options - - const qrImageNaturalWidth = ref(0) - - const qrImage = computed(() => - session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', - ) - - watch(qrImage, () => { - qrImageNaturalWidth.value = 0 - }) - - function handleQrImageLoad(event: Event) { - const target = event.target - - if (!(target instanceof HTMLImageElement)) { - return - } - - qrImageNaturalWidth.value = target.naturalWidth || 0 - } - - const qrDisplayWidth = computed(() => { - const naturalWidth = qrImageNaturalWidth.value - - if (!naturalWidth) { - return 220 - } - - if (naturalWidth < 160) { - return Math.min(naturalWidth * 2, 220) - } - - return Math.min(naturalWidth, 240) - }) - - const qrFigureStyle = computed(() => ({ - width: `${qrDisplayWidth.value}px`, - maxWidth: '100%', - })) - - const qrPreviewWidth = computed( - () => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`, - ) - - return { - qrImage, - qrImageNaturalWidth, - qrDisplayWidth, - qrFigureStyle, - qrPreviewWidth, - handleQrImageLoad, - } -} diff --git a/apps/frontend/src/composables/tencent/session-errors.ts b/apps/frontend/src/composables/tencent/session-errors.ts deleted file mode 100644 index 6bc31eb6..00000000 --- a/apps/frontend/src/composables/tencent/session-errors.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { showError } from '@/lib/feedback' - -export type TencentActionError = Error & { - errorCode?: string - status?: number -} - -export function notifyTencentActionError(error: unknown, fallbackMessage: string) { - const message = resolveTencentActionMessage(error, fallbackMessage) - console.error(error) - showError(message) -} - -export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) { - const normalizedError = error as TencentActionError - const errorCode = String(normalizedError?.errorCode || '').trim() - const message = normalizedError instanceof Error ? normalizedError.message.trim() : '' - - if (errorCode === 'missing_redeem_code') { - return '请输入兑换码' - } - - if (errorCode === 'session_not_ready') { - return '当前会话还不能兑换,请先完成扫码并确认角色信息' - } - - if (errorCode === 'session_not_found' || errorCode === 'not_found') { - return '当前会话不存在或已过期,请重新生成二维码' - } - - if (errorCode === 'session_closed' || errorCode === 'gone') { - return '当前会话已关闭,请重新生成二维码' - } - - if (errorCode === 'dependency_unavailable') { - return '后端依赖暂不可用,请检查浏览器或 OCR 服务是否已就绪' - } - - if (errorCode === 'invalid_request') { - return '请求参数不完整,请检查输入后重试' - } - - if (errorCode === 'conflict') { - return '当前会话状态已变化,请刷新后重试' - } - - return message || fallbackMessage -} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts deleted file mode 100644 index 4064734e..00000000 --- a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { computed, ref, type Ref } from 'vue' - -import { - fetchTencentBrowserSession, - fetchTencentBrowserSessionSummary, -} from '@/services/tencent/session' -import type { - TencentBrowserSessionData, - TencentBrowserSessionStatus, - TencentBrowserSessionSummaryData, -} from '@/types/tencent/session' - -import { resolveTencentActionMessage } from './session-errors' - -const POLL_INTERVAL_MS = 2_500 -const POLL_FAILURE_LIMIT = 3 - -export const ACTIVE_TENCENT_SESSION_STATUSES = new Set([ - 'waiting_scan', - 'scanned', - 'logged_in', - 'ready_to_redeem', - 'redeeming', -]) - -export function useTencentBrowserSessionPolling(options: { - session: Ref - sessionLoading: Ref - notifyActionError: (error: unknown, fallbackMessage: string) => void -}) { - const { session, sessionLoading, notifyActionError } = options - const pollFailureCount = ref(0) - const pollWarningMessage = ref('') - - let pollTimer: ReturnType | null = null - let pollToken = 0 - - const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || '') - - async function refreshSession({ silent = false } = {}) { - if (!session.value?.sessionId) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = session.value - const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId) - - const nextSession = mergeSessionData(currentSession, response.data) - session.value = nextSession - - if (shouldRefreshQrImage(currentSession, response.data)) { - const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId) - - session.value = mergeSessionData(nextSession, fullResponse.data) - } - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyActionError(error, '获取浏览器会话状态失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - function startPolling() { - const token = ++pollToken - - const loop = async () => { - if (token !== pollToken || !session.value?.sessionId) { - return - } - - await refreshSession({ silent: true }) - - if (token !== pollToken || !isActiveTencentSessionStatus(session.value?.status)) { - return - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - function resetPolling() { - pollToken += 1 - - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - } - } - - function resetPollingWarning() { - pollFailureCount.value = 0 - pollWarningMessage.value = '' - } - - function restartPollingIfActive(nextSession = session.value) { - if (isActiveTencentSessionStatus(nextSession?.status)) { - startPolling() - } - } - - function handleSilentPollingError(error: unknown) { - console.error(error) - pollFailureCount.value += 1 - - if (pollFailureCount.value < POLL_FAILURE_LIMIT) { - return - } - - pollWarningMessage.value = `${resolveTencentActionMessage( - error, - '会话状态刷新失败', - )},已暂停自动轮询,请手动刷新或重新生成二维码。` - resetPolling() - } - - return { - refreshSession, - resetPolling, - resetPollingWarning, - restartPollingIfActive, - sessionNotice, - startPolling, - } -} - -export function isActiveTencentSessionStatus( - status: TencentBrowserSessionStatus | null | undefined, -) { - return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status)) -} - -function mergeSessionData( - current: TencentBrowserSessionData | null, - next: TencentBrowserSessionSummaryData | TencentBrowserSessionData, -) { - if (!current) { - return { - ...next, - qrImageBase64: next.qrImageBase64 || '', - } - } - - return { - ...current, - ...next, - qrImageBase64: - typeof next.qrImageBase64 === 'string' ? next.qrImageBase64 : current.qrImageBase64, - activityInfo: next.activityInfo ?? current.activityInfo ?? null, - redeem: next.redeem ?? current.redeem ?? null, - artifacts: next.artifacts || current.artifacts, - } -} - -function shouldRefreshQrImage( - current: TencentBrowserSessionData, - next: TencentBrowserSessionSummaryData, -) { - if (!next.artifacts?.hasQrImage) { - return false - } - - if (!current.qrImageBase64) { - return true - } - - return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt) -} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts deleted file mode 100644 index b96c15a4..00000000 --- a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { computed, type ComputedRef, type Ref } from 'vue' - -import { buildTencentBrowserSessionScreenshotUrl } from '@/services/tencent/artifacts' -import type { TencentBrowserActivityInfo } from '@/types/tencent/activity' -import type { TencentBrowserSessionData } from '@/types/tencent/session' - -const REDEEM_ALLOWED_STATUSES = new Set(['logged_in', 'ready_to_redeem', 'redeemed']) - -export function useTencentBrowserSessionPresentation(options: { - activityInfo: ComputedRef - hasSession: ComputedRef - loginTypeLabel: ComputedRef - redeemLoading: Ref - roleConfirmed: Ref - roleReady: ComputedRef - session: Ref - sessionLoading: Ref - sessionNotice: ComputedRef -}) { - const { - activityInfo, - hasSession, - loginTypeLabel, - redeemLoading, - roleConfirmed, - roleReady, - session, - sessionLoading, - sessionNotice, - } = options - - const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) - const scanInstruction = computed( - () => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`, - ) - - const qrImage = computed(() => - session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', - ) - - const screenshotUrl = computed(() => { - if (!session.value?.sessionId || !session.value?.artifacts?.hasScreenshot) { - return '' - } - - return buildTencentBrowserSessionScreenshotUrl(session.value.sessionId, session.value.updatedAt) - }) - - const statusLabel = computed(() => { - if (!hasSession.value) { - return '待初始化' - } - - switch (session.value?.status) { - case 'waiting_scan': - return '等待扫码' - case 'scanned': - return '已扫码待确认' - case 'logged_in': - return '页面已登录' - case 'ready_to_redeem': - return '可以兑换' - case 'redeeming': - return '兑换中' - case 'redeemed': - return '已完成' - case 'failed': - return '会话异常' - default: - return '初始化中' - } - }) - - const canRedeem = computed(() => { - if (redeemLoading.value || sessionLoading.value || !session.value?.sessionId) { - return false - } - - return ( - REDEEM_ALLOWED_STATUSES.has(String(session.value.status || '')) && - roleReady.value && - roleConfirmed.value - ) - }) - - const redeemBlockedReason = computed(() => { - if (!session.value?.sessionId) { - return `请先选择${loginTypeLabel.value}登录,并手动开始初始化浏览器会话` - } - - if (redeemLoading.value) { - return '兑换任务正在执行中' - } - - if (sessionLoading.value) { - return '正在刷新浏览器会话状态' - } - - if (!roleReady.value) { - return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息' - } - - if (!roleConfirmed.value) { - return '请先确认当前角色与大区无误,再开始兑换' - } - - if (REDEEM_ALLOWED_STATUSES.has(String(session.value.status || ''))) { - return '' - } - - switch (session.value.status) { - case 'waiting_scan': - return `请先使用${loginTypeLabel.value}扫码` - case 'scanned': - return '请在手机上确认登录后再兑换' - case 'failed': - return sessionNotice.value || '浏览器会话异常,请重新生成二维码' - default: - return sessionNotice.value || '当前状态还不能开始兑换' - } - }) - - const finalRedeemMessage = computed(() => { - const result = session.value?.redeem?.final?.redeem - - if (!result || typeof result !== 'object') { - return '' - } - - const record = result as Record - return String(record.sMsg || record.msg || '') - }) - - const finalRetCode = computed(() => { - const result = session.value?.redeem?.final?.redeem - - if (!result || typeof result !== 'object') { - return '' - } - - const record = result as Record - return String(record.iRet ?? '') - }) - - const roleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待扫码登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '验证码', - value: - activityInfo.value?.form?.verifyValue || - (activityInfo.value?.verify?.visible ? '等待识别' : '等待显示'), - }, - ]) - - const resultFacts = computed(() => [ - { - label: '业务返回码', - value: finalRetCode.value || '-', - }, - { - label: '业务消息', - value: finalRedeemMessage.value || '尚未兑换', - }, - { - label: 'OCR 尝试次数', - value: String(session.value?.redeem?.attempts.length || 0), - }, - { - label: '证明截图', - value: session.value?.artifacts?.hasScreenshot ? '已生成' : '未生成', - }, - ]) - - const redeemButtonLabel = computed(() => '开始兑换') - const screenshotEmptyTitle = computed(() => - session.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', - ) - const screenshotEmptyMessage = computed(() => - session.value?.status === 'redeemed' - ? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。' - : '如本次兑换生成了结果截图,这里会展示。', - ) - - return { - canRedeem, - initButtonLabel, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - resultFacts, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - statusLabel, - } -} diff --git a/apps/frontend/src/composables/useAdminManualRedeemPage.ts b/apps/frontend/src/composables/useAdminManualRedeemPage.ts deleted file mode 100644 index 7e3446af..00000000 --- a/apps/frontend/src/composables/useAdminManualRedeemPage.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' -import { useRoute, useRouter } from 'vue-router' - -import { showConfirm, showSuccess } from '@/lib/feedback' -import { - closeAdminManualRedeemTask, - confirmAdminManualRedeemRole, - createAdminManualRedeemSession, - createAdminManualRedeemTask, - fetchAdminManualRedeemDetail, - fetchAdminManualRedeemSessionSummary, - fetchAdminManualRedeemSkuSuggestions, - fetchAdminTaskScreenshot, - refreshAdminManualRedeemSession, - redeemAdminManualRedeem, - removeAdminManualRedeemSession, -} from '@/services/admin' -import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin' -import type { TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { - useSessionPolling, - useSessionQrDisplay, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, - mergeSessionDetail, - shouldRefreshQrImage, - shouldKeepPolling, - DEFAULT_LOGIN_TYPE, -} from './shared' - -export function useAdminManualRedeemPage() { - const route = useRoute() - const router = useRouter() - - // ── Form state (admin-only) ──────────────────────────────────────── - const form = reactive({ - proofValue: '', - skuCode: '', - remark: '', - }) - const suggestions = ref([]) - - // ── Core state ──────────────────────────────────────────────────── - const detail = ref(null) - const detailLoading = ref(false) - const createLoading = ref(false) - const sessionLoading = ref(false) - const roleConfirmLoading = ref(false) - const redeemLoading = ref(false) - const closeLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - - // ── Screenshot state (admin-only) ────────────────────────────────── - const screenshotBlob = ref(null) - const screenshotUrl = ref('') - const screenshotLoading = ref(false) - - // ── Derived state ────────────────────────────────────────────────── - const session = computed(() => detail.value?.session || null) - const task = computed(() => detail.value?.task || null) - const order = computed(() => detail.value?.order || null) - const orderItem = computed(() => detail.value?.orderItem || null) - const result = computed(() => detail.value?.result || null) - const manualRequest = computed(() => detail.value?.manualRequest || null) - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const roleConfirmed = computed(() => - Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)), - ) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0) - const reviewReady = computed( - () => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed', - ) - const resultReady = computed( - () => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed', - ) - - // ── Polling ──────────────────────────────────────────────────────── - const { - pollWarningMessage, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } = useSessionPolling({ - hasSession: computed(() => Boolean(task.value?.taskId && hasSession.value)), - shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)), - onPollTick: async () => { await refreshSessionSummary({ silent: true }) }, - pollWarningPrefix: '人工兑换状态刷新失败', - }) - - const sessionNotice = computed( - () => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '', - ) - - // ── QR display ───────────────────────────────────────────────────── - const { - qrImage, - qrFigureStyle, - qrPreviewWidth, - handleQrImageLoad, - } = useSessionQrDisplay({ session }) - - // ── Presentation ─────────────────────────────────────────────────── - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const statusLabel = computed(() => - resolveTaskStatusLabel(task.value?.status, session.value?.status, 'admin'), - ) - const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`) - const scanInstruction = computed( - () => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`, - ) - - const proofValue = computed(() => manualRequest.value?.proofValue || order.value?.platformOrderId || '-') - - const { resultFacts } = useSessionResultFacts({ - order, - orderItem, - result, - resultFactOverrides: { - firstLabel: '唯一凭据', - firstValue: proofValue, - }, - }) - - // Admin overrides roleFacts to include "所在大区" and exclude "任务状态" - const adminRoleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待客户登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '所在大区', - value: activityInfo.value?.role?.area || '未识别', - }, - ]) - - const canConfirmRole = computed(() => - Boolean( - task.value && - task.value.status === 'claimed' && - hasSession.value && - roleReady.value && - !roleConfirmLoading.value, - ), - ) - const canRedeem = computed(() => - Boolean( - task.value && - ['role_confirmed', 'redeeming'].includes(task.value.status) && - !redeemLoading.value, - ), - ) - const redeemBlockedReason = computed(() => - buildRedeemBlockedReason({ - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context: 'admin', - }), - ) - const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换')) - const screenshotEmptyTitle = computed(() => - task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图', - ) - const screenshotEmptyMessage = computed(() => - task.value?.status === 'redeemed' - ? '本次兑换可能未生成最终截图,或截图仍在同步中。' - : '客户登录并识别到角色后,这里会展示给客服复核的角色截图。', - ) - const canCreateTask = computed( - () => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value, - ) - const hasActiveTask = computed(() => Boolean(task.value?.taskId)) - - // ── Watchers ─────────────────────────────────────────────────────── - watch(qrImage, () => { - // QR image natural width reset handled by useSessionQrDisplay - }) - - watch( - () => currentTaskId.value, - (taskId, previousTaskId) => { - if (!taskId || taskId === previousTaskId) { - return - } - - void loadDetail(taskId) - }, - { immediate: true }, - ) - - onMounted(() => { - void loadSkuSuggestions() - }) - - onBeforeUnmount(() => { - resetPolling() - clearScreenshotPreview() - }) - - // ── Helpers ──────────────────────────────────────────────────────── - function restartPollingIfNeeded(nextDetail = detail.value) { - resetPolling() - - if (shouldKeepPolling(nextDetail)) { - startPolling() - } - } - - function resolveSkuName(skuCode: string) { - const normalized = String(skuCode || '').trim() - const matched = suggestions.value.find( - (item) => String(item.skuCode || '').trim() === normalized, - ) - return matched?.skuCode || normalized - } - - // ── Screenshot helpers (admin-only) ─────────────────────────────── - async function loadScreenshotPreview(nextDetail = detail.value) { - const taskId = Number(nextDetail?.task?.taskId || 0) - const shouldFetch = Boolean( - taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady), - ) - - if (!shouldFetch) { - clearScreenshotPreview() - return - } - - screenshotLoading.value = true - - try { - const blob = await fetchAdminTaskScreenshot(taskId) - clearScreenshotPreview() - screenshotBlob.value = blob - screenshotUrl.value = URL.createObjectURL(blob) - } catch { - clearScreenshotPreview() - } finally { - screenshotLoading.value = false - } - } - - function clearScreenshotPreview() { - screenshotBlob.value = null - - if (screenshotUrl.value) { - URL.revokeObjectURL(screenshotUrl.value) - screenshotUrl.value = '' - } - } - - async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) { - if ( - typeof navigator === 'undefined' || - !navigator.clipboard || - typeof window === 'undefined' || - !('ClipboardItem' in window) - ) { - throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载') - } - - try { - const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem - await navigator.clipboard.write([ - new ClipboardItemCtor({ - [blob.type || 'image/png']: blob, - }), - ]) - } catch (error) { - throw error instanceof Error ? error : new Error(fallbackMessage) - } - } - - // ── API flows ───────────────────────────────────────────────────── - async function loadSkuSuggestions(keyword = '') { - try { - const response = await fetchAdminManualRedeemSkuSuggestions({ - credentialType: 'tencent_code', - keyword: keyword.trim(), - }) - suggestions.value = response.data.items - } catch (error) { - notifyTencentActionError(error, '读取人工兑换 SKU 建议失败') - } - } - - async function loadDetail(taskId = currentTaskId.value) { - if (!taskId) { - detail.value = null - clearScreenshotPreview() - return false - } - - detailLoading.value = true - - try { - const response = await fetchAdminManualRedeemDetail(taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - restartPollingIfNeeded() - return true - } catch (error) { - notifyTencentActionError(error, '读取人工兑换详情失败') - return false - } finally { - detailLoading.value = false - } - } - - async function createTaskNow() { - if (!canCreateTask.value) { - return - } - - createLoading.value = true - - try { - const response = await createAdminManualRedeemTask({ - proofValue: form.proofValue.trim(), - skuCode: form.skuCode.trim(), - skuName: resolveSkuName(form.skuCode), - remark: form.remark.trim(), - }) - - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - await router.replace({ - path: route.path, - query: { - taskId: String(response.data.task.taskId), - }, - }) - showSuccess(response.msg || '人工兑换任务已创建') - } catch (error) { - notifyTencentActionError(error, '创建人工兑换任务失败') - } finally { - createLoading.value = false - } - } - - async function createSessionFlow(nextLoginType = loginType.value) { - if (!task.value?.taskId) { - return - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - loginType.value = nextLoginType - const response = await createAdminManualRedeemSession(task.value.taskId, { - loginType: nextLoginType, - forceRecreate: hasSession.value, - }) - - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType) - await loadScreenshotPreview(response.data) - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建人工兑换登录会话失败') - } finally { - sessionLoading.value = false - } - } - - async function refreshSessionFlow() { - if (!task.value?.taskId) { - return false - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await refreshAdminManualRedeemSession(task.value.taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - restartPollingIfNeeded(response.data) - return true - } catch (error) { - notifyTencentActionError(error, '刷新人工兑换会话失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - return false - } finally { - sessionLoading.value = false - } - } - - async function closeSessionFlow({ silent = false } = {}) { - if (!task.value?.taskId) { - return false - } - - if (!silent) { - sessionLoading.value = true - } - - resetPolling() - resetPollingWarning() - - try { - const response = await removeAdminManualRedeemSession(task.value.taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - - if (!silent) { - showSuccess(response.msg || '人工兑换会话已关闭') - } - - return true - } catch (error) { - if (!silent) { - notifyTencentActionError(error, '关闭人工兑换会话失败') - } - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function refreshSessionSummary({ silent = false } = {}) { - if (!task.value?.taskId) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = detail.value?.session || null - const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId) - detail.value = mergeSessionDetail( - detail.value, - response.data, - (currentSession, nextSession) => ({ - review: nextSession.review ?? currentSession.review ?? null, - }), - ) - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - - if (shouldRefreshQrImage(currentSession, response.data.session)) { - const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId!) - detail.value = mergeSessionDetail( - detail.value, - fullResponse.data, - (currentSession, nextSession) => ({ - review: nextSession.review ?? currentSession.review ?? null, - }), - ) - loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value) - } - - await loadScreenshotPreview(detail.value) - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyTencentActionError(error, '刷新人工兑换会话状态失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function confirmRoleNow() { - if (!task.value?.taskId || !canConfirmRole.value) { - return - } - - roleConfirmLoading.value = true - - try { - const response = await confirmAdminManualRedeemRole(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - showSuccess(response.msg || '角色已确认') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '确认角色失败') - await refreshSessionSummary({ silent: true }) - } finally { - roleConfirmLoading.value = false - } - } - - async function redeemNow() { - if (!task.value?.taskId || !canRedeem.value) { - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemAdminManualRedeem(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - showSuccess(response.msg || '兑换完成') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '人工兑换失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - } finally { - redeemLoading.value = false - } - } - - async function closeTaskNow() { - if (!task.value?.taskId) { - return - } - - try { - await showConfirm( - `确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`, - '关闭人工兑换', - ) - } catch { - return - } - - closeLoading.value = true - - try { - const response = await closeAdminManualRedeemTask(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - resetPolling() - showSuccess(response.msg || '人工兑换任务已关闭') - } catch (error) { - notifyTencentActionError(error, '关闭人工兑换任务失败') - } finally { - closeLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - if (hasSession.value) { - await createSessionFlow(nextLoginType) - return - } - - loginType.value = nextLoginType - } - - function resetCurrentTask() { - resetPolling() - detail.value = null - clearScreenshotPreview() - void router.replace({ - path: route.path, - query: {}, - }) - } - - async function copyQrImage() { - if (!qrImage.value) { - return false - } - - try { - const response = await fetch(qrImage.value) - const blob = await response.blob() - await writeImageBlobToClipboard(blob, '复制二维码失败') - showSuccess('二维码已复制到剪贴板') - return true - } catch (error) { - notifyTencentActionError(error, '复制二维码失败') - return false - } - } - - async function copyScreenshot() { - if (!screenshotBlob.value) { - return false - } - - try { - await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败') - showSuccess('截图已复制到剪贴板') - return true - } catch (error) { - notifyTencentActionError(error, '复制截图失败') - return false - } - } - - function downloadScreenshot() { - if (!screenshotBlob.value || !screenshotUrl.value) { - return false - } - - const anchor = document.createElement('a') - anchor.href = screenshotUrl.value - anchor.download = - task.value?.status === 'redeemed' - ? `manual-redeem-result-${task.value?.taskNo || 'task'}.png` - : `manual-redeem-review-${task.value?.taskNo || 'task'}.png` - anchor.click() - return true - } - - return { - form, - suggestions, - detail, - detailLoading, - createLoading, - sessionLoading, - roleConfirmLoading, - redeemLoading, - closeLoading, - loginType, - loginTypeLabel, - loginTabs, - session, - task, - order, - orderItem, - manualRequest, - activityInfo, - roleReady, - roleConfirmed, - hasSession, - qrImage, - qrFigureStyle, - qrPreviewWidth, - statusLabel, - sessionNotice, - roleFacts: adminRoleFacts, - resultFacts, - canConfirmRole, - canRedeem, - redeemBlockedReason, - redeemButtonLabel, - initButtonLabel, - scanInstruction, - screenshotEmptyTitle, - screenshotEmptyMessage, - screenshotUrl, - screenshotLoading, - reviewReady, - resultReady, - canCreateTask, - hasActiveTask, - createTaskNow, - loadSkuSuggestions, - createSessionFlow, - refreshSessionFlow, - closeSessionFlow, - switchLoginType, - confirmRoleNow, - redeemNow, - closeTaskNow, - resetCurrentTask, - handleQrImageLoad, - copyQrImage, - copyScreenshot, - downloadScreenshot, - } -} diff --git a/apps/frontend/src/composables/useClaimPage.ts b/apps/frontend/src/composables/useClaimPage.ts deleted file mode 100644 index 824fc642..00000000 --- a/apps/frontend/src/composables/useClaimPage.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { computed, onBeforeUnmount, ref, watch } from 'vue' - -import { showSuccess } from '@/lib/feedback' -import { - confirmClaimRole, - createClaimSession, - fetchClaimDetail, - fetchClaimSessionSummary, - refreshClaimSession, - removeClaimSession, - redeemClaim, -} from '@/services/claim' -import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { - useSessionPolling, - useSessionQrDisplay, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, - mergeSessionDetail, - shouldRefreshQrImage, - shouldKeepPolling, - DEFAULT_LOGIN_TYPE, -} from './shared' - -export function useClaimPage(token: string) { - const detailLoading = ref(true) - const sessionLoading = ref(false) - const redeemLoading = ref(false) - const roleConfirmLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - const detail = ref(null) - const roleConfirmed = ref(false) - - // ── Derived state ────────────────────────────────────────────────── - const session = computed(() => detail.value?.session || null) - const task = computed(() => detail.value?.task || null) - const order = computed(() => detail.value?.order || null) - const orderItem = computed(() => detail.value?.orderItem || null) - const result = computed(() => detail.value?.result || null) - const tokenStatus = computed(() => detail.value?.tokenStatus || 'active') - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - - // ── Polling ──────────────────────────────────────────────────────── - const { - pollWarningMessage, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } = useSessionPolling({ - hasSession, - shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)), - onPollTick: async () => { await refreshSessionSummary({ silent: true }) }, - pollWarningPrefix: '领取状态刷新失败', - }) - - const sessionNotice = computed( - () => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '', - ) - - // ── QR display ───────────────────────────────────────────────────── - const { qrImage, qrFigureStyle, qrPreviewWidth, handleQrImageLoad } = useSessionQrDisplay({ - session, - }) - - // ── Presentation ─────────────────────────────────────────────────── - const screenshotUrl = computed(() => result.value?.screenshotUrl || '') - const showScreenshot = computed(() => Boolean(screenshotUrl.value)) - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const statusLabel = computed(() => - resolveTaskStatusLabel(task.value?.status, session.value?.status, 'claim'), - ) - const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) - const scanInstruction = computed( - () => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`, - ) - - // Claim pages show 4 role facts, but original only had 3 (no area). - // Override roleFacts to match original exactly: - const claimRoleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待扫码登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '任务状态', - value: statusLabel.value, - }, - ]) - - const { resultFacts } = useSessionResultFacts({ - order, - orderItem, - result, - }) - - const canConfirmRole = computed(() => - Boolean( - task.value && - !task.value.requiresSupportReview && - hasSession.value && - roleReady.value && - task.value.status === 'claimed', - ), - ) - const canRedeem = computed(() => - Boolean( - task.value && - !task.value.requiresSupportReview && - hasSession.value && - roleConfirmed.value && - !redeemLoading.value && - (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'), - ), - ) - const redeemBlockedReason = computed(() => - buildRedeemBlockedReason({ - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context: 'claim', - tokenStatus, - requiresSupportReview: computed(() => Boolean(task.value?.requiresSupportReview)), - }), - ) - const redeemButtonLabel = computed(() => - task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换', - ) - const screenshotEmptyTitle = computed(() => - task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', - ) - const screenshotEmptyMessage = computed(() => - task.value?.status === 'redeemed' - ? '本次兑换可能未生成截图,或截图产物还未同步完成。' - : '领取完成后,如本次生成了结果截图,这里会展示。', - ) - - // ── Watchers ─────────────────────────────────────────────────────── - watch( - () => - [ - task.value?.taskId || '', - activityInfo.value?.nickname || '', - activityInfo.value?.role?.roleId || '', - activityInfo.value?.role?.roleName || '', - activityInfo.value?.role?.area || '', - activityInfo.value?.role?.partition || '', - ].join('|'), - () => { - roleConfirmed.value = Boolean( - task.value?.status === 'role_confirmed' || - task.value?.status === 'redeeming' || - task.value?.status === 'redeemed', - ) - }, - { immediate: true }, - ) - - // ── Helpers ──────────────────────────────────────────────────────── - function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) { - loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value) - } - - function restartPollingIfNeeded(nextDetail = detail.value) { - resetPolling() - - if (shouldKeepPolling(nextDetail)) { - startPolling() - } - } - - // ── API flows ───────────────────────────────────────────────────── - async function loadDetail() { - detailLoading.value = true - - try { - const response = await fetchClaimDetail(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - restartPollingIfNeeded() - } catch (error) { - notifyTencentActionError(error, '领取详情加载失败') - } finally { - detailLoading.value = false - } - } - - async function createSessionFlow(nextLoginType = loginType.value) { - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - loginType.value = nextLoginType - const response = await createClaimSession(token, nextLoginType, { - forceRecreate: hasSession.value, - }) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建领取会话失败') - } finally { - sessionLoading.value = false - } - } - - async function reloadSessionPage() { - if (!hasSession.value) { - return false - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await refreshClaimSession(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - restartPollingIfNeeded(response.data) - return true - } catch (error) { - notifyTencentActionError(error, '刷新后端页面失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - return false - } finally { - sessionLoading.value = false - } - } - - async function closeSessionFlow({ silent = false } = {}) { - if (!hasSession.value) { - return false - } - - if (!silent) { - sessionLoading.value = true - } - - resetPolling() - resetPollingWarning() - - try { - const response = await removeClaimSession(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - - if (!silent) { - showSuccess(response.msg || '领取会话已关闭') - } - - return true - } catch (error) { - if (!silent) { - notifyTencentActionError(error, '关闭领取会话失败') - } - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function refreshSessionSummary({ silent = false } = {}) { - if (!hasSession.value) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = detail.value?.session || null - const response = await fetchClaimSessionSummary(token) - - let nextDetail = mergeSessionDetail(detail.value, response.data) - detail.value = nextDetail - applyLoginTypeFromDetail(nextDetail) - - if (shouldRefreshQrImage(currentSession, response.data.session)) { - const fullResponse = await fetchClaimDetail(token) - - nextDetail = mergeSessionDetail(nextDetail, fullResponse.data) - detail.value = nextDetail - applyLoginTypeFromDetail(nextDetail) - } - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyTencentActionError(error, '领取会话状态刷新失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function confirmRoleNow() { - if (!canConfirmRole.value) { - return - } - - roleConfirmLoading.value = true - - try { - const response = await confirmClaimRole(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - roleConfirmed.value = true - showSuccess(response.msg || '角色已确认') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '角色确认失败') - await refreshSessionSummary({ silent: true }) - } finally { - roleConfirmLoading.value = false - } - } - - async function redeemNow() { - if (!canRedeem.value) { - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemClaim(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - showSuccess(response.msg || '兑换完成') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '领取兑换失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - } finally { - redeemLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - if (hasSession.value) { - await createSessionFlow(nextLoginType) - return - } - - loginType.value = nextLoginType - } - - // ── Lifecycle ────────────────────────────────────────────────────── - loadDetail() - - onBeforeUnmount(() => { - resetPolling() - }) - - return { - detailLoading, - sessionLoading, - redeemLoading, - roleConfirmLoading, - loginType, - loginTypeLabel, - loginTabs, - detail, - task, - order, - orderItem, - activityInfo, - hasSession, - qrImage, - qrFigureStyle, - qrPreviewWidth, - statusLabel, - session, - sessionNotice, - roleFacts: claimRoleFacts, - resultFacts, - roleConfirmed, - roleReady, - canConfirmRole, - canRedeem, - redeemBlockedReason, - redeemButtonLabel, - initButtonLabel, - scanInstruction, - screenshotEmptyTitle, - screenshotEmptyMessage, - screenshotUrl, - showScreenshot, - createSessionFlow, - reloadSessionPage, - closeSessionFlow, - refreshSessionSummary, - switchLoginType, - confirmRoleNow, - redeemNow, - handleQrImageLoad, - } -} diff --git a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts deleted file mode 100644 index 9070d492..00000000 --- a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { computed, onBeforeUnmount, ref, watch } from 'vue' - -import { showError, showSuccess } from '@/lib/feedback' -import { - createTencentBrowserSession, - refreshTencentBrowserSessionPage, - removeTencentBrowserSession, -} from '@/services/tencent/session' -import { redeemTencentBrowserSession } from '@/services/tencent/redeem' -import type { TencentBrowserSessionData, TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { useTencentBrowserSessionPolling } from './tencent/useTencentBrowserSessionPolling' -import { useTencentBrowserSessionPresentation } from './tencent/useTencentBrowserSessionPresentation' - -const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' - -export function useTencentBrowserSessionPage() { - const sessionLoading = ref(false) - const redeemLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - const session = ref(null) - const redeemCode = ref('') - const maxAttempts = ref(6) - const roleConfirmed = ref(false) - - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const { - refreshSession, - resetPolling, - resetPollingWarning, - restartPollingIfActive, - sessionNotice, - startPolling, - } = useTencentBrowserSessionPolling({ - session, - sessionLoading, - notifyActionError: notifyTencentActionError, - }) - - const { - canRedeem, - initButtonLabel, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - resultFacts, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - statusLabel, - } = useTencentBrowserSessionPresentation({ - activityInfo, - hasSession, - loginTypeLabel, - redeemLoading, - roleConfirmed, - roleReady, - session, - sessionLoading, - sessionNotice, - }) - - watch( - () => - [ - session.value?.sessionId || '', - activityInfo.value?.nickname || '', - activityInfo.value?.role?.roleId || '', - activityInfo.value?.role?.roleName || '', - activityInfo.value?.role?.area || '', - activityInfo.value?.role?.partition || '', - ].join('|'), - () => { - roleConfirmed.value = false - }, - ) - - async function createSessionFlow(nextLoginType = loginType.value) { - resetPolling() - resetPollingWarning() - sessionLoading.value = true - - try { - loginType.value = nextLoginType - await teardownSession() - - const response = await createTencentBrowserSession({ - loginType: nextLoginType, - }) - - session.value = response.data - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建浏览器会话失败') - } finally { - sessionLoading.value = false - } - } - - async function reloadSessionPage() { - if (!session.value?.sessionId) { - return - } - - resetPolling() - resetPollingWarning() - sessionLoading.value = true - - try { - const response = await refreshTencentBrowserSessionPage(session.value.sessionId) - - session.value = response.data - restartPollingIfActive(response.data) - } catch (error) { - notifyTencentActionError(error, '刷新后端页面失败') - await refreshSession({ silent: true }) - restartPollingIfActive() - } finally { - sessionLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - loginType.value = nextLoginType - - if (session.value?.sessionId) { - await createSessionFlow(nextLoginType) - } - } - - async function redeemNow() { - if (!session.value?.sessionId) { - showError('请先创建浏览器会话') - return - } - - if (!redeemCode.value.trim()) { - showError('请输入兑换码') - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemTencentBrowserSession(session.value.sessionId, { - code: redeemCode.value.trim(), - maxAttempts: maxAttempts.value, - }) - - session.value = response.data - showSuccess(response.msg || '兑换完成') - } catch (error) { - notifyTencentActionError(error, '浏览器兑换失败') - await refreshSession({ silent: true }) - } finally { - redeemLoading.value = false - } - } - - async function teardownSession() { - if (!session.value?.sessionId) { - return - } - - const sessionId = session.value.sessionId - session.value = null - - try { - await removeTencentBrowserSession(sessionId) - } catch { - // ignore close failures on local teardown - } - } - - onBeforeUnmount(() => { - resetPolling() - void teardownSession() - }) - - return { - activityInfo, - canRedeem, - teardownSession, - createSessionFlow, - hasSession, - initButtonLabel, - loginTabs, - loginType, - loginTypeLabel, - maxAttempts, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - redeemCode, - redeemLoading, - redeemNow, - reloadSessionPage, - resultFacts, - roleConfirmed, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - session, - sessionLoading, - sessionNotice, - statusLabel, - switchLoginType, - } -} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index db9adcc0..1eead01f 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -1,22 +1,12 @@ import { createRouter, createWebHashHistory } from 'vue-router' import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' -const isKuaishouMode = import.meta.env.VITE_APP_MODE === 'kuaishou' - const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', - redirect: isKuaishouMode ? '/admin/dashboard' : '/tx/browser', - }, - { - path: '/tx', - redirect: '/tx/browser', - }, - { - path: '/tx/browser', - component: () => import('@/views/tx/TencentBrowserView.vue'), + redirect: '/admin/dashboard', }, { path: '/claim/:token', @@ -59,10 +49,6 @@ const router = createRouter({ path: 'tasks', component: () => import('@/views/admin/tasks/AdminTasksView.vue'), }, - { - path: 'manual-redeem', - component: () => import('@/views/admin/manual-redeem/AdminManualRedeemView.vue'), - }, { path: 'tasks/:taskId', component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'), diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 5c0cdc09..11902e5f 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -6,6 +6,5 @@ export * from './platform-config' export * from './orders' export * from './tasks' export * from './inventory' -export * from './manual-redeem' export * from './message-deliveries' export * from './webhook-events' diff --git a/apps/frontend/src/services/admin/manual-redeem.ts b/apps/frontend/src/services/admin/manual-redeem.ts deleted file mode 100644 index 6941c52f..00000000 --- a/apps/frontend/src/services/admin/manual-redeem.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' -import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin' - -export function fetchAdminManualRedeemSkuSuggestions(params?: Record) { - return apiGet<{ items: AdminInventorySkuSuggestion[] }>( - '/api/v1/admin/manual-redeem/sku-suggestions', - params, - ) -} - -export function createAdminManualRedeemTask(payload: { - proofValue: string - skuCode: string - skuName?: string - remark?: string -}) { - return apiPost('/api/v1/admin/manual-redeem', payload) -} - -export function fetchAdminManualRedeemDetail(taskId: number | string) { - return apiGet(`/api/v1/admin/manual-redeem/${taskId}`) -} - -export function createAdminManualRedeemSession( - taskId: number | string, - payload: { loginType: string; forceRecreate?: boolean }, -) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session`, payload) -} - -export function fetchAdminManualRedeemSessionSummary(taskId: number | string) { - return apiGet(`/api/v1/admin/manual-redeem/${taskId}/session/summary`) -} - -export function refreshAdminManualRedeemSession(taskId: number | string) { - return apiPost( - `/api/v1/admin/manual-redeem/${taskId}/session/refresh`, - {}, - ) -} - -export function removeAdminManualRedeemSession(taskId: number | string) { - return apiDelete(`/api/v1/admin/manual-redeem/${taskId}/session`) -} - -export function confirmAdminManualRedeemRole(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {}) -} - -export function redeemAdminManualRedeem(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {}) -} - -export function closeAdminManualRedeemTask(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/close`, {}) -} diff --git a/apps/frontend/src/services/admin/platform-config/agiso.ts b/apps/frontend/src/services/admin/platform-config/agiso.ts deleted file mode 100644 index cc7ee38e..00000000 --- a/apps/frontend/src/services/admin/platform-config/agiso.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, - AdminAgisoShopConfigItem, -} from '@/types/admin' - -export function fetchAdminAgisoShopConfigs() { - return apiGet<{ - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] - observedShops: AdminAgisoObservedShopItem[] - }>('/api/v1/admin/platform-config/agiso-shops') -} - -export function saveAdminAgisoShopConfigs(payload: { - defaults: AdminAgisoMessagingDefaults - shops: Array> -}) { - return apiPost<{ - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] - }>('/api/v1/admin/platform-config/agiso-shops', payload) -} diff --git a/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 918f347b..00000000 --- a/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminFulfillmentBindingConfigItem, - AdminFulfillmentLookupResult, - AdminObservedProductItem, -} from '@/types/admin' - -export function fetchAdminFulfillmentBindingConfigs() { - return apiGet<{ - filePath: string - bindings: AdminFulfillmentBindingConfigItem[] - observedProducts: AdminObservedProductItem[] - }>('/api/v1/admin/platform-config/fulfillment-bindings') -} - -export function lookupAdminFulfillmentBindingOrder(payload: { - provider?: string - platform?: string - shopId: string - platformOrderId: string -}) { - return apiPost( - '/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', - payload, - ) -} - -export function saveAdminFulfillmentBindingConfigs(payload: { - bindings: Array> -}) { - return apiPost<{ - filePath: string - bindings: AdminFulfillmentBindingConfigItem[] - }>('/api/v1/admin/platform-config/fulfillment-bindings', payload) -} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index f7260eb5..3a29bf1b 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -1,8 +1,6 @@ -export * from './agiso' export * from './notifications' export * from './scheduled-jobs' export * from './ninetyone' export * from './kuaishou-eticket' export * from './cloudtentacles' -export * from './fulfillment-bindings' export * from './kuaishou-cloud-fulfillment' diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index ddeb6d58..ad6f1899 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -1,26 +1,10 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' +import { apiGet, apiPost } from '@/lib/http' import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' export function fetchClaimDetail(token: string) { return apiGet(`/api/v1/claim/${token}`) } -export function createClaimSession( - token: string, - loginType: TencentLoginType, - options: { forceRecreate?: boolean } = {}, -) { - return apiPost(`/api/v1/claim/${token}/session`, { - loginType, - forceRecreate: Boolean(options.forceRecreate), - }) -} - -export function fetchClaimSessionSummary(token: string) { - return apiGet(`/api/v1/claim/${token}/session/summary`) -} - export function verifyKuaishouCloudClaimTicket( token: string, payload: { @@ -37,19 +21,3 @@ export function confirmKuaishouCloudClaimRole(token: string) { export function redeemKuaishouCloudClaim(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {}) } - -export function refreshClaimSession(token: string) { - return apiPost(`/api/v1/claim/${token}/session/refresh`, {}) -} - -export function removeClaimSession(token: string) { - return apiDelete(`/api/v1/claim/${token}/session`) -} - -export function confirmClaimRole(token: string) { - return apiPost(`/api/v1/claim/${token}/confirm-role`, { confirm: true }) -} - -export function redeemClaim(token: string) { - return apiPost(`/api/v1/claim/${token}/redeem`, {}) -} diff --git a/apps/frontend/src/services/tencent/artifacts.ts b/apps/frontend/src/services/tencent/artifacts.ts deleted file mode 100644 index 0387e34d..00000000 --- a/apps/frontend/src/services/tencent/artifacts.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function buildTencentBrowserSessionScreenshotUrl(sessionId: string, cacheKey = '') { - const suffix = cacheKey ? `?t=${encodeURIComponent(cacheKey)}` : '' - return `/api/v1/tencent/browser/session/${sessionId}/screenshot${suffix}` -} diff --git a/apps/frontend/src/services/tencent/redeem.ts b/apps/frontend/src/services/tencent/redeem.ts deleted file mode 100644 index f63ad7c9..00000000 --- a/apps/frontend/src/services/tencent/redeem.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { apiPost } from '@/lib/http' -import type { TencentBrowserSessionData } from '@/types/tencent/session' - -export interface TencentBrowserRedeemPayload { - code: string - maxAttempts?: number -} - -export function redeemTencentBrowserSession( - sessionId: string, - payload: TencentBrowserRedeemPayload, -) { - return apiPost( - `/api/v1/tencent/browser/session/${sessionId}/redeem`, - payload, - ) -} diff --git a/apps/frontend/src/services/tencent/session.ts b/apps/frontend/src/services/tencent/session.ts deleted file mode 100644 index a1864052..00000000 --- a/apps/frontend/src/services/tencent/session.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' -import type { - TencentBrowserSessionData, - TencentBrowserSessionSummaryData, - TencentLoginType, -} from '@/types/tencent/session' - -export interface CreateTencentBrowserSessionPayload { - loginType?: TencentLoginType -} - -export function createTencentBrowserSession(payload?: CreateTencentBrowserSessionPayload) { - return apiPost( - '/api/v1/tencent/browser/session', - payload as Record | undefined, - ) -} - -export function fetchTencentBrowserSession(sessionId: string) { - return apiGet(`/api/v1/tencent/browser/session/${sessionId}`) -} - -export function fetchTencentBrowserSessionSummary(sessionId: string) { - return apiGet( - `/api/v1/tencent/browser/session/${sessionId}/summary`, - ) -} - -export function refreshTencentBrowserSessionPage(sessionId: string) { - return apiPost(`/api/v1/tencent/browser/session/${sessionId}/refresh`) -} - -export function removeTencentBrowserSession(sessionId: string) { - return apiDelete>(`/api/v1/tencent/browser/session/${sessionId}`) -} diff --git a/apps/frontend/src/styles/admin-config-pages.css b/apps/frontend/src/styles/admin-config-pages.css index a582ab35..80a06eca 100644 --- a/apps/frontend/src/styles/admin-config-pages.css +++ b/apps/frontend/src/styles/admin-config-pages.css @@ -1,6 +1,6 @@ /* ============================================================ 管理员配置页面共享结构样式 - 引入: AdminFulfillmentBindings.css, AdminKuaishouCloudFulfillment.css + 引入: AdminKuaishouCloudFulfillment.css 各页面自行覆盖颜色、圆角、主题属性 ============================================================ */ diff --git a/apps/frontend/src/types/admin/fulfillment.ts b/apps/frontend/src/types/admin/fulfillment.ts deleted file mode 100644 index 8f674424..00000000 --- a/apps/frontend/src/types/admin/fulfillment.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AdminObservedProductItem } from './platform-config/fulfillment-bindings' - -export interface AdminFulfillmentLookupOrder { - provider: string - platform: string - shopId: string - shopName: string - platformOrderId: string - buyerName: string - totalAmount: string - totalAmountFen: number - paidAt: string | null - enriched: boolean - enrichReason: string - errorMessage: string -} - -export interface AdminFulfillmentLookupItem extends AdminObservedProductItem { - lineId: string - itemTitle: string - quantity: number -} - -export interface AdminFulfillmentLookupResult { - order: AdminFulfillmentLookupOrder - items: AdminFulfillmentLookupItem[] -} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 5c4c2761..c354ca19 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -34,9 +34,6 @@ export type { // Inventory types export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory' -// Manual redeem types -export type { AdminManualRedeemDetail } from './manual-redeem' - // Webhook events types export type { AdminWebhookEventListItem, @@ -47,18 +44,8 @@ export type { // Message deliveries types export type { AdminMessageDeliveryListItem } from './message-deliveries' -// Fulfillment types -export type { - AdminFulfillmentLookupOrder, - AdminFulfillmentLookupItem, - AdminFulfillmentLookupResult, -} from './fulfillment' - // Platform config types export type { - AdminAgisoShopConfigItem, - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, AdminNotificationBarkRecipient, AdminNotificationWpushRecipient, AdminNotificationConfig, @@ -92,8 +79,6 @@ export type { AdminCloudtentaclesValidateSessionResult, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSkuListResult, - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, AdminKuaishouCloudFulfillmentItem, AdminKuaishouCloudFulfillmentConfig, } from './platform-config' diff --git a/apps/frontend/src/types/admin/manual-redeem.ts b/apps/frontend/src/types/admin/manual-redeem.ts deleted file mode 100644 index 026f53e0..00000000 --- a/apps/frontend/src/types/admin/manual-redeem.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ClaimDetailData } from '../claim' - -export interface AdminManualRedeemDetail extends ClaimDetailData { - manualRequest: { - sourceType: string - proofValue: string - remark: string - } -} diff --git a/apps/frontend/src/types/admin/platform-config/agiso.ts b/apps/frontend/src/types/admin/platform-config/agiso.ts deleted file mode 100644 index 7288a5e1..00000000 --- a/apps/frontend/src/types/admin/platform-config/agiso.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface AdminAgisoShopConfigItem { - shopId: string - shopName: string - AccessToken: string - accessTokenMasked: string - enabled: boolean | null - messageTemplate: string - autoDeliveryMessageTemplate: string - appSecretConfigured: boolean - apiVersion: string - sendMessageEndpoint: string -} - -export interface AdminAgisoMessagingDefaults { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export interface AdminAgisoObservedShopItem { - shopId: string - detectedShopName: string - displayShopName: string - latestSeenAt: string | null - webhookEventCount: number - configured: boolean -} diff --git a/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 3aa383ea..00000000 --- a/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface AdminFulfillmentBindingConfigItem { - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - config: Record - match: { - externalSkuCode: string - externalItemId: string - externalSkuName: string - config: Record - } -} - -export interface AdminObservedProductItem { - provider: string - platform: string - shopId: string - shopName: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - latestSeenAt: string | null - orderItemCount: number - configured: boolean - matchedBinding: null | { - skuCode: string - skuName: string - profileKey: string - } -} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 62af5fb4..bdded7ba 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -1,9 +1,3 @@ -export type { - AdminAgisoShopConfigItem, - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, -} from './agiso' - export type { AdminNotificationBarkRecipient, AdminNotificationWpushRecipient, @@ -52,11 +46,6 @@ export type { AdminCloudtentaclesSkuListResult, } from './cloudtentacles' -export type { - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, -} from './fulfillment-bindings' - export type { AdminKuaishouCloudFulfillmentItem, AdminKuaishouCloudFulfillmentConfig, diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 59b7b087..e13ffc6b 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -1,5 +1,3 @@ -import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session' - export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {}) export type ClaimTaskStatus = @@ -128,11 +126,11 @@ export interface ClaimKuaishouCloudFlowInfo { export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string - flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {}) + flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {}) task: ClaimTaskInfo order: ClaimOrderInfo orderItem: ClaimOrderItemInfo - session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null + session: unknown | null kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null result: ClaimResultInfo | null } diff --git a/apps/frontend/src/types/tencent/activity.ts b/apps/frontend/src/types/tencent/activity.ts deleted file mode 100644 index dd66aac3..00000000 --- a/apps/frontend/src/types/tencent/activity.ts +++ /dev/null @@ -1,51 +0,0 @@ -export interface TencentBrowserSessionArtifacts { - hasQrImage: boolean - hasScreenshot: boolean -} - -export interface TencentBrowserReviewInfo { - capturedAt: string - roleId: string - roleName: string -} - -export interface TencentBrowserRoleInfo { - ready: boolean - roleName: string - roleId: string - area: string - partition: string - platId: string - md5str: string - checkparam: string -} - -export interface TencentBrowserFormInfo { - cdkeyInputId: string - cdkeyValue: string - verifyInputId: string - verifyValue: string - verifyImgId: string - submitId: string -} - -export interface TencentBrowserVerifyInfo { - visible: boolean - src: string - naturalWidth: number - naturalHeight: number -} - -export interface TencentBrowserPopupInfo { - visible: boolean - text: string - detail: string -} - -export interface TencentBrowserActivityInfo { - nickname: string - role: TencentBrowserRoleInfo - form: TencentBrowserFormInfo - verify: TencentBrowserVerifyInfo - popup: TencentBrowserPopupInfo -} diff --git a/apps/frontend/src/types/tencent/redeem.ts b/apps/frontend/src/types/tencent/redeem.ts deleted file mode 100644 index 4df77a64..00000000 --- a/apps/frontend/src/types/tencent/redeem.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface TencentBrowserRedeemAttempt { - attempt: number - redeem?: Record | null -} - -export interface TencentBrowserRedeemResult { - code: string - area: string - attempts: TencentBrowserRedeemAttempt[] - final?: TencentBrowserRedeemAttempt | null - finishedAt: string -} diff --git a/apps/frontend/src/types/tencent/session.ts b/apps/frontend/src/types/tencent/session.ts deleted file mode 100644 index 908bd2f0..00000000 --- a/apps/frontend/src/types/tencent/session.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - TencentBrowserActivityInfo, - TencentBrowserReviewInfo, - TencentBrowserSessionArtifacts, -} from './activity' -import type { TencentBrowserRedeemResult } from './redeem' - -export type TencentLoginType = 'qq' | 'wx' - -export type TencentBrowserSessionStatus = - | 'waiting_scan' - | 'scanned' - | 'expired' - | 'logged_in' - | 'ready_to_redeem' - | 'redeeming' - | 'redeemed' - | 'closed' - | 'failed' - | (string & {}) - -export interface TencentBrowserSessionData { - sessionId: string - loginType: TencentLoginType | string - activityUrl: string - status: TencentBrowserSessionStatus - notice: string - createdAt: string - updatedAt: string - expiresAt: string - qrImageBase64: string - qrUpdatedAt: string - credentialReady: boolean - activityInfo?: TencentBrowserActivityInfo | null - review?: TencentBrowserReviewInfo | null - redeem?: TencentBrowserRedeemResult | null - artifacts: TencentBrowserSessionArtifacts -} - -export interface TencentBrowserSessionSummaryData extends Omit< - TencentBrowserSessionData, - 'qrImageBase64' -> { - qrImageBase64?: string -} diff --git a/apps/frontend/src/views/NotFoundView.vue b/apps/frontend/src/views/NotFoundView.vue index d6f04c77..50882789 100644 --- a/apps/frontend/src/views/NotFoundView.vue +++ b/apps/frontend/src/views/NotFoundView.vue @@ -3,8 +3,8 @@

404

页面不存在

-

当前前端只保留腾讯浏览器兑换页,这个地址没有对应页面。

- 前往兑换入口 +

当前快手轻量前端没有对应页面。

+ 前往后台
diff --git a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue index b2f58824..e4e151b6 100644 --- a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue +++ b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue @@ -1,45 +1,15 @@ @@ -48,21 +18,4 @@ const activeTab = computed({ display: grid; gap: var(--space-4); } - -:deep(.hub-tabs .el-tabs__header) { - margin-bottom: 0; -} - -:deep(.hub-tabs .el-tabs__content) { - padding-top: var(--space-4); -} - -:deep(.hub-tabs .el-tabs__item) { - font-weight: 500; - font-size: var(--text-base); -} - -:deep(.hub-tabs .el-tabs__item.is-active) { - font-weight: 600; -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css deleted file mode 100644 index fbccea8a..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css +++ /dev/null @@ -1,290 +0,0 @@ -.admin-panel { - display: grid; - gap: var(--space-4); -} - -.section-card { - border-radius: var(--radius-lg); - border: 1px solid var(--border-default); - background: var(--bg-surface); -} - -.section-card :deep(.el-card__header) { - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); - padding: var(--space-4) var(--space-5); -} - -.section-card :deep(.el-card__body) { - padding: var(--space-5); -} - -.card-header { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.card-header--split { - flex-direction: row; - justify-content: space-between; - align-items: flex-start; -} - -.card-title { - display: block; - font-size: var(--text-lg); - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--space-1); -} - -.card-desc { - display: block; - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: 1.5; -} - -.overview-card { - display: grid; - gap: var(--space-5); -} - -.overview-file { - margin-top: var(--space-1); -} - -.overview-file code { - color: var(--text-primary); - word-break: break-all; - font-size: var(--text-sm); -} - -.overview-stats { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-4); -} - -.overview-stat { - min-width: 0; - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - background: var(--bg-surface); -} - -.overview-stat :deep(.el-statistic__head) { - margin-bottom: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.overview-stat :deep(.el-statistic__content) { - color: var(--text-primary); - font-size: var(--text-2xl); - font-weight: 700; -} - -.overview-notes, -.section-mini-stats { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.lookup-toolbar, -.lookup-summary { - display: grid; - gap: var(--space-4); -} - -.lookup-toolbar { - grid-template-columns: 96px 110px minmax(140px, 1fr) minmax(180px, 1.2fr) 136px; - align-items: end; -} - -.lookup-summary { - margin-top: var(--space-1); -} - -.field-help { - display: block; - margin-top: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.6; -} - -.field-wide { - grid-column: 1 / -1; -} - -.text-input { - width: 100%; -} - -.admin-panel :deep(.text-input--invalid .el-input__wrapper), -.admin-panel :deep(.text-input--invalid .el-select__wrapper) { - border-color: var(--color-danger); - box-shadow: 0 0 0 1px var(--color-danger) inset; -} - -.admin-panel :deep(.el-form-item) { - margin-bottom: 0; -} - -.admin-panel :deep(.el-form-item__label) { - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: 600; - line-height: 1.5; - margin-bottom: var(--space-2); -} - -.cell-subtle { - color: var(--text-secondary); - display: block; - font-size: var(--text-xs); - line-height: 1.6; -} - -.cell-stack { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.binding-card { - margin-top: var(--space-3); - background: var(--bg-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.binding-card :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); -} - -.binding-card :deep(.el-card__body) { - display: grid; - gap: var(--space-4); - padding: var(--space-4); -} - -.binding-card--invalid { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08); -} - -.binding-header { - display: flex; - justify-content: space-between; - gap: var(--space-4); - align-items: flex-start; -} - -.binding-summary { - display: grid; - gap: var(--space-2); - flex: 1; -} - -.binding-title-row { - display: flex; - gap: var(--space-2); - align-items: center; - flex-wrap: wrap; - color: var(--text-primary); - font-size: var(--text-base); -} - -.binding-subtle { - color: var(--text-secondary); - font-size: var(--text-sm); - line-height: 1.5; -} - -.binding-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4) var(--space-5); -} - -.binding-collapsed-preview { - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.binding-collapsed-preview :deep(.el-descriptions__label) { - font-weight: 600; -} - -.binding-actions { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: center; - padding-top: var(--space-3); - border-top: 1px solid var(--border-default); -} - -@media (max-width: 900px) { - .card-header--split, - .binding-actions, - .binding-header { - display: grid; - grid-template-columns: 1fr; - align-items: stretch; - gap: var(--space-3); - } - - .overview-stats, - .lookup-toolbar, - .binding-grid { - grid-template-columns: 1fr; - } - - .binding-collapsed-preview :deep(.el-descriptions__body .el-descriptions__table) { - display: block; - } - - .section-card :deep(.el-card__header), - .section-card :deep(.el-card__body) { - padding: var(--space-4); - } - - .binding-card :deep(.el-card__header), - .binding-card :deep(.el-card__body) { - padding: var(--space-3); - } -} - -.data-table { - margin-top: var(--space-4); -} - -.data-table :deep(.el-table__header th) { - background: var(--bg-surface); - color: var(--text-secondary); - font-weight: 600; - font-size: var(--text-sm); -} - -.data-table :deep(.el-table__body td) { - padding: var(--space-3) var(--space-2); -} - -.mt-4 { - margin-top: var(--space-4); -} - -.w-full { - width: 100%; -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue deleted file mode 100644 index a3cf2b6e..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue deleted file mode 100644 index 5f10455c..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue +++ /dev/null @@ -1,213 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue deleted file mode 100644 index 397a2f38..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue deleted file mode 100644 index d1fe9e15..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue deleted file mode 100644 index 8c711113..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue deleted file mode 100644 index a021340e..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts deleted file mode 100644 index bc5c17c0..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AdminObservedProductItem } from '@/types/admin' - -export type EditableBinding = { - id: string - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - externalSkuCode: string - externalItemId: string - externalSkuName: string - resolvedSkuName: string -} - -export type SaveBindingPayload = { - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - match: { - externalSkuCode: string - externalItemId: string - externalSkuName: string - config: { - resolvedSkuName: string - } - } -} - -export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' - -export type ValidationState = { - bindingId: string - field: ValidationField - message: string -} | null - -export type ImportableProductCandidate = Pick< - AdminObservedProductItem, - | 'provider' - | 'platform' - | 'shopId' - | 'shopName' - | 'externalSkuCode' - | 'externalItemId' - | 'externalSkuName' -> & {} - -export const PROFILE_OPTIONS = [ - { label: '腾讯领取兑换', value: 'tencent_claim_redeem' }, - { label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' }, - { label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' }, - { label: '人工发货', value: 'manual_review' }, -] - -export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value)) diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts deleted file mode 100644 index b75019d8..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { computed, nextTick, ref } from 'vue' - -import { showSuccess } from '@/lib/feedback' -import { - fetchAdminFulfillmentBindingConfigs, - saveAdminFulfillmentBindingConfigs, -} from '@/services/admin' -import type { - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, -} from '@/types/admin' -import { hasAdminRole } from '@/utils/admin-auth' - -import { - VALID_PROFILE_KEYS, - type EditableBinding, - type ImportableProductCandidate, - type SaveBindingPayload, - type ValidationField, - type ValidationState, -} from './types' - -export function useFulfillmentBindings() { - const loading = ref(true) - const saving = ref(false) - const errorMessage = ref('') - const filePath = ref('') - const bindings = ref([]) - const observedProducts = ref([]) - const validationState = ref(null) - const collapsedBindingIds = ref([]) - - // ── computed ────────────────────────────────────────── - - const ruleMetrics = computed(() => { - const completedCount = bindings.value.filter(isEditableBindingComplete).length - const manualCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'manual_review', - ).length - const assistedCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'tencent_claim_assisted', - ).length - const kuaishouCloudCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'kuaishou_ct_assisted', - ).length - - return { - totalCount: bindings.value.length, - completedCount, - draftCount: Math.max(bindings.value.length - completedCount, 0), - manualCount, - assistedCount, - kuaishouCloudCount, - } - }) - - const pendingObservedCount = computed( - () => observedProducts.value.filter((item) => !item.configured).length, - ) - - // ── factories ──────────────────────────────────────── - - function createEmptyBinding(): EditableBinding { - return { - id: crypto.randomUUID(), - provider: 'agiso', - platform: '', - shopId: '', - shopName: '', - skuCode: '', - skuName: '', - profileKey: 'manual_review', - enabled: true, - priority: 100, - externalSkuCode: '', - externalItemId: '', - externalSkuName: '', - resolvedSkuName: '', - } - } - - function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding { - return { - id: crypto.randomUUID(), - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shopId, - shopName: item.shopName || '', - skuCode: item.skuCode, - skuName: item.skuName, - profileKey: item.profileKey || 'manual_review', - enabled: item.enabled !== false, - priority: item.priority || 100, - externalSkuCode: item.match.externalSkuCode, - externalItemId: item.match.externalItemId, - externalSkuName: item.match.externalSkuName, - resolvedSkuName: String(item.match.config?.resolvedSkuName || ''), - } - } - - // ── validation ──────────────────────────────────────── - - function hasMatchCondition( - item: Pick, - ) { - return Boolean( - item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(), - ) - } - - function isEditableBindingComplete(item: EditableBinding) { - return Boolean( - item.skuCode.trim() && - hasMatchCondition(item) && - VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'), - ) - } - - // ── collapse state ──────────────────────────────────── - - function buildCollapsedIds(items: EditableBinding[]) { - return items.filter(isEditableBindingComplete).map((item) => item.id) - } - - function isBindingCollapsed(bindingId: string) { - return collapsedBindingIds.value.includes(bindingId) - } - - function setBindingCollapsed(bindingId: string, collapsed: boolean) { - const next = new Set(collapsedBindingIds.value) - - if (collapsed) { - next.add(bindingId) - } else { - next.delete(bindingId) - } - - collapsedBindingIds.value = Array.from(next) - } - - function toggleBindingCollapsed(bindingId: string) { - setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId)) - } - - // ── display helpers ─────────────────────────────────── - - function getBindingTitle(binding: EditableBinding, index: number) { - const internalName = binding.skuName.trim() - const externalName = binding.externalSkuName.trim() - const skuCode = binding.skuCode.trim() - - return internalName || externalName || skuCode || `规则 ${index + 1}` - } - - function getBindingStatusLabel(binding: EditableBinding) { - return isEditableBindingComplete(binding) ? '已完成' : '待完善' - } - - function getBindingSummary(binding: EditableBinding) { - const parts = [ - binding.provider.trim() || 'agiso', - binding.platform.trim() || '未选平台', - binding.shopName.trim() || binding.shopId.trim() || '跨店铺', - binding.profileKey.trim() || 'manual_review', - ] - - return parts.join(' / ') - } - - // ── data loading ────────────────────────────────────── - - async function loadConfigs() { - if (!hasAdminRole('admin')) { - loading.value = false - return - } - - loading.value = true - errorMessage.value = '' - - try { - const response = await fetchAdminFulfillmentBindingConfigs() - filePath.value = response.data.filePath - const nextBindings = response.data.bindings.map(mapEditableBinding) - bindings.value = nextBindings - collapsedBindingIds.value = buildCollapsedIds(nextBindings) - observedProducts.value = response.data.observedProducts - } catch (error) { - errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败' - } finally { - loading.value = false - } - } - - // ── mutation ────────────────────────────────────────── - - function addBinding() { - validationState.value = null - const next = createEmptyBinding() - setBindingCollapsed(next.id, false) - bindings.value.unshift(next) - } - - function removeBinding(id: string) { - if (validationState.value?.bindingId === id) { - validationState.value = null - } - setBindingCollapsed(id, false) - bindings.value = bindings.value.filter((item) => item.id !== id) - } - - // ── import from observed products ──────────────────── - - function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding { - const binding = { - id: crypto.randomUUID(), - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shopId, - shopName: item.shopName, - skuCode: '', - skuName: item.externalSkuName, - profileKey: 'manual_review', - enabled: true, - priority: 100, - externalSkuCode: item.externalSkuCode, - externalItemId: item.externalItemId, - externalSkuName: item.externalSkuName, - resolvedSkuName: item.externalSkuName, - } - - return binding - } - - function importObservedProduct(item: ImportableProductCandidate) { - validationState.value = null - const nextBinding = createBindingFromProductCandidate(item) - setBindingCollapsed(nextBinding.id, false) - bindings.value.unshift(nextBinding) - } - - // ── save ────────────────────────────────────────────── - - function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload { - return { - provider: item.provider.trim() || 'agiso', - platform: item.platform.trim(), - shopId: item.shopId.trim(), - shopName: item.shopName.trim(), - skuCode: item.skuCode.trim(), - skuName: item.skuName.trim(), - profileKey: item.profileKey.trim() || 'manual_review', - enabled: item.enabled, - priority: Number(item.priority || 100), - match: { - externalSkuCode: item.externalSkuCode.trim(), - externalItemId: item.externalItemId.trim(), - externalSkuName: item.externalSkuName.trim(), - config: { - resolvedSkuName: item.resolvedSkuName.trim(), - }, - }, - } - } - - function hasBindingContent(item: SaveBindingPayload) { - return Boolean( - item.platform || - item.shopId || - item.shopName || - item.skuCode || - item.skuName || - item.match.externalSkuCode || - item.match.externalItemId || - item.match.externalSkuName || - item.match.config.resolvedSkuName || - item.provider !== 'agiso' || - item.profileKey !== 'manual_review' || - item.priority !== 100 || - item.enabled !== true, - ) - } - - function resolveBindingValidationState( - item: SaveBindingPayload, - index: number, - bindingId: string, - ): ValidationState { - const label = `第 ${index + 1} 条规则` - - if (!item.skuCode) { - return { - bindingId, - field: 'skuCode', - message: `${label} 还没填写"内部履约 SKU"`, - } - } - - if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) { - return { - bindingId, - field: 'match', - message: `${label} 至少填写一种外部匹配条件`, - } - } - - if (!VALID_PROFILE_KEYS.has(item.profileKey)) { - return { - bindingId, - field: 'profileKey', - message: `${label} 的履约方式无效`, - } - } - - return null - } - - async function focusValidationTarget(state: ValidationState) { - if (!state?.bindingId) { - return - } - - setBindingCollapsed(state.bindingId, false) - await nextTick() - - const card = document.querySelector(`[data-binding-id="${state.bindingId}"]`) - if (!card) { - return - } - - card.scrollIntoView({ behavior: 'smooth', block: 'center' }) - - const selectors: Record = { - shopId: '[data-field="shopId"]', - skuCode: '[data-field="skuCode"]', - match: '[data-field="externalSkuCode"]', - profileKey: '[data-field="profileKey"]', - } - - const target = card.querySelector(selectors[state.field]) - target?.focus() - } - - function clearValidationState() { - validationState.value = null - } - - function isBindingInvalid(bindingId: string) { - return validationState.value?.bindingId === bindingId - } - - function isFieldInvalid( - bindingId: string, - field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName', - ) { - if (validationState.value?.bindingId !== bindingId) { - return false - } - - if (validationState.value.field === 'match') { - return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field) - } - - return validationState.value.field === field - } - - async function saveConfigs() { - const normalizedBindings = bindings.value.map(normalizeBindingForSave) - const nonEmptyBindings = normalizedBindings.filter(hasBindingContent) - const invalidState = normalizedBindings.reduce((state, item, index) => { - if (state || !hasBindingContent(item)) { - return state - } - - return resolveBindingValidationState(item, index, bindings.value[index]?.id || '') - }, null) - - if (invalidState) { - validationState.value = invalidState - await focusValidationTarget(invalidState) - return - } - - if (bindings.value.length > 0 && nonEmptyBindings.length === 0) { - validationState.value = { - bindingId: '', - field: 'skuCode', - message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件', - } - return - } - - const payload = nonEmptyBindings - - saving.value = true - validationState.value = null - - try { - const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload }) - filePath.value = response.data.filePath - bindings.value = response.data.bindings.map(mapEditableBinding) - showSuccess('履约配置已保存') - await loadConfigs() - } catch (error) { - validationState.value = { - bindingId: '', - field: 'skuCode', - message: error instanceof Error ? error.message : '保存履约配置失败', - } - } finally { - saving.value = false - } - } - - return { - // state - loading, - saving, - errorMessage, - filePath, - bindings, - observedProducts, - validationState, - collapsedBindingIds, - // computed - ruleMetrics, - pendingObservedCount, - // factories - createEmptyBinding, - mapEditableBinding, - // validation - isEditableBindingComplete, - clearValidationState, - isBindingInvalid, - isFieldInvalid, - // collapse - isBindingCollapsed, - setBindingCollapsed, - toggleBindingCollapsed, - // display - getBindingTitle, - getBindingStatusLabel, - getBindingSummary, - // data - loadConfigs, - addBinding, - removeBinding, - importObservedProduct, - createBindingFromProductCandidate, - normalizeBindingForSave, - saveConfigs, - } -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts deleted file mode 100644 index 6b99cead..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { reactive, ref } from 'vue' - -import { lookupAdminFulfillmentBindingOrder } from '@/services/admin' -import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin' - -import type { useFulfillmentBindings } from './useFulfillmentBindings' - -export function useFulfillmentBindingsLookup( - bindings: ReturnType, -) { - const lookupLoading = ref(false) - const lookupErrorMessage = ref('') - const lookupResult = ref(null) - const importedLookupLineIds = ref([]) - const lookupForm = reactive({ - shopId: '', - platformOrderId: '', - }) - - function importLookupProduct(item: AdminFulfillmentLookupItem) { - bindings.importObservedProduct(item) - if (!importedLookupLineIds.value.includes(item.lineId)) { - importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId] - } - } - - function isLookupProductImported(lineId: string) { - return importedLookupLineIds.value.includes(lineId) - } - - async function lookupOrderProducts() { - const shopId = lookupForm.shopId.trim() - const platformOrderId = lookupForm.platformOrderId.trim() - - if (!shopId || !platformOrderId) { - lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号' - return - } - - lookupLoading.value = true - lookupErrorMessage.value = '' - - try { - const response = await lookupAdminFulfillmentBindingOrder({ - provider: 'agiso', - platform: 'xianyu', - shopId, - platformOrderId, - }) - lookupResult.value = response.data - importedLookupLineIds.value = [] - } catch (error) { - lookupResult.value = null - lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败' - } finally { - lookupLoading.value = false - } - } - - return { - lookupLoading, - lookupErrorMessage, - lookupResult, - importedLookupLineIds, - lookupForm, - importLookupProduct, - isLookupProductImported, - lookupOrderProducts, - } -} diff --git a/apps/frontend/src/views/admin/layout/AdminLayout.vue b/apps/frontend/src/views/admin/layout/AdminLayout.vue index fd556e86..211db269 100644 --- a/apps/frontend/src/views/admin/layout/AdminLayout.vue +++ b/apps/frontend/src/views/admin/layout/AdminLayout.vue @@ -11,7 +11,6 @@ import { Fold, List, Message, - Service, Setting, SwitchButton, Tickets, @@ -63,10 +62,7 @@ const navItems = computed(() => { items.push({ to: '/admin/inventory', label: '库存', icon: Box }) } - items.push( - { to: '/admin/manual-redeem', label: '人工兑换', icon: Service }, - { to: '/admin/message-deliveries', label: '消息发送', icon: Message }, - ) + items.push({ to: '/admin/message-deliveries', label: '消息发送', icon: Message }) if (isAdmin.value || isOperator.value) { items.push({ to: '/admin/webhook-events', label: 'Webhook', icon: Connection }) diff --git a/apps/frontend/src/views/admin/manual-redeem/AdminManualRedeemView.vue b/apps/frontend/src/views/admin/manual-redeem/AdminManualRedeemView.vue deleted file mode 100644 index 23999b94..00000000 --- a/apps/frontend/src/views/admin/manual-redeem/AdminManualRedeemView.vue +++ /dev/null @@ -1,275 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemCreateCard.vue b/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemCreateCard.vue deleted file mode 100644 index 277e48ca..00000000 --- a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemCreateCard.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemReviewCard.vue b/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemReviewCard.vue deleted file mode 100644 index 255820d4..00000000 --- a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemReviewCard.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemSummaryCard.vue b/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemSummaryCard.vue deleted file mode 100644 index ef99cbfc..00000000 --- a/apps/frontend/src/views/admin/manual-redeem/components/AdminManualRedeemSummaryCard.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue index 72251d16..db3a162b 100644 --- a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue @@ -1,22 +1,19 @@ - - - - diff --git a/apps/frontend/src/views/admin/platform-shops/composables/types.ts b/apps/frontend/src/views/admin/platform-shops/composables/types.ts index 1f89f4a5..b62b5e67 100644 --- a/apps/frontend/src/views/admin/platform-shops/composables/types.ts +++ b/apps/frontend/src/views/admin/platform-shops/composables/types.ts @@ -1,25 +1,9 @@ export type PlatformTab = - | 'agiso' | 'notifications' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles' -export type EditableDefaults = { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export type EditableShop = { - id: string - shopId: string - shopName: string - accessToken: string - enabled: boolean - messageTemplate: string - autoDeliveryMessageTemplate: string -} - export type EditableKuaishouEticketShop = { id: string shopId: string diff --git a/apps/frontend/src/views/admin/platform-shops/composables/useAdminAgisoPlatform.ts b/apps/frontend/src/views/admin/platform-shops/composables/useAdminAgisoPlatform.ts deleted file mode 100644 index c6b4a79e..00000000 --- a/apps/frontend/src/views/admin/platform-shops/composables/useAdminAgisoPlatform.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { computed, ref } from 'vue' - -import { showError, showSuccess } from '@/lib/feedback' -import { saveAdminAgisoShopConfigs } from '@/services/admin' -import type { - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, - AdminAgisoShopConfigItem, -} from '@/types/admin' - -import type { EditableDefaults, EditableShop } from './types' - -export function useAdminAgisoPlatform() { - const saving = ref(false) - const agisoFilePath = ref('') - const defaults = ref(createEmptyDefaults()) - const shops = ref([]) - const observedShops = ref([]) - const expandedShopIds = ref([]) - - const agisoStats = computed(() => ({ - configuredShopCount: shops.value.length, - observedShopCount: observedShops.value.length, - pendingObservedCount: observedShops.value.filter((item) => !item.configured).length, - })) - - function hydrateAgisoConfig(data: { - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] - observedShops: AdminAgisoObservedShopItem[] - }) { - agisoFilePath.value = data.filePath - defaults.value = mapEditableDefaults(data.defaults) - shops.value = data.shops.map(mapEditableShop) - observedShops.value = data.observedShops - expandedShopIds.value = [] - } - - function addShop() { - const shop = createEmptyShop() - shops.value.unshift(shop) - expandShop(shop.id) - } - - function removeShop(id: string) { - shops.value = shops.value.filter((item) => item.id !== id) - collapseShop(id) - } - - function importObservedShop(item: AdminAgisoObservedShopItem) { - const shop: EditableShop = { - id: crypto.randomUUID(), - shopId: item.shopId, - shopName: item.detectedShopName || item.displayShopName || '', - accessToken: '', - enabled: true, - messageTemplate: '', - autoDeliveryMessageTemplate: '', - } - shops.value.unshift(shop) - expandShop(shop.id) - } - - function isShopExpanded(id: string) { - return expandedShopIds.value.includes(id) - } - - function expandShop(id: string) { - if (expandedShopIds.value.includes(id)) { - return - } - expandedShopIds.value = [id, ...expandedShopIds.value] - } - - function collapseShop(id: string) { - expandedShopIds.value = expandedShopIds.value.filter((item) => item !== id) - } - - function toggleShop(id: string) { - if (isShopExpanded(id)) { - collapseShop(id) - return - } - expandShop(id) - } - - function expandAllShops() { - expandedShopIds.value = shops.value.map((item) => item.id) - } - - function collapseAllShops() { - expandedShopIds.value = [] - } - - function describeShop(shop: EditableShop) { - const overrides: string[] = [] - - if (shop.messageTemplate.trim()) { - overrides.push('领取模板') - } - if (shop.autoDeliveryMessageTemplate.trim()) { - overrides.push('发货模板') - } - - return overrides.length === 0 ? '使用默认模板' : `已覆盖 ${overrides.join('、')}` - } - - async function saveAgisoConfigs(reloadConfigs: () => Promise) { - const payloadDefaults = { - messageTemplate: defaults.value.messageTemplate.trim(), - autoDeliveryMessageTemplate: defaults.value.autoDeliveryMessageTemplate.trim(), - } - const payload = shops.value - .map((item) => ({ - shopId: item.shopId.trim(), - shopName: item.shopName.trim(), - accessToken: item.accessToken.trim(), - enabled: item.enabled, - messageTemplate: item.messageTemplate.trim(), - autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate.trim(), - })) - .filter((item) => item.shopId && item.accessToken) - - saving.value = true - - try { - const response = await saveAdminAgisoShopConfigs({ - defaults: payloadDefaults, - shops: payload, - }) - agisoFilePath.value = response.data.filePath - defaults.value = mapEditableDefaults(response.data.defaults) - shops.value = response.data.shops.map(mapEditableShop) - showSuccess('Agiso 配置已保存') - await reloadConfigs() - } catch (error) { - showError(error instanceof Error ? error.message : '保存 Agiso 配置失败') - } finally { - saving.value = false - } - } - - return { - saving, - agisoFilePath, - defaults, - shops, - observedShops, - expandedShopIds, - agisoStats, - hydrateAgisoConfig, - addShop, - removeShop, - importObservedShop, - isShopExpanded, - expandShop, - collapseShop, - toggleShop, - expandAllShops, - collapseAllShops, - describeShop, - saveAgisoConfigs, - } -} - -function createEmptyDefaults(): EditableDefaults { - return { - messageTemplate: '', - autoDeliveryMessageTemplate: '', - } -} - -function createEmptyShop(): EditableShop { - return { - id: crypto.randomUUID(), - shopId: '', - shopName: '', - accessToken: '', - enabled: true, - messageTemplate: '', - autoDeliveryMessageTemplate: '', - } -} - -function mapEditableDefaults(item?: AdminAgisoMessagingDefaults): EditableDefaults { - return { - messageTemplate: item?.messageTemplate ?? '', - autoDeliveryMessageTemplate: item?.autoDeliveryMessageTemplate ?? '', - } -} - -function mapEditableShop(item: AdminAgisoShopConfigItem): EditableShop { - return { - id: crypto.randomUUID(), - shopId: item.shopId, - shopName: item.shopName, - accessToken: item.AccessToken, - enabled: item.enabled !== false, - messageTemplate: item.messageTemplate, - autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate, - } -} diff --git a/apps/frontend/src/views/claim/ClaimTencentView.vue b/apps/frontend/src/views/claim/ClaimTencentView.vue deleted file mode 100644 index 056e312d..00000000 --- a/apps/frontend/src/views/claim/ClaimTencentView.vue +++ /dev/null @@ -1,357 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/claim/ClaimView.vue b/apps/frontend/src/views/claim/ClaimView.vue index 72756140..45f05643 100644 --- a/apps/frontend/src/views/claim/ClaimView.vue +++ b/apps/frontend/src/views/claim/ClaimView.vue @@ -1,90 +1,13 @@ - - diff --git a/apps/frontend/src/views/tx/TencentBrowserView.vue b/apps/frontend/src/views/tx/TencentBrowserView.vue deleted file mode 100644 index 3aa7e8c3..00000000 --- a/apps/frontend/src/views/tx/TencentBrowserView.vue +++ /dev/null @@ -1,303 +0,0 @@ - - - - - diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile index cd6c489d..d72fcaae 100644 --- a/deploy/docker/frontend.Dockerfile +++ b/deploy/docker/frontend.Dockerfile @@ -1,11 +1,9 @@ FROM docker.m.daocloud.io/library/node:22-bookworm AS builder ARG NPM_REGISTRY=https://registry.npmmirror.com -ARG VITE_APP_MODE= ENV TZ=Asia/Shanghai ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY} -ENV VITE_APP_MODE=${VITE_APP_MODE} WORKDIR /app diff --git a/docker-compose.kuaishou.dev.yml b/docker-compose.kuaishou.dev.yml index 65818617..8366c427 100644 --- a/docker-compose.kuaishou.dev.yml +++ b/docker-compose.kuaishou.dev.yml @@ -73,7 +73,6 @@ services: NPM_CONFIG_REGISTRY: https://registry.npmmirror.com VITE_API_TARGET: http://backend:3000 VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1} - VITE_APP_MODE: kuaishou volumes: - ./apps/frontend:/app - frontend_kuaishou_node_modules:/app/node_modules diff --git a/docker-compose.kuaishou.yml b/docker-compose.kuaishou.yml index baddd318..e19f1ee3 100644 --- a/docker-compose.kuaishou.yml +++ b/docker-compose.kuaishou.yml @@ -57,7 +57,6 @@ services: dockerfile: deploy/docker/frontend.Dockerfile args: APP_DOMAIN: ${APP_DOMAIN:-localhost} - VITE_APP_MODE: kuaishou restart: unless-stopped depends_on: backend: From 9aafddd624103361b98e89c9bc0fbf8a99a89e68 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 21:12:03 +0800 Subject: [PATCH 03/73] =?UTF-8?q?=E6=96=AD=E5=BC=80=E5=BF=AB=E6=89=8B?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=97=A7=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/app-kuaishou.ts | 2 - apps/backend/src/routes/admin-kuaishou.ts | 2 +- .../routes/admin/platform-config-kuaishou.ts | 19 ++++++ .../admin/platform-config/cloudtentacles.ts | 2 +- .../kuaishou-cloud-fulfillment.ts | 2 +- .../admin/platform-config/kuaishou-eticket.ts | 2 +- .../routes/admin/platform-config/ninetyone.ts | 2 +- .../admin/platform-config/notifications.ts | 2 +- .../routes/admin/webhook-events-kuaishou.ts | 19 +++--- .../admin/admin-read-shared-helpers.ts | 8 --- .../admin/platform-config/agiso-template.ts | 6 ++ .../platform-config/cloudtentacles-service.ts | 2 +- .../platform-config/cloudtentacles-writes.ts | 19 ++++++ .../services/admin/platform-config/domain.ts | 2 +- .../services/admin/platform-config/writes.ts | 16 ----- .../src/services/order/order-service.ts | 59 ++++++++----------- .../src/services/order/webhook-service.ts | 26 +++++++- .../platforms/agiso/xianyu/message-service.ts | 8 +-- apps/backend/tsconfig.kuaishou.json | 1 + 19 files changed, 113 insertions(+), 86 deletions(-) create mode 100644 apps/backend/src/routes/admin/platform-config-kuaishou.ts create mode 100644 apps/backend/src/services/admin/platform-config/agiso-template.ts create mode 100644 apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts diff --git a/apps/backend/src/app-kuaishou.ts b/apps/backend/src/app-kuaishou.ts index 0848ceec..954ffe71 100644 --- a/apps/backend/src/app-kuaishou.ts +++ b/apps/backend/src/app-kuaishou.ts @@ -5,7 +5,6 @@ import process from "node:process"; import adminRouter from "./routes/admin-kuaishou.js"; import claimsRouter from "./routes/claims-kuaishou.js"; import open91Router from "./routes/open-91.js"; -import webhooksRouter from "./routes/webhooks.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js"; @@ -91,7 +90,6 @@ export function createKuaishouApp({ }); app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/webhooks", webhooksRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); diff --git a/apps/backend/src/routes/admin-kuaishou.ts b/apps/backend/src/routes/admin-kuaishou.ts index 6f5a54b4..d329014c 100644 --- a/apps/backend/src/routes/admin-kuaishou.ts +++ b/apps/backend/src/routes/admin-kuaishou.ts @@ -6,7 +6,7 @@ import dashboardRouter from "./admin/dashboard.js"; import inventoryRouter from "./admin/inventory-kuaishou.js"; import messageDeliveriesRouter from "./admin/message-deliveries.js"; import ordersRouter from "./admin/orders.js"; -import platformConfigRouter from "./admin/platform-config.js"; +import platformConfigRouter from "./admin/platform-config-kuaishou.js"; import { requireAdminSession } from "./admin/shared.js"; import tasksRouter from "./admin/tasks-kuaishou.js"; import usersRouter from "./admin/users.js"; diff --git a/apps/backend/src/routes/admin/platform-config-kuaishou.ts b/apps/backend/src/routes/admin/platform-config-kuaishou.ts new file mode 100644 index 00000000..f5cb0345 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config-kuaishou.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; + +import { requireAdminRoles } from "./shared.js"; +import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; +import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; +import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; +import ninetyoneRouter from "./platform-config/ninetyone.js"; +import notificationsRouter from "./platform-config/notifications.js"; + +const router = Router(); + +router.use("/platform-config", requireAdminRoles(["admin"])); +router.use("/platform-config", notificationsRouter); +router.use("/platform-config", kuaishouEticketRouter); +router.use("/platform-config", ninetyoneRouter); +router.use("/platform-config", cloudtentaclesRouter); +router.use("/platform-config", kuaishouCloudFulfillmentRouter); + +export default router; diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts index 26852a12..854b1878 100644 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -21,7 +21,7 @@ import { useAdminCloudtentaclesSku, validateAdminCloudtentaclesSession, verifyAdminCloudtentaclesLoginCode, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/cloudtentacles-service.js"; import type { AdminCloudtentaclesCatalogQueryRouteBody, AdminCloudtentaclesFullFlowRouteBody, diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts index c76b106f..170dce6c 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -3,7 +3,7 @@ import { Router } from "express"; import { getAdminKuaishouCloudFulfillmentConfig, updateAdminKuaishouCloudFulfillmentConfig, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/kuaishou-cloud-fulfillment-service.js"; import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js"; import { createJsonHandler } from "../shared.js"; import type { JsonRecord } from "./shared.js"; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts index 0979b248..6fce9fa8 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts @@ -6,7 +6,7 @@ import { queryAdminKuaishouEticketDetail, queryAdminKuaishouEticketShopInfo, updateAdminKuaishouEticketSourceConfig, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/kuaishou-eticket-service.js"; import type { AdminKuaishouEticketConsumeRouteBody, AdminKuaishouEticketDetailQueryRouteBody, diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts index 51ffb0bb..1da89d08 100644 --- a/apps/backend/src/routes/admin/platform-config/ninetyone.ts +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -4,7 +4,7 @@ import { failAdminNinetyoneOrder, getAdminNinetyoneOrders, retryAdminNinetyoneOrder, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/ninetyone-service.js"; import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js"; import { createJsonHandler } from "../shared.js"; import type { JsonRecord } from "./shared.js"; diff --git a/apps/backend/src/routes/admin/platform-config/notifications.ts b/apps/backend/src/routes/admin/platform-config/notifications.ts index 3a94680a..4fcf800a 100644 --- a/apps/backend/src/routes/admin/platform-config/notifications.ts +++ b/apps/backend/src/routes/admin/platform-config/notifications.ts @@ -7,7 +7,7 @@ import { testAdminNotification, updateAdminNotificationConfig, updateAdminScheduledJobsConfig, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/notification-service.js"; import type { AdminEntityRouteParams, AdminNotificationConfigRouteBody, diff --git a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts index 1b71dea5..b60970a9 100644 --- a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts +++ b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts @@ -1,16 +1,15 @@ import { Router } from "express"; -import { replayAdminWebhookEvent } from "../../services/admin/write/webhook-events.js"; import { getAdminWebhookEventDetail, getAdminWebhookEvents, } from "../../services/admin/admin-read-service.js"; +import { createHttpError } from "../../utils/http.js"; import { createJsonHandler, requireAdminRoles } from "./shared.js"; import type { AdminWebhookEventRouteParams, AdminWebhookEventRouteQuery, } from "../../types/admin-route-inputs.js"; -import type { AdminWebhookReplayResponse } from "../../types/admin-write-models.js"; const router = Router(); @@ -47,17 +46,17 @@ router.post( "/webhook-events/:eventId/replay", requireAdminRoles(["admin"]), createJsonHandler( - (req) => - replayAdminWebhookEvent( - (req.params as AdminWebhookEventRouteParams).eventId - ), + () => { + throw createHttpError("快手轻量后端不支持重放旧 Webhook", { + statusCode: 409, + errorCode: "admin_webhook_replay_disabled", + }); + }, { successMessage: "Webhook 已重放", errorMessage: "重放 webhook 失败", scope: "[admin-kuaishou/webhook-events/:eventId/replay]", audit: (req, data) => { - const result = data as AdminWebhookReplayResponse; - return { action: "webhook_replayed", targetType: "webhook_event", @@ -65,8 +64,8 @@ router.post( (req.params as AdminWebhookEventRouteParams).eventId ), data: { - eventId: result.eventId, - replayed: result.replayed, + disabled: true, + result: data, }, }; }, diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 2b442d67..6dc5db72 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -1,4 +1,3 @@ -import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js' import { safeParseJson } from './admin-query-utils.js' import { normalizeAdminRole } from './admin-auth-service.js' import { @@ -224,13 +223,6 @@ export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopN return '' } - if (String(provider || '').trim().toLowerCase() === 'agiso') { - const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim() - if (configuredName) { - return configuredName - } - } - return normalizedShopId } diff --git a/apps/backend/src/services/admin/platform-config/agiso-template.ts b/apps/backend/src/services/admin/platform-config/agiso-template.ts new file mode 100644 index 00000000..0aec7536 --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/agiso-template.ts @@ -0,0 +1,6 @@ +export function normalizeAgisoMessageTemplate(value: unknown): string { + return String(value || "") + .replaceAll("\\r\\n", "\n") + .replaceAll("\\n", "\n") + .replaceAll("\r\n", "\n"); +} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts index 94e4a9e8..6465c039 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts @@ -49,7 +49,7 @@ import { resolveAdminCloudtentaclesCredentialPayload, resolveAdminCloudtentaclesSessionPayload, } from "./cloudtentacles.js"; -import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./writes.js"; +import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./cloudtentacles-writes.js"; import { mapAdminCloudtentaclesSession, mapAdminCloudtentaclesSourceConfig, diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts new file mode 100644 index 00000000..070fd78e --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts @@ -0,0 +1,19 @@ +type JsonObject = Record; + +export function normalizeAdminCloudtentaclesSourceConfigPayload( + payload: JsonObject = {} +) { + const sourceKey = + String(payload.sourceKey || payload.key || "").trim() || "default"; + return { + key: sourceKey, + label: String(payload.label || "").trim(), + enabled: payload.enabled !== false, + baseUrl: String(payload.baseUrl || "").trim() || "https://123.207.217.176", + username: String(payload.username || "").trim(), + password: String(payload.password || "").trim(), + phone: String(payload.phone || "").trim(), + deviceId: String(payload.deviceId || "-").trim() || "-", + deviceType: Number(payload.deviceType || 0), + }; +} diff --git a/apps/backend/src/services/admin/platform-config/domain.ts b/apps/backend/src/services/admin/platform-config/domain.ts index 303ca50d..a84c41d0 100644 --- a/apps/backend/src/services/admin/platform-config/domain.ts +++ b/apps/backend/src/services/admin/platform-config/domain.ts @@ -1,4 +1,4 @@ -import { normalizeAgisoMessageTemplate } from '../../platforms/agiso/xianyu/message-service.js' +import { normalizeAgisoMessageTemplate } from './agiso-template.js' import { maskSecret } from './mappers.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/writes.ts b/apps/backend/src/services/admin/platform-config/writes.ts index 8886455d..68c7a9bb 100644 --- a/apps/backend/src/services/admin/platform-config/writes.ts +++ b/apps/backend/src/services/admin/platform-config/writes.ts @@ -98,19 +98,3 @@ export function buildAdminAgisoMessagingSavePayload( shops: nextMap, }; } - -export function normalizeAdminCloudtentaclesSourceConfigPayload(payload: JsonObject = {}) { - const sourceKey = - String(payload.sourceKey || payload.key || "").trim() || "default"; - return { - key: sourceKey, - label: String(payload.label || "").trim(), - enabled: payload.enabled !== false, - baseUrl: String(payload.baseUrl || "").trim() || "https://123.207.217.176", - username: String(payload.username || "").trim(), - password: String(payload.password || "").trim(), - phone: String(payload.phone || "").trim(), - deviceId: String(payload.deviceId || "-").trim() || "-", - deviceType: Number(payload.deviceType || 0), - }; -} diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index b1290a4d..8fc3624c 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -5,10 +5,7 @@ import { updateOrder, } from '../../repositories/order-repo.js' import { replaceOrderItems } from '../../repositories/order-item-repo.js' -import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { buildClaimUrl } from '../claim/claim-service.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' -import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js' import { resolveOrderItemForFulfillment } from './product-match-service.js' import { nowIso } from '../../utils/time.js' import { logWebhook } from '../../utils/logger.js' @@ -51,6 +48,11 @@ type SourceOrderEvent = { type UpsertOrderSourceOptions = { sourceLabel?: string + handleClaimLinkTask?: (input: { + event: SourceOrderEvent + order: OrderRow + task: TaskRow + }) => Promise | null | undefined> } type MessageDeliveryResult = { @@ -99,7 +101,7 @@ export async function upsertOrderFromWebhook(event: SourceOrderEvent): Promise { const now = nowIso() const exactExisting = await findOrderByPlatformOrderId({ @@ -219,37 +221,28 @@ export async function upsertOrderFromSource( resolvedSkuCodes: configuredItems.map((item) => item.skuCode), }) - for (const task of tasks) { - const isClaimLinkTask = String(task.task_status || '') === 'link_generated' - || ( - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && ['pending_binding_prepare', 'waiting_binding', 'role_confirmed', 'dispatched_pending_return'].includes(String(task.task_status || '').trim()) - ) + if (handleClaimLinkTask) { + for (const task of tasks) { + const isClaimLinkTask = String(task.task_status || '') === 'link_generated' + || ( + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' + && ['pending_binding_prepare', 'waiting_binding', 'role_confirmed', 'dispatched_pending_return'].includes(String(task.task_status || '').trim()) + ) - if ( - event.provider !== 'agiso' - || event.platform !== 'xianyu' - || !isClaimLinkTask - || !task.primary_claim_token_id - ) { - continue + if (!isClaimLinkTask || !task.primary_claim_token_id) { + continue + } + + const result = await handleClaimLinkTask({ event, order, task }) + if (!result) { + continue + } + + messageDeliveries.push({ + taskId: task.id, + ...result, + }) } - - const claimToken = await getClaimTokenById(task.primary_claim_token_id) - if (!claimToken) { - continue - } - - const result = await ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl: buildClaimUrl(claimToken.token), - expiredAt: claimToken.expired_at, - }) - messageDeliveries.push({ - taskId: task.id, - ...result, - }) } return { diff --git a/apps/backend/src/services/order/webhook-service.ts b/apps/backend/src/services/order/webhook-service.ts index 7b8a1628..4f050c34 100644 --- a/apps/backend/src/services/order/webhook-service.ts +++ b/apps/backend/src/services/order/webhook-service.ts @@ -1,14 +1,17 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' +import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js' +import { buildClaimUrl } from '../claim/claim-service.js' +import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js' import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' import { extractAgisoTradeOrderItemSources, extractAgisoTradePayload, resolveAgisoTradePlatformOrderId, } from './agiso-trade-parsing.js' -import { upsertOrderFromWebhook } from './order-service.js' +import { upsertOrderFromSource, upsertOrderFromWebhook } from './order-service.js' import { hasConfiguredOrderItems } from './product-match-service.js' import { createHttpError } from '../../utils/http.js' import { parseJsonObject } from '../../utils/json.js' @@ -98,7 +101,7 @@ export async function executeAgisoTradeWebhookWithDeps( ) { const updateEvent = deps.updateWebhookEvent || updateWebhookEvent const hasConfiguredItems = deps.hasConfiguredOrderItems || hasConfiguredOrderItems - const upsertOrder = deps.upsertOrderFromWebhook || upsertOrderFromWebhook + const upsertOrder = deps.upsertOrderFromWebhook || upsertAgisoOrderFromWebhook try { if (!parsed.signatureValid) { @@ -287,6 +290,25 @@ export async function executeAgisoTradeWebhookWithDeps( } } +async function upsertAgisoOrderFromWebhook(event) { + return upsertOrderFromSource(event, { + sourceLabel: 'webhook', + handleClaimLinkTask: async ({ order, task }) => { + const claimToken = await getClaimTokenById(task.primary_claim_token_id) + if (!claimToken) { + return null + } + + return ensureAgisoXianyuClaimMessageDeliveredForTask({ + order, + task, + claimUrl: buildClaimUrl(claimToken.token), + expiredAt: claimToken.expired_at, + }) + }, + }) +} + async function enrichTradeBeforeUpsert( parsed, { requestId = '', webhookEventId = null } = {}, diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts index 5a9ccd14..1a7aebe0 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts +++ b/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../../../config/runtime.js' +import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js' import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js' import { createMessageDelivery, @@ -193,13 +194,6 @@ export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({ }) } -export function normalizeAgisoMessageTemplate(value: unknown): string { - return String(value || '') - .replaceAll('\\r\\n', '\n') - .replaceAll('\\n', '\n') - .replaceAll('\r\n', '\n') -} - async function deliverAgisoXianyuMessageForTask({ order, task, diff --git a/apps/backend/tsconfig.kuaishou.json b/apps/backend/tsconfig.kuaishou.json index 3dcacbb1..95aff275 100644 --- a/apps/backend/tsconfig.kuaishou.json +++ b/apps/backend/tsconfig.kuaishou.json @@ -3,6 +3,7 @@ "files": [ "src/index-kuaishou.ts" ], + "include": [], "exclude": [ "node_modules", "data", From e7aa194ddeda1252e238c8586896d1a38533df04 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 21:46:39 +0800 Subject: [PATCH 04/73] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=92=B8=E9=B1=BC?= =?UTF-8?q?=E6=97=A7=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 30 - .env.server.example | 26 - README.md | 111 +- apps/backend/README.md | 137 +-- apps/backend/package-lock.json | 47 +- apps/backend/package.json | 11 +- apps/backend/package.kuaishou.json | 22 - apps/backend/scripts/seed-dev-data.ts | 513 -------- apps/backend/scripts/start-dev-container.sh | 92 -- .../scripts/test-agiso-auto-delivery.ts | 288 ----- apps/backend/scripts/test-order-flow.sh | 98 -- apps/backend/src/app-kuaishou.ts | 101 -- apps/backend/src/app.ts | 12 +- apps/backend/src/config/defaults.ts | 46 - .../src/config/env-documentation.test.ts | 6 +- apps/backend/src/config/env-overrides.test.ts | 50 - apps/backend/src/config/env-overrides.ts | 65 -- .../src/config/runtime-validation.test.ts | 39 - apps/backend/src/config/runtime-validation.ts | 1 - apps/backend/src/index-kuaishou.ts | 71 -- apps/backend/src/index.ts | 6 +- apps/backend/src/routes/admin-kuaishou.ts | 34 - apps/backend/src/routes/admin.ts | 58 +- .../src/routes/admin/inventory-kuaishou.ts | 149 --- apps/backend/src/routes/admin/inventory.ts | 216 ++-- .../backend/src/routes/admin/manual-redeem.ts | 218 ---- .../routes/admin/platform-config-kuaishou.ts | 19 - .../src/routes/admin/platform-config.ts | 4 - .../src/routes/admin/platform-config/agiso.ts | 47 - .../platform-config/fulfillment-bindings.ts | 98 -- .../src/routes/admin/tasks-kuaishou.ts | 162 --- apps/backend/src/routes/admin/tasks.ts | 329 +----- .../routes/admin/webhook-events-kuaishou.ts | 76 -- .../src/routes/admin/webhook-events.ts | 108 +- apps/backend/src/routes/claims-kuaishou.ts | 71 -- apps/backend/src/routes/claims.ts | 130 +-- apps/backend/src/routes/tencent.ts | 77 -- apps/backend/src/routes/webhooks.ts | 57 - .../admin/admin-manual-redeem-service.ts | 434 ------- .../src/services/admin/admin-read-service.ts | 2 - .../src/services/admin/admin-service.ts | 86 -- .../services/admin/admin-task-read-helpers.ts | 93 -- .../admin/admin-task-screenshot-service.ts | 28 - .../admin/admin-webhook-read-helpers.ts | 44 +- .../admin/admin-write-service.test.ts | 105 -- .../src/services/admin/admin-write-service.ts | 30 - .../admin/platform-config/agiso-service.ts | 65 -- .../admin/platform-config/agiso-template.ts | 6 - .../admin/platform-config/domain.test.ts | 110 +- .../services/admin/platform-config/domain.ts | 65 -- .../fulfillment-bindings-service.ts | 103 -- .../admin/platform-config/fulfillment.test.ts | 124 -- .../admin/platform-config/fulfillment.ts | 137 --- .../services/admin/platform-config/service.ts | 61 - .../admin/platform-config/validation.test.ts | 175 --- .../admin/platform-config/validation.ts | 125 -- .../admin/platform-config/writes.test.ts | 127 -- .../services/admin/platform-config/writes.ts | 100 -- .../src/services/admin/write/task-actions.ts | 520 --------- .../services/admin/write/webhook-events.ts | 32 - .../fulfillment-bootstrap-service.test.ts | 26 - .../fulfillment-bootstrap-service.ts | 10 +- .../claim/claim-session-service.test.ts | 273 ----- .../services/claim/claim-session-service.ts | 28 - ...red.ts => kuaishou-cloud-claim-context.ts} | 180 +-- .../claim/kuaishou-cloud-claim-service.ts | 4 +- .../src/services/claim/session/actions.ts | 312 ----- .../src/services/claim/session/context.ts | 150 --- .../src/services/claim/session/lifecycle.ts | 175 --- .../src/services/claim/session/redeem.ts | 301 ----- .../src/services/claim/session/runtime.ts | 107 -- .../order/agiso-trade-parsing.test.ts | 31 - .../src/services/order/agiso-trade-parsing.ts | 73 -- ...fulfillment-binding-config-service.test.ts | 52 - .../fulfillment-binding-config-service.ts | 129 -- .../services/order/webhook-service.test.ts | 387 ------ .../src/services/order/webhook-service.ts | 1038 ----------------- .../src/services/platforms/agiso/README.md | 9 - .../services/platforms/agiso/pdd/README.md | 3 - .../platforms/agiso/shop-config-service.ts | 181 --- .../services/platforms/agiso/taobao/README.md | 3 - .../services/platforms/agiso/xianyu/README.md | 3 - .../xianyu/auto-delivery-service.test.ts | 264 ----- .../agiso/xianyu/auto-delivery-service.ts | 640 ---------- .../agiso/xianyu/message-service.test.ts | 103 -- .../platforms/agiso/xianyu/message-service.ts | 539 --------- .../agiso/xianyu/order-detail-service.test.ts | 219 ---- .../agiso/xianyu/order-detail-service.ts | 803 ------------- apps/backend/src/services/session/ocr.ts | 101 -- .../src/services/session/session-activity.ts | 308 ----- .../session/session-browser-config.ts | 135 --- .../services/session/session-cookie-map.ts | 49 - .../services/session/session-login-shared.ts | 113 -- .../src/services/session/session-page.ts | 244 ---- .../src/services/session/session-payload.ts | 190 --- .../session/session-proof-beijing-time.ts | 55 - .../session/session-proof-constants.ts | 11 - .../services/session/session-proof-html.ts | 180 --- .../services/session/session-proof-mode.ts | 38 - .../services/session/session-proof-paths.ts | 19 - .../session/session-proof-renderer.ts | 131 --- .../session/session-proof-result-writer.ts | 65 -- .../services/session/session-proof.test.ts | 81 -- .../src/services/session/session-proof.ts | 205 ---- .../src/services/session/session-qq.ts | 240 ---- .../services/session/session-redeem.test.ts | 89 -- .../src/services/session/session-redeem.ts | 718 ------------ .../src/services/session/session-review.ts | 68 -- .../session/session-service-config.ts | 11 - .../session/session-service-runtime.ts | 102 -- .../services/session/session-service-store.ts | 205 ---- .../src/services/session/session-state.ts | 137 --- .../src/services/session/session-wx.ts | 377 ------ .../src/services/session/session.test.ts | 376 ------ apps/backend/src/services/session/session.ts | 410 ------- .../backend/src/startup/bootstrap-kuaishou.ts | 71 -- apps/backend/src/startup/bootstrap.ts | 103 +- apps/backend/src/startup/shutdown-kuaishou.ts | 44 - apps/backend/src/startup/shutdown.ts | 21 +- apps/backend/src/types/admin-read-models.ts | 12 - apps/backend/src/types/admin-route-inputs.ts | 9 - apps/backend/src/types/admin-write-inputs.ts | 59 - apps/backend/src/types/admin-write-models.ts | 24 - apps/backend/src/types/runtime-config.ts | 55 - apps/backend/tsconfig.kuaishou.build.json | 10 - apps/backend/tsconfig.kuaishou.json | 14 - apps/frontend/src/types/admin/index.ts | 2 - apps/frontend/src/types/admin/orders.ts | 9 - apps/frontend/src/types/admin/tasks.ts | 12 - apps/frontend/src/utils/admin-display.ts | 70 +- apps/frontend/src/utils/admin-options.ts | 2 +- .../admin/orders/AdminOrderDetailView.vue | 33 - .../views/admin/tasks/AdminTaskDetailView.vue | 10 - .../AdminTaskAutoDeliverySection.vue | 66 -- .../tasks/composables/useAdminTaskDetail.ts | 26 - deploy/docker/backend-dev.Dockerfile | 13 +- deploy/docker/backend-kuaishou.Dockerfile | 62 - deploy/docker/backend.Dockerfile | 56 +- docker-compose.dev.yml | 32 - docker-compose.kuaishou.dev.yml | 99 -- docker-compose.kuaishou.yml | 78 -- docker-compose.yml | 22 - 142 files changed, 539 insertions(+), 17373 deletions(-) delete mode 100644 apps/backend/package.kuaishou.json delete mode 100644 apps/backend/scripts/seed-dev-data.ts delete mode 100644 apps/backend/scripts/test-agiso-auto-delivery.ts delete mode 100755 apps/backend/scripts/test-order-flow.sh delete mode 100644 apps/backend/src/app-kuaishou.ts delete mode 100644 apps/backend/src/index-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/inventory-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/manual-redeem.ts delete mode 100644 apps/backend/src/routes/admin/platform-config-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/platform-config/agiso.ts delete mode 100644 apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts delete mode 100644 apps/backend/src/routes/admin/tasks-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/webhook-events-kuaishou.ts delete mode 100644 apps/backend/src/routes/claims-kuaishou.ts delete mode 100644 apps/backend/src/routes/tencent.ts delete mode 100644 apps/backend/src/routes/webhooks.ts delete mode 100644 apps/backend/src/services/admin/admin-manual-redeem-service.ts delete mode 100644 apps/backend/src/services/admin/admin-service.ts delete mode 100644 apps/backend/src/services/admin/admin-task-screenshot-service.ts delete mode 100644 apps/backend/src/services/admin/admin-write-service.test.ts delete mode 100644 apps/backend/src/services/admin/admin-write-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/agiso-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/agiso-template.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment.ts delete mode 100644 apps/backend/src/services/admin/platform-config/service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/validation.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/validation.ts delete mode 100644 apps/backend/src/services/admin/platform-config/writes.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/writes.ts delete mode 100644 apps/backend/src/services/admin/write/task-actions.ts delete mode 100644 apps/backend/src/services/admin/write/webhook-events.ts delete mode 100644 apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts delete mode 100644 apps/backend/src/services/claim/claim-session-service.test.ts delete mode 100644 apps/backend/src/services/claim/claim-session-service.ts rename apps/backend/src/services/claim/{session/shared.ts => kuaishou-cloud-claim-context.ts} (62%) delete mode 100644 apps/backend/src/services/claim/session/actions.ts delete mode 100644 apps/backend/src/services/claim/session/context.ts delete mode 100644 apps/backend/src/services/claim/session/lifecycle.ts delete mode 100644 apps/backend/src/services/claim/session/redeem.ts delete mode 100644 apps/backend/src/services/claim/session/runtime.ts delete mode 100644 apps/backend/src/services/order/agiso-trade-parsing.test.ts delete mode 100644 apps/backend/src/services/order/agiso-trade-parsing.ts delete mode 100644 apps/backend/src/services/order/fulfillment-binding-config-service.test.ts delete mode 100644 apps/backend/src/services/order/fulfillment-binding-config-service.ts delete mode 100644 apps/backend/src/services/order/webhook-service.test.ts delete mode 100644 apps/backend/src/services/order/webhook-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/pdd/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/shop-config-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/taobao/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/message-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts delete mode 100644 apps/backend/src/services/session/ocr.ts delete mode 100644 apps/backend/src/services/session/session-activity.ts delete mode 100644 apps/backend/src/services/session/session-browser-config.ts delete mode 100644 apps/backend/src/services/session/session-cookie-map.ts delete mode 100644 apps/backend/src/services/session/session-login-shared.ts delete mode 100644 apps/backend/src/services/session/session-page.ts delete mode 100644 apps/backend/src/services/session/session-payload.ts delete mode 100644 apps/backend/src/services/session/session-proof-beijing-time.ts delete mode 100644 apps/backend/src/services/session/session-proof-constants.ts delete mode 100644 apps/backend/src/services/session/session-proof-html.ts delete mode 100644 apps/backend/src/services/session/session-proof-mode.ts delete mode 100644 apps/backend/src/services/session/session-proof-paths.ts delete mode 100644 apps/backend/src/services/session/session-proof-renderer.ts delete mode 100644 apps/backend/src/services/session/session-proof-result-writer.ts delete mode 100644 apps/backend/src/services/session/session-proof.test.ts delete mode 100644 apps/backend/src/services/session/session-proof.ts delete mode 100644 apps/backend/src/services/session/session-qq.ts delete mode 100644 apps/backend/src/services/session/session-redeem.test.ts delete mode 100644 apps/backend/src/services/session/session-redeem.ts delete mode 100644 apps/backend/src/services/session/session-review.ts delete mode 100644 apps/backend/src/services/session/session-service-config.ts delete mode 100644 apps/backend/src/services/session/session-service-runtime.ts delete mode 100644 apps/backend/src/services/session/session-service-store.ts delete mode 100644 apps/backend/src/services/session/session-state.ts delete mode 100644 apps/backend/src/services/session/session-wx.ts delete mode 100644 apps/backend/src/services/session/session.test.ts delete mode 100644 apps/backend/src/services/session/session.ts delete mode 100644 apps/backend/src/startup/bootstrap-kuaishou.ts delete mode 100644 apps/backend/src/startup/shutdown-kuaishou.ts delete mode 100644 apps/backend/tsconfig.kuaishou.build.json delete mode 100644 apps/backend/tsconfig.kuaishou.json delete mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskAutoDeliverySection.vue delete mode 100644 deploy/docker/backend-kuaishou.Dockerfile delete mode 100644 docker-compose.kuaishou.dev.yml delete mode 100644 docker-compose.kuaishou.yml diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 0a934257..0aa3a517 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -24,41 +24,11 @@ POSTGRES_PORT=5432 DATA_ROOT=/app/data LOG_LEVEL=info -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_PORT=8100 -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=basic -TENCENT_BROWSER_HEADLESS=false -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=200 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=true -TENCENT_BROWSER_NOVNC_ENABLED=true -TENCENT_BROWSER_NOVNC_PORT=6080 -TENCENT_BROWSER_VNC_PORT=5900 - # Admin ADMIN_SESSION_SECRET=dev-local-session-secret ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] -# Agiso -AGISO_APP_SECRET= -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan NINETYONE_USER_ID= NINETYONE_SECRET= diff --git a/.env.server.example b/.env.server.example index 20443117..1cbcf74f 100644 --- a/.env.server.example +++ b/.env.server.example @@ -23,37 +23,11 @@ DATABASE_MAX_CONNECTIONS=20 DATA_ROOT=/app/data LOG_LEVEL=info -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=full -TENCENT_BROWSER_HEADLESS=true -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=0 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=false - # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] -# Agiso -AGISO_APP_SECRET=change-me-agiso-app-secret -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan NINETYONE_USER_ID= NINETYONE_SECRET= diff --git a/README.md b/README.md index a2dfdb6f..03bf600a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # order_site -订单自动兑换与履约管理系统。 +快手自动发货与履约管理系统。 -项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。 +项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。 ## 技术栈 - 前端:Vue 3、Vite、Element Plus、TypeScript -- 后端:Node.js、Express、PostgreSQL、Playwright -- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用 +- 后端:Node.js、Express、PostgreSQL - 网关:Caddy - 本地与生产运行:Docker Compose @@ -30,28 +29,18 @@ docker-compose.yml 生产/服务器 Compose ## 本地开发 -推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。 +推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。 ```bash cp .env.mac-docker.example .env docker compose -f docker-compose.dev.yml up -d --build ``` -如果只运行快手 Cloud / 91 卡券 / 快手小店核销链路,可以使用轻量 Compose。这个模式不会启动 OCR,也不会安装或预热 Playwright 浏览器: - -```bash -docker compose -f docker-compose.kuaishou.dev.yml up -d --build -``` - 启动后常用入口: - 前端、后台、领取页:`http://localhost` - 后端健康检查:`http://localhost/health` - 后端 API 前缀:`http://localhost/api/v1/...` -- OCR worker:`http://127.0.0.1:8100/health` -- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html` - -快手轻量开发模式没有 noVNC 入口,前端首页默认进入后台。 查看容器状态: @@ -64,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps ```bash docker compose -f docker-compose.dev.yml logs -f backend docker compose -f docker-compose.dev.yml logs -f frontend -docker compose -f docker-compose.dev.yml logs -f ocr-worker ``` 停止环境: @@ -83,11 +71,6 @@ docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck docker compose -f docker-compose.dev.yml exec -T backend npm run build docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -前端生产构建: - -```bash docker compose -f docker-compose.dev.yml exec -T frontend npm run build ``` @@ -97,16 +80,6 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run build docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate ``` -## 热更新 - -开发版 Compose 会把源码挂载进容器: - -- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启 -- 修改 `apps/frontend/src`:Vite 自动 HMR -- 修改数据库 migration:重启后端会自动执行迁移 - -如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。 - ## 环境变量 本地开发从根目录 `.env` 读取,模板为: @@ -128,10 +101,7 @@ cp .env.server.example .env - `ADMIN_SESSION_SECRET`:后台登录态签名密钥 - `ADMIN_DEFAULT_USERS_JSON`:默认后台用户 - `DATABASE_URL`:PostgreSQL 连接串 -- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行 -- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器 -- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest` -- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100` +- `NINETYONE_*`:91 卡券对接配置 ## 运行数据 @@ -140,8 +110,6 @@ cp .env.server.example .env - 后端配置与运行产物:`apps/backend/data` - PostgreSQL 数据:Docker volume `postgres_dev_data` - 后端日志:`apps/backend/data/logs` -- 浏览器会话:`apps/backend/data/browser-sessions` -- 兑换截图:`apps/backend/data/redeem-screenshots` 生产部署数据: @@ -161,13 +129,6 @@ mkdir -p deploy/data/backend docker compose up -d --build ``` -只部署快手轻量服务时使用: - -```bash -mkdir -p deploy/data/backend-kuaishou -docker compose -f docker-compose.kuaishou.yml up -d --build -``` - 生产入口: - 前端与领取页:`https://你的域名/` @@ -177,78 +138,21 @@ docker compose -f docker-compose.kuaishou.yml up -d --build 生产 Compose 会自动: - 启动 PostgreSQL -- 启动 OCR worker - 启动后端并执行 migration - 初始化默认后台管理员 - 同步履约目录 - 通过 Caddy 暴露前端和 API -查看生产日志: - -```bash -docker compose logs -f backend -docker compose logs -f web -``` - -## 开发数据脚本 - -后端提供开发数据脚本: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data -docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data -``` - -脚本默认有预览/确认语义,执行前注意终端输出说明。 - ## 主要业务模块 - `apps/backend/src/routes`:API 路由 -- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配 -- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排 -- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图 +- `apps/backend/src/services/order`:订单、库存和履约匹配 +- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排 - `apps/backend/src/services/admin`:后台读写、权限、配置管理 -- `apps/backend/src/services/platforms`:外部平台 API 封装 - `apps/backend/src/repositories`:数据库访问 - `apps/frontend/src/views/admin`:后台页面 - `apps/frontend/src/views/claim`:用户领取页 -## 排障 - -如果页面打不开: - -```bash -docker compose -f docker-compose.dev.yml ps -docker compose -f docker-compose.dev.yml logs -f web -``` - -如果后端未 ready: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -curl http://localhost/health/ready -``` - -如果浏览器兑换或扫码异常: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -open http://127.0.0.1:6080/vnc.html -``` - -如果前端类型或 lint 失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck -docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -如果后端单测失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm test -``` - ## 提交前检查清单 ```bash @@ -264,4 +168,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint - `.env` 未被提交 - 数据库 migration 与代码一起提交 - 领取链接相关改动已在 Docker 环境验证 -- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志 diff --git a/apps/backend/README.md b/apps/backend/README.md index ce404ff2..f0d88734 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,13 +1,8 @@ # order-site-backend -腾讯活动浏览器会话后端。 +快手轻量后端服务。 -当前主线只有一条: - -- 后端托管 Playwright 浏览器 -- 前端展示二维码并轮询会话状态 -- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换 -- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物 +后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是轻量后端,不再包含浏览器会话、OCR 或旧咸鱼自动发货链路。 ## 快速启动 @@ -20,21 +15,18 @@ npm install 复制并填写环境变量后启动: ```bash -cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env -cd /Users/yml/codes/order-site-workspace/apps/backend +cp ../../.env.mac-docker.example ../../.env npm run dev ``` -Linux 部署: +生产构建: ```bash -npm install npm run build -npm run browser:install:linux npm run start ``` -后端渐进 TypeScript 迁移已经进入源码和构建双轨模式: +常用验证: ```bash npm run typecheck @@ -42,120 +34,61 @@ npm run build npm test ``` -当前约定: - -- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src` -- 数据库迁移:`npm run db:migrate` 使用 `tsx` -- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js` -- 新增或迁移后端模块优先使用 `.ts` - ## 配置 -当前后端采用 `.env` 单一路径做环境配置: +后端采用工作区根目录 `.env` 做环境配置: - 通用默认值:`src/config/defaults.ts` - 本地/部署覆盖:工作区根目录 `.env` -当前最常改的配置有: +常用配置: -- 服务端口 +- 服务端口 `PORT` - 日志级别 `LOG_LEVEL=debug|info|warn|error` -- 浏览器是否无头、是否预热、是否常驻、slowMo -- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL` -- 会话调试开关 -- 兑换证明模式 `full | basic | off` -- Agiso 全局配置与后台店铺文件配置 - -### Agiso 多店铺 - -当前支持同一个 Agiso 应用下配置多个店铺的消息 token。 - -- 全局兜底配置仍走 `.env`: - - `AGISO_APP_SECRET` - - `AGISO_MESSAGING_ENABLED` -- 默认消息模板与店铺级覆盖配置改为文件: - - `apps/backend/data/agiso-shops.json` -- 后台维护入口: - - `#/admin/platform-shops` - -消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。 +- 数据库连接 `DATABASE_URL` +- 后台登录密钥 `ADMIN_SESSION_SECRET` +- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON` +- 91 卡券与快手相关平台配置 ## 接口 -- `POST /api/v1/tencent/browser/session` - 创建浏览器会话,返回 `sessionId`、二维码和初始状态 -- `GET /api/v1/tencent/browser/session/:sessionId` - 返回完整会话状态 -- `GET /api/v1/tencent/browser/session/:sessionId/summary` - 轮询用轻量摘要接口,默认不返回二维码 base64 -- `POST /api/v1/tencent/browser/session/:sessionId/refresh` - 强制刷新后端活动页 -- `POST /api/v1/tencent/browser/session/:sessionId/redeem` - 执行兑换 -- `GET /api/v1/tencent/browser/session/:sessionId/screenshot` - 读取最近一次兑换截图 -- `DELETE /api/v1/tencent/browser/session/:sessionId` - 关闭浏览器会话 +- `GET /health`:健康检查 +- `GET /health/live`:存活检查 +- `GET /health/ready`:就绪检查 +- `/api/v1/open/91/*`:91 卡券开放回调 +- `/api/v1/claim/*`:领取页接口 +- `/api/v1/admin/*`:后台管理接口 ## 目录 -- `src/index.ts` - Express 入口 -- `src/routes/tencent.ts` - 接口路由 -- `src/services/session/session.ts` - 会话编排与浏览器生命周期 -- `src/services/session/*.ts` - 登录、兑换、OCR、凭证产物等模块 +- `src/index.ts`:服务入口 +- `src/app.ts`:Express 应用组装 +- `src/routes`:API 路由 +- `src/services/order`:订单、库存和履约匹配 +- `src/services/fulfillment`:快手 Cloud 履约编排 +- `src/services/admin`:后台读写、权限、配置管理 +- `src/repositories`:数据库访问 +- `src/db/migrations`:数据库迁移 ## Docker 开发 -当前 `docker-compose.dev.yml` 已切到源码挂载模式: +开发版 Compose 会把源码挂载进容器: - `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接 -- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用 -- 后端容器启动时执行 `npm install` 和 `npm run dev` -- 平时改 `src/` 或 `.env`,一般都不需要重建镜像 -- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖 +- 后端容器启动时执行依赖安装和 `npm run dev` +- 平时改 `src/` 或 `.env`,一般不需要重建镜像 -只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`: +只有下面几类改动通常还需要重新构建 backend: -- `deploy/docker/backend.Dockerfile` -- 系统层依赖 -- Playwright 浏览器基础环境 +- `deploy/docker/backend*.Dockerfile` +- Node 版本或系统层依赖 +- `package.json` / `package-lock.json` -## 产物 - -浏览器会话产物默认保存在: - -- `data/browser-sessions//qq-qr.png` -- `data/browser-sessions//session.json` -- `data/browser-sessions//captcha-attempt-*.png` -- `data/browser-sessions//redeem-result.png` -- `data/browser-sessions//page.html` -- `data/browser-sessions//result.json` - -这些都是运行时产物,默认不提交 Git。 +## 运行产物 运行日志默认保存在: - `data/logs/app-YYYY-MM-DD.log` - `data/logs/webhook-YYYY-MM-DD.log` -日志按天切分,默认自动清理 7 天前的旧日志。 -成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 - -`LOG_LEVEL` 默认是 `info`: - -- `debug`:输出最详细的调试日志 -- `info`:输出启动、请求、业务成功/失败等常规日志 -- `warn`:只保留告警和错误 -- `error`:只保留错误 - -OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。 - -`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度: - -- `full`:完整证明 -- `basic`:只保留最终截图和结果 JSON -- `off`:不生成证明文件 +日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 9e587fd5..30a329c8 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -9,8 +9,7 @@ "version": "0.1.0", "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", @@ -911,20 +910,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1275,36 +1260,6 @@ "split2": "^4.1.0" } }, - "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.42.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 22bff426..23e1d140 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,27 +4,18 @@ "private": true, "type": "module", "scripts": { - "browser:install": "playwright install chromium", - "browser:install:linux": "playwright install --with-deps chromium", - "agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts", "cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts", - "seed:dev-data": "tsx scripts/seed-dev-data.ts", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", - "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "db:migrate": "tsx src/db/migrate.ts", "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:kuaishou": "tsx watch --clear-screen=false src/index-kuaishou.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", - "typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit", "start": "node dist/index.js", - "start:kuaishou": "node dist/index-kuaishou.js", "start:src": "tsx src/index.ts" }, "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", diff --git a/apps/backend/package.kuaishou.json b/apps/backend/package.kuaishou.json deleted file mode 100644 index 10d71dfb..00000000 --- a/apps/backend/package.kuaishou.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "order-site-backend-kuaishou", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", - "db:migrate": "tsx src/db/migrate.ts", - "start:kuaishou": "node dist/index-kuaishou.js" - }, - "dependencies": { - "express": "^5.1.0", - "pg": "^8.16.3" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", - "@types/pg": "^8.20.0", - "tsx": "^4.22.3", - "typescript": "^6.0.2" - } -} diff --git a/apps/backend/scripts/seed-dev-data.ts b/apps/backend/scripts/seed-dev-data.ts deleted file mode 100644 index 45444b50..00000000 --- a/apps/backend/scripts/seed-dev-data.ts +++ /dev/null @@ -1,513 +0,0 @@ -#!/usr/bin/env node - -import process from 'node:process' - -import { runtimeConfig } from '../src/config/runtime.js' -import { closeDb, query } from '../src/db/client.js' -import { runDatabaseMigrations } from '../src/db/migrate.js' -import { updateClaimToken } from '../src/repositories/claim-token-repo.js' -import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js' -import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js' -import { getTaskById, updateTask } from '../src/repositories/task-repo.js' -import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js' -import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js' -import { upsertOrderFromWebhook } from '../src/services/order/order-service.js' -import { addHours, nowIso } from '../src/utils/time.js' - -const RESET_TABLES = [ - 'message_deliveries', - 'tencent_browser_contexts', - 'task_events', - 'claim_tokens', - 'task_inventory_bindings', - 'fulfillment_tasks', - 'order_items', - 'orders', - 'webhook_events', - 'product_match_rules', - 'inventory_items', -] - -const SEED_PROVIDER = 'seed' -const SEED_PLATFORM = 'sandbox' -const SEED_SHOP_ID = 'seed-shop' -const SEED_SHOP_NAME = 'Seed Sandbox Shop' - -const options = parseArgs(process.argv.slice(2)) - -if (options.help) { - printHelp() - process.exit(0) -} - -try { - printPreview() - - if (!options.apply) { - console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。') - process.exit(0) - } - - await runDatabaseMigrations() - await ensureFulfillmentCatalogBootstrapped() - - if (options.reset) { - await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`) - console.log('\n已先清空测试业务数据。') - } - - await ensureSeedBindings() - await createSeedInventory() - - const manualPending = await seedScenario({ - scenarioKey: 'manual_pending', - orderLabel: 'SEED-MANUAL-PENDING', - skuCode: 'seed-manual-pending', - skuName: '开发种子-人工履约待处理', - }) - - const manualCompleted = await seedScenario({ - scenarioKey: 'manual_completed', - orderLabel: 'SEED-MANUAL-COMPLETED', - skuCode: 'seed-manual-completed', - skuName: '开发种子-人工履约已完成', - afterCreate: async (result) => { - const task = result.tasks[0] - if (!task) { - return null - } - - await completeAdminTaskManualDispatch(task.id, { - outcome: 'delivered', - resultMessage: '开发种子数据:人工履约已完成', - deliveryReference: 'SEED-MANUAL-REF-001', - deliveredCredential: '开发种子数据:已通过人工渠道发放', - }) - - return getTaskById(task.id) - }, - }) - - const claimReady = await seedScenario({ - scenarioKey: 'claim_ready', - orderLabel: 'SEED-CLAIM-READY', - skuCode: 'seed-claim-ready', - skuName: '开发种子-领取兑换可用', - }) - - const claimClaimed = await seedScenario({ - scenarioKey: 'claim_claimed', - orderLabel: 'SEED-CLAIM-CLAIMED', - skuCode: 'seed-claim-claimed', - skuName: '开发种子-已打开领取链接', - afterCreate: async (result) => applyClaimedState(result.tasks[0]), - }) - - const claimRoleConfirmed = await seedScenario({ - scenarioKey: 'claim_role_confirmed', - orderLabel: 'SEED-CLAIM-ROLE', - skuCode: 'seed-claim-role-confirmed', - skuName: '开发种子-已确认角色', - afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]), - }) - - const claimRetryPending = await seedScenario({ - scenarioKey: 'claim_retry_pending', - orderLabel: 'SEED-CLAIM-RETRY', - skuCode: 'seed-claim-retry', - skuName: '开发种子-等待重试', - afterCreate: async (result) => applyRetryPendingState(result.tasks[0]), - }) - - const claimExpired = await seedScenario({ - scenarioKey: 'claim_expired', - orderLabel: 'SEED-CLAIM-EXPIRED', - skuCode: 'seed-claim-expired', - skuName: '开发种子-领取链接已过期', - afterCreate: async (result) => applyExpiredState(result.tasks[0]), - }) - - const claimWaitingInventory = await seedScenario({ - scenarioKey: 'claim_waiting_inventory', - orderLabel: 'SEED-CLAIM-WAIT', - skuCode: 'seed-claim-wait', - skuName: '开发种子-等待库存', - }) - - printResultSummary([ - manualPending, - manualCompleted, - claimReady, - claimClaimed, - claimRoleConfirmed, - claimRetryPending, - claimExpired, - claimWaitingInventory, - ]) -} catch (error) { - console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误')) - process.exitCode = 1 -} finally { - await closeDb() -} - -async function ensureSeedBindings() { - const timestamp = nowIso() - const manualProfile = await getRequiredProfile('manual_review') - const claimProfile = await getRequiredProfile('tencent_claim_redeem') - - const bindings = [ - { skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 }, - { skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 }, - { skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 }, - { skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 }, - { skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 }, - { skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 }, - { skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 }, - { skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 }, - ] - - for (const binding of bindings) { - await upsertSkuFulfillmentBinding({ - skuCode: binding.skuCode, - provider: SEED_PROVIDER, - platform: SEED_PLATFORM, - shopId: SEED_SHOP_ID, - profileId: binding.profileId, - enabled: true, - priority: binding.priority, - configJson: JSON.stringify({ - seededBy: 'scripts/seed-dev-data.ts', - scenario: binding.skuCode, - }), - createdAt: timestamp, - updatedAt: timestamp, - }) - } -} - -async function createSeedInventory() { - const timestamp = nowIso() - - await createInventoryItems([ - { - skuCode: 'seed-claim-ready', - displayValue: 'SEED-CLAIM-CODE-READY-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-READY-001', - scenario: 'claim_ready', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-claimed', - displayValue: 'SEED-CLAIM-CODE-CLAIMED-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-CLAIMED-001', - scenario: 'claim_claimed', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-role-confirmed', - displayValue: 'SEED-CLAIM-CODE-ROLE-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-ROLE-001', - scenario: 'claim_role_confirmed', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-retry', - displayValue: 'SEED-CLAIM-CODE-RETRY-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-RETRY-001', - scenario: 'claim_retry_pending', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-expired', - displayValue: 'SEED-CLAIM-CODE-EXPIRED-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-EXPIRED-001', - scenario: 'claim_expired', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-spare-available', - displayValue: 'SEED-SPARE-CODE-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-SPARE-CODE-001', - scenario: 'spare_available', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - ]) -} - -async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) { - const result = await upsertOrderFromWebhook(buildSeedOrderEvent({ - scenarioKey, - orderLabel, - skuCode, - skuName, - })) - - const task = typeof afterCreate === 'function' - ? await afterCreate(result) - : result.tasks[0] || null - - return { - scenarioKey, - orderId: result.order.id, - platformOrderId: result.order.platform_order_id, - taskId: task?.id || null, - taskNo: task?.task_no || '', - taskStatus: task?.task_status || '', - deliveryStatus: task?.delivery_status || '', - inventoryStatus: task?.inventory_status || '', - inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null, - claimTokenId: Number(task?.primary_claim_token_id || 0) || null, - executorKey: String(task?.executor_key || '').trim(), - } -} - -async function applyClaimedState(task) { - if (!task) { - return null - } - - const now = nowIso() - await updateTask(task.id, { - task_status: 'claimed', - user_action_status: 'claimed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - claimed_at: now, - updated_at: now, - last_error: '', - }) - - return getTaskById(task.id) -} - -async function applyRoleConfirmedState(task) { - if (!task) { - return null - } - - const claimedAt = addHours(nowIso(), -1) - const roleConfirmedAt = nowIso() - await updateTask(task.id, { - task_status: 'role_confirmed', - user_action_status: 'role_confirmed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - nickname: 'SeedHero', - role_id: 'seed-role-1001', - role_name: '开发角色-已确认', - area: 'seed-area', - partition_name: 'seed-partition', - claimed_at: claimedAt, - role_confirmed_at: roleConfirmedAt, - updated_at: roleConfirmedAt, - last_error: '', - }) - - return getTaskById(task.id) -} - -async function applyRetryPendingState(task) { - if (!task) { - return null - } - - const claimedAt = addHours(nowIso(), -2) - const roleConfirmedAt = addHours(nowIso(), -1) - const updatedAt = nowIso() - await updateTask(task.id, { - task_status: 'retry_pending', - delivery_status: 'pending', - inventory_status: 'reserved', - user_action_status: 'role_confirmed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - nickname: 'SeedRetryUser', - role_id: 'seed-role-retry', - role_name: '开发角色-待重试', - area: 'seed-area', - partition_name: 'seed-partition', - claimed_at: claimedAt, - role_confirmed_at: roleConfirmedAt, - attempt_count: 2, - last_error: '开发种子数据:模拟兑换失败,等待重试', - updated_at: updatedAt, - }) - - return getTaskById(task.id) -} - -async function applyExpiredState(task) { - if (!task) { - return null - } - - const now = nowIso() - if (task.primary_claim_token_id) { - await updateClaimToken(task.primary_claim_token_id, { - status: 'expired', - expired_at: addHours(now, -2), - updated_at: now, - }) - } - - if (task.primary_inventory_item_id) { - await releaseReservedInventoryItem(task.primary_inventory_item_id, now) - } - - await updateTask(task.id, { - task_status: 'expired', - inventory_status: 'pending', - user_action_status: 'expired', - last_error: '领取链接已过期,预占库存项已释放', - updated_at: now, - }) - - return getTaskById(task.id) -} - -function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) { - const timestamp = nowIso() - - return { - provider: SEED_PROVIDER, - platform: SEED_PLATFORM, - shopId: SEED_SHOP_ID, - shopName: SEED_SHOP_NAME, - platformOrderId: orderLabel, - orderStatus: 'paid', - payStatus: 'paid', - buyerId: `${scenarioKey}-buyer`, - buyerName: `开发种子-${scenarioKey}`, - receiverContact: `seed+${scenarioKey}@example.com`, - totalAmount: 100, - currency: 'CNY', - paidAt: timestamp, - rawPayload: { - source: 'seed-dev-data', - scenarioKey, - platformOrderId: orderLabel, - }, - items: [ - { - skuCode, - skuName, - quantity: 1, - spec: { - title: skuName, - scenarioKey, - }, - snapshot: { - title: skuName, - scenarioKey, - seeded: true, - }, - }, - ], - } -} - -async function getRequiredProfile(profileKey) { - const profile = await getFulfillmentProfileByKey(profileKey) - - if (!profile) { - throw new Error(`缺少履约档案: ${profileKey}`) - } - - return profile -} - -function parseArgs(argv) { - return { - apply: argv.includes('--apply'), - reset: argv.includes('--reset'), - help: argv.includes('-h') || argv.includes('--help'), - } -} - -function printHelp() { - console.log(` -用法: - npm run seed:dev-data -- [--apply] [--reset] - -说明: - 基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。 - -场景: - - manual_pending: 人工履约待处理 - - manual_completed: 人工履约已完成 - - claim_ready: 已预占库存并生成领取链接 - - claim_claimed: 用户已打开领取链接 - - claim_role_confirmed: 用户已确认角色 - - claim_retry_pending: 自动履约失败等待重试 - - claim_expired: 领取链接已过期 - - claim_waiting_inventory: 等待库存 - -参数: - --apply 实际写入数据 - --reset 写入前先清空测试业务数据 - -数据库连接: - ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'} -`.trim()) -} - -function printPreview() { - console.log('准备生成开发种子数据:') - console.log('- manual_pending -> 人工履约待处理') - console.log('- manual_completed -> 人工履约已完成') - console.log('- claim_ready -> 领取兑换已就绪') - console.log('- claim_claimed -> 用户已打开领取链接') - console.log('- claim_role_confirmed -> 用户已确认角色') - console.log('- claim_retry_pending -> 自动履约失败等待重试') - console.log('- claim_expired -> 领取链接已过期') - console.log('- claim_waiting_inventory -> 等待库存') - console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`) -} - -function printResultSummary(rows) { - console.log('\n开发种子数据已写入:') - - for (const row of rows) { - console.log(`- ${row.scenarioKey}`) - console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`) - console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`) - console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`) - console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`) - } - - console.log('\n建议验证入口:') - console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`') - console.log('- `/api/v1/admin/tasks?taskNo=DT`') - console.log('- `/api/v1/admin/inventory`') -} diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh index ec4373ea..69990b1c 100644 --- a/apps/backend/scripts/start-dev-container.sh +++ b/apps/backend/scripts/start-dev-container.sh @@ -1,97 +1,5 @@ #!/bin/sh set -eu -is_true() { - normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]') - case "$normalized" in - 1|true|yes|on) - return 0 - ;; - *) - return 1 - ;; - esac -} - -start_browser_debug_services() { - display="${TENCENT_BROWSER_DISPLAY:-:99}" - resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}" - display_number="${display#:}" - display_number="${display_number%%.*}" - display_socket="/tmp/.X11-unix/X${display_number}" - display_lock="/tmp/.X${display_number}-lock" - - if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then - rm -f "$display_socket" - fi - - if [ -f "$display_lock" ]; then - display_pid=$(cat "$display_lock" 2>/dev/null || true) - if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then - rm -f "$display_lock" "$display_socket" - fi - fi - - export DISPLAY="$display" - Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 & - xvfb_pid=$! - - ready=0 - attempt=0 - while [ "$attempt" -lt 50 ]; do - if [ -S "$display_socket" ]; then - ready=1 - break - fi - - if ! kill -0 "$xvfb_pid" 2>/dev/null; then - break - fi - - attempt=$((attempt + 1)) - sleep 0.1 - done - - if [ "$ready" -ne 1 ]; then - echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2 - return - fi - - if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled" - return - fi - - vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}" - novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}" - - x11vnc \ - -display "$display" \ - -rfbport "$vnc_port" \ - -forever \ - -shared \ - -nopw \ - -listen 0.0.0.0 \ - -xkb >/tmp/x11vnc.log 2>&1 & - - if command -v novnc_proxy >/dev/null 2>&1; then - novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then - /usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then - websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - else - echo "[backend-dev] noVNC requested but novnc/websockify not found" - return - fi - - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html" -} - npm install --no-fund --no-audit - -if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then - start_browser_debug_services -fi - exec npm run dev diff --git a/apps/backend/scripts/test-agiso-auto-delivery.ts b/apps/backend/scripts/test-agiso-auto-delivery.ts deleted file mode 100644 index 5dac381b..00000000 --- a/apps/backend/scripts/test-agiso-auto-delivery.ts +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env node - -import crypto from 'node:crypto' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { setTimeout as sleep } from 'node:timers/promises' - -import { query, closeDb } from '../src/db/client.js' -import { parseJsonObject } from '../src/utils/json.js' -import { getAgisoShopConfig } from '../src/services/platforms/agiso/shop-config-service.js' -import { queryAgisoXianyuOrderDetail } from '../src/services/platforms/agiso/xianyu/order-detail-service.js' - -const DEFAULT_DUMMY_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend' -const DEFAULT_POLL_SECONDS = [0, 3, 10] - -async function main() { - const options = parseArgs(process.argv.slice(2)) - if (options.help || !options.orderId) { - printHelp() - process.exit(options.help ? 0 : 1) - } - - const orderRecord = await resolveOrderRecord(options) - const shopId = orderRecord?.shop_id || options.shopId - const shopConfig = getAgisoShopConfig(shopId) || {} - const accessToken = String(shopConfig.accessToken || '').trim() - const appSecret = String(shopConfig.appSecret || process.env.AGISO_APP_SECRET || '').trim() - - if (!shopId) { - throw new Error('未提供 shopId,且数据库中也没有查到该订单对应的店铺') - } - - if (!accessToken) { - throw new Error(`店铺 ${shopId} 缺少 accessToken 配置,请检查 apps/backend/data/agiso-shops.json`) - } - - if (!appSecret) { - throw new Error('缺少 AGISO_APP_SECRET 或店铺级 appSecret 配置') - } - - printJson('测试输入', { - orderId: options.orderId, - shopId, - pollSeconds: options.pollSeconds, - orderRecord: orderRecord - ? { - id: Number(orderRecord.id || 0) || null, - platformOrderId: String(orderRecord.platform_order_id || '').trim(), - orderStatus: String(orderRecord.order_status || '').trim(), - payStatus: String(orderRecord.pay_status || '').trim(), - provider: String(orderRecord.provider || '').trim(), - platform: String(orderRecord.platform || '').trim(), - } - : null, - }) - - const beforeDetail = await queryAgisoXianyuOrderDetail({ - shopId, - platformOrderId: options.orderId, - requestId: `diag-before-${Date.now()}`, - }) - printJson('调用前订单详情', summarizeDetail(beforeDetail)) - - const result = await executeRequest({ - platformOrderId: options.orderId, - accessToken, - appSecret, - }) - printJson('接口响应 更新发货状态 DummySend', result) - - for (const seconds of options.pollSeconds) { - if (seconds > 0) { - await sleep(seconds * 1000) - } - - const detail = await queryAgisoXianyuOrderDetail({ - shopId, - platformOrderId: options.orderId, - requestId: `diag-after-${seconds}s-${Date.now()}`, - }) - printJson(`调用后订单详情 +${seconds}s`, summarizeDetail(detail)) - } -} - -function parseArgs(argv) { - const output = { - orderId: '', - shopId: '', - pollSeconds: [...DEFAULT_POLL_SECONDS], - help: false, - } - - for (let index = 0; index < argv.length; index += 1) { - const current = String(argv[index] || '').trim() - if (!current) { - continue - } - - if (!current.startsWith('--')) { - if (!output.orderId) { - output.orderId = current - continue - } - - throw new Error(`无法识别的参数:${current}`) - } - - if (current === '--help') { - output.help = true - continue - } - - const [rawKey, inlineValue = ''] = current.split('=', 2) - const key = rawKey.slice(2) - const nextValue = inlineValue || argv[index + 1] || '' - const shouldConsumeNext = !inlineValue && argv[index + 1] && !String(argv[index + 1]).startsWith('--') - - switch (key) { - case 'shop-id': - output.shopId = String(nextValue || '').trim() - break - case 'poll': - output.pollSeconds = parsePollSeconds(nextValue) - break - default: - throw new Error(`无法识别的参数:${current}`) - } - - if (shouldConsumeNext) { - index += 1 - } - } - - return output -} - -function parsePollSeconds(value) { - const normalized = String(value || '').trim() - if (!normalized) { - return [...DEFAULT_POLL_SECONDS] - } - - const parsed = normalized - .split(',') - .map((item) => Number(String(item || '').trim())) - .filter((item) => Number.isFinite(item) && item >= 0) - .map((item) => Math.floor(item)) - - if (parsed.length === 0) { - throw new Error(`poll 参数格式无效:${value}`) - } - - return parsed -} - -async function resolveOrderRecord(options) { - const explicitShopId = String(options.shopId || '').trim() - if (explicitShopId) { - const result = await query( - ` - SELECT * - FROM orders - WHERE provider = 'agiso' - AND platform = 'xianyu' - AND shop_id = $1 - AND platform_order_id = $2 - ORDER BY id DESC - LIMIT 1 - `, - [explicitShopId, options.orderId], - ) - - return result.rows[0] || null - } - - const result = await query( - ` - SELECT * - FROM orders - WHERE provider = 'agiso' - AND platform = 'xianyu' - AND platform_order_id = $1 - ORDER BY id DESC - LIMIT 2 - `, - [options.orderId], - ) - - if (result.rows.length > 1) { - throw new Error(`数据库里命中多条同订单号记录,请显式传入 --shop-id。订单号:${options.orderId}`) - } - - return result.rows[0] || null -} - -async function executeRequest(context) { - const timestamp = String(Math.floor(Date.now() / 1000)) - const requestBody = buildDummySendBody(context, timestamp) - const response = await fetch(DEFAULT_DUMMY_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${context.accessToken}`, - ApiVersion: '1', - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - }, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) - - return { - endpoint: DEFAULT_DUMMY_ENDPOINT, - status: response.status, - requestBody, - response: payload, - rawText, - } -} - -function buildDummySendBody(context, timestamp) { - const payload = { - tid: context.platformOrderId, - timestamp, - } - - payload.sign = generateSign(payload, context.appSecret) - return payload -} - -function generateSign(params, appSecret) { - let raw = String(appSecret || '').trim() - for (const [key, value] of Object.entries(params).sort(([left], [right]) => left.localeCompare(right))) { - raw += `${key}${value}` - } - raw += String(appSecret || '').trim() - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function summarizeDetail(detailResult) { - return { - success: Boolean(detailResult?.success), - reason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - responseStatus: Number(detailResult?.responseStatus || 0), - shipped: Boolean(detailResult?.shipped), - shipTime: Number(detailResult?.shipTime || 0), - orderStatus: Number(detailResult?.orderStatus || 0), - bizOrderId: String(detailResult?.detailPayload?.biz_order_id || '').trim(), - sellerNick: String(detailResult?.detailPayload?.seller_nick || '').trim(), - buyerNick: String(detailResult?.detailPayload?.buyer_nick || '').trim(), - sku: String(detailResult?.detailPayload?.sku || '').trim(), - itemTitle: String(detailResult?.detailPayload?.item?.title || '').trim(), - } -} - -function printJson(title, value) { - process.stdout.write(`\n=== ${title} ===\n`) - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) -} - -function printHelp() { - process.stdout.write(`用法: - npm run agiso:auto-delivery:test -- <订单号> [--shop-id <店铺ID>] [--poll 0,3,10] - -说明: - 1. 该脚本只测试正确的发货接口 DummySend。 - 2. 会先查 Order/Detail,再调用 DummySend,再按 poll 秒数轮询发货状态。 - -示例: - npm run agiso:auto-delivery:test -- 4502285714045005830 - npm run agiso:auto-delivery:test -- 4502285714045005830 --poll 0,5,15 - npm run agiso:auto-delivery:test -- 4502285714045005830 --shop-id 2209880145223 -`) -} - -const isNodeTestRunner = Array.isArray(process.execArgv) && process.execArgv.includes('--test') -const isDirectRun = !isNodeTestRunner && process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] - -if (isDirectRun) { - main() - .catch((error) => { - process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`) - process.exitCode = 1 - }) - .finally(async () => { - await closeDb() - }) -} diff --git a/apps/backend/scripts/test-order-flow.sh b/apps/backend/scripts/test-order-flow.sh deleted file mode 100755 index 0924019a..00000000 --- a/apps/backend/scripts/test-order-flow.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE_URL="http://127.0.0.1:3000" -APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))") - -ORDER_ID="${1:-SIM-ORDER-20260408-0001}" -SKU_ID="32768" -SKU_NAME="海底捞套餐" - -sign_agiso() { - local raw_json="$1" - local ts="$2" - - node -e ' - const crypto = require("crypto") - const appSecret = process.argv[1] - const rawJson = process.argv[2] - const timestamp = process.argv[3] - const sign = crypto - .createHash("md5") - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, "utf8") - .digest("hex") - .toLowerCase() - process.stdout.write(sign) - ' "$APP_SECRET" "$raw_json" "$ts" -} - -push_agiso_event() { - local aopic="$1" - local raw_json="$2" - local ts - ts=$(date +%s) - local sign - sign=$(sign_agiso "$raw_json" "$ts") - - echo - echo "========== PUSH aopic=$aopic ==========" - echo "ORDER_ID=$ORDER_ID" - echo - - curl -sS -X POST "${BASE_URL}/api/v1/webhooks/agiso/trade?aopic=${aopic}×tamp=${ts}&sign=${sign}" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "json=${raw_json}" - - echo - echo -} - -create_payload() { - cat < boolean; - config: RuntimeConfig; -}; - -export function createKuaishouApp({ - startupState, - isShutdownStarted, - config, -}: CreateKuaishouAppOptions) { - const app = express(); - - app.use(accessLogMiddleware); - app.use(createCorsMiddleware(config)); - app.use(express.json({ limit: "2mb" })); - app.use(express.urlencoded({ extended: true })); - - app.get("/health", (_req, res) => { - res.json( - buildSuccessPayload( - buildHealthPayload(startupState, isShutdownStarted()), - startupState.core.ready ? "ready" : "starting" - ) - ); - }); - - app.get("/health/live", (_req, res) => { - res.json( - buildSuccessPayload({ - status: isShutdownStarted() ? "shutting_down" : "alive", - pid: process.pid, - mode: "kuaishou-lite", - }) - ); - }); - - app.get("/health/ready", (_req, res) => { - if (startupState.core.ready) { - res.json( - buildSuccessPayload( - buildHealthPayload(startupState, isShutdownStarted()), - "ready" - ) - ); - return; - } - - res.status(503).json({ - code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", - time: Math.floor(Date.now() / 1000), - data: buildHealthPayload(startupState, isShutdownStarted()), - }); - }); - - app.use((req, res, next) => { - if (startupState.core.ready) { - next(); - return; - } - - res.status(503).json({ - code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", - time: Math.floor(Date.now() / 1000), - data: { - startup: { - phase: startupState.phase, - attemptCount: startupState.core.attemptCount, - lastAttemptAt: startupState.core.lastAttemptAt, - }, - }, - }); - }); - - app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/claim", claimsRouter); - app.use("/api/v1/admin", adminRouter); - - app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { - sendRouteError(res, err, "服务内部错误", "[global:kuaishou]"); - }); - - return app; -} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index e11f35e4..195f5699 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,17 +1,16 @@ -import type { Request, Response, NextFunction } from "express"; +import type { NextFunction, Request, Response } from "express"; import express from "express"; import process from "node:process"; import adminRouter from "./routes/admin.js"; import claimsRouter from "./routes/claims.js"; import open91Router from "./routes/open-91.js"; -import tencentRouter from "./routes/tencent.js"; -import webhooksRouter from "./routes/webhooks.js"; -import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; -import type { RuntimeConfig } from "./types/runtime-config.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js"; +import type { RuntimeConfig } from "./types/runtime-config.js"; +import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; + type CreateAppOptions = { startupState: StartupState; isShutdownStarted: () => boolean; @@ -44,6 +43,7 @@ export function createApp({ buildSuccessPayload({ status: isShutdownStarted() ? "shutting_down" : "alive", pid: process.pid, + mode: "kuaishou-lite", }) ); }); @@ -89,9 +89,7 @@ export function createApp({ }); }); - app.use("/api/v1/tencent", tencentRouter); app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/webhooks", webhooksRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index aabc6eff..467de9e2 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { port: 3000, }, - browser: { - chromePath: "", - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: "1600x900x24", - }, - - session: { - debug: false, - }, - - ocr: { - baseUrl: "", - }, - data: { root: path.resolve(projectRoot, "data"), }, @@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, platforms: { - agiso: { - appSecret: "", - tradeDetail: { - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail", - apiVersion: "1", - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend", - apiVersion: "1", - }, - messaging: { - enabled: false, - sendMessageEndpoint: - "https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg", - apiVersion: "1", - messageTemplate: - "您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}", - autoDeliveryMessageTemplate: - "您的订单 {platformOrderId} 已完成自动发货,请注意查收。", - shops: {}, - }, - }, ninetyone: { userId: "", secret: "", @@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 cors: { allowedOrigins: ["*"], }, - - redeem: { - proofMode: "full", - }, }; } diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index dce702ae..609c84e6 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', 'NPM_CONFIG_REGISTRY', - 'OCR_IMAGE', - 'OCR_PORT', 'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_PORT', 'POSTGRES_USER', - 'TENCENT_BROWSER_NOVNC_ENABLED', - 'TENCENT_BROWSER_NOVNC_PORT', - 'TENCENT_BROWSER_VNC_PORT', 'TZ', 'VITE_API_TARGET', + 'VITE_ALLOWED_HOSTS', ] const KNOWN_ENV_NAMES = new Set([ diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index f63256a5..2f3cdb0b 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf const config = applyEnvOverrides(baseConfig, { PORT: '4000', - TENCENT_BROWSER_HEADLESS: 'true', - TENCENT_BROWSER_SLOW_MO: '250', - TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24', DATA_ROOT: './tmp/backend-data', DATABASE_SSL: 'yes', ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', - AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000', CLOUDTENTACLES_DEVICE_TYPE: '3', - TENCENT_REDEEM_PROOF_MODE: 'basic', }) assert.equal(config.server.port, 4000) - assert.equal(config.browser.headless, true) - assert.equal(config.browser.slowMoMs, 250) - assert.equal(config.browser.vncResolution, '1920x1080x24') assert.equal(config.data.root, path.resolve('./tmp/backend-data')) assert.equal(config.database.ssl, true) assert.deepEqual(config.admin.defaultUsers, [ { username: 'admin', password: 'secret', role: 'admin' }, ]) - assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000) assert.equal(config.platforms.cloudtentacles.deviceType, 3) - assert.equal(config.redeem.proofMode, 'basic') assert.equal(baseConfig.server.port, 3000) - assert.equal(baseConfig.browser.headless, null) assert.deepEqual(baseConfig.admin.defaultUsers, []) }) @@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig { server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, @@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig { defaultUsers: [], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig { deviceType: 0, }, }, - redeem: { - proofMode: 'full', - }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index e47e5222..0b018d35 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -30,15 +30,6 @@ type EnvOverride = { export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv("PORT", ["server", "port"]), - stringEnv("CHROME_PATH", ["browser", "chromePath"]), - booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]), - booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]), - booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]), - booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]), - integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]), - stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]), - booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]), - stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]), stringEnv("DATA_ROOT", ["data", "root"], path.resolve), stringEnv("LOG_LEVEL", ["logging", "level"]), stringEnv("DATABASE_URL", ["database", "url"]), @@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]), integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]), adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]), - stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]), - stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [ - "platforms", - "agiso", - "tradeDetail", - "endpoint", - ]), - stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [ - "platforms", - "agiso", - "tradeDetail", - "apiVersion", - ]), - integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [ - "platforms", - "agiso", - "tradeDetail", - "timeoutMs", - ]), - booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [ - "platforms", - "agiso", - "autoDelivery", - "enabled", - ]), - stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [ - "platforms", - "agiso", - "autoDelivery", - "endpoint", - ]), - stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [ - "platforms", - "agiso", - "autoDelivery", - "apiVersion", - ]), - stringEnv("AGISO_MESSAGE_API_VERSION", [ - "platforms", - "agiso", - "messaging", - "apiVersion", - ]), - stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [ - "platforms", - "agiso", - "messaging", - "sendMessageEndpoint", - ]), - booleanEnv("AGISO_MESSAGING_ENABLED", [ - "platforms", - "agiso", - "messaging", - "enabled", - ]), stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]), stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]), stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]), @@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "cloudtentacles", "deviceType", ]), - stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), ]; diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 6def1efc..ba403a20 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, @@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo ], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo deviceType: 0, }, }, - redeem: { - proofMode: 'full', - }, }, overrides) as RuntimeConfig } diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0e3fcebb..6a68eb80 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -47,7 +47,6 @@ export function validateRuntimeConfig( requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) - requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) requireInteger( diff --git a/apps/backend/src/index-kuaishou.ts b/apps/backend/src/index-kuaishou.ts deleted file mode 100644 index ec1ce6ea..00000000 --- a/apps/backend/src/index-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import process from "node:process"; - -import { createKuaishouApp } from "./app-kuaishou.js"; -import { runtimeConfig } from "./config/runtime.js"; -import { assertRuntimeConfigValid } from "./config/runtime-validation.js"; -import { bootstrapKuaishouCoreServices } from "./startup/bootstrap-kuaishou.js"; -import { createKuaishouShutdownController } from "./startup/shutdown-kuaishou.js"; -import { createStartupState, formatStartupError } from "./startup/state.js"; -import { logError, logInfo } from "./utils/logger.js"; - -const port = Number(runtimeConfig.server.port || 3000); -const host = "0.0.0.0"; -const startupState = createStartupState(); - -let shutdownController: ReturnType< - typeof createKuaishouShutdownController -> | null = null; - -try { - assertRuntimeConfigValid(runtimeConfig); -} catch (error) { - logError("[startup:kuaishou]", "运行时配置校验失败,服务停止启动", error); - process.exit(1); -} - -const app = createKuaishouApp({ - startupState, - isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, - config: runtimeConfig, -}); - -const server = app.listen(port, host, () => { - logInfo( - "[startup:kuaishou]", - `order-site-backend kuaishou-lite listening on http://${host}:${port}` - ); - void bootstrapKuaishouCoreServices( - startupState, - () => shutdownController?.isShutdownStarted() || false - ); -}); - -shutdownController = createKuaishouShutdownController(server, startupState); - -server.on("error", (error) => { - logError("[startup:kuaishou]", "HTTP server failed", error); -}); - -process.on("SIGINT", () => { - void shutdownController?.shutdown("SIGINT"); -}); - -process.on("SIGTERM", () => { - void shutdownController?.shutdown("SIGTERM"); -}); - -process.on("unhandledRejection", (reason) => { - startupState.process.lastUnhandledRejection = { - time: new Date().toISOString(), - message: formatStartupError(reason), - }; - logError("[process:kuaishou]", "unhandled promise rejection", reason); -}); - -process.on("uncaughtException", (error) => { - startupState.process.lastUncaughtException = { - time: new Date().toISOString(), - message: formatStartupError(error), - }; - logError("[process:kuaishou]", "uncaught exception captured", error); -}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f0bbbae0..ffb144c5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -20,6 +20,7 @@ try { logError("[startup]", "运行时配置校验失败,服务停止启动", error); process.exit(1); } + const app = createApp({ startupState, isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, @@ -27,7 +28,10 @@ const app = createApp({ }); const server = app.listen(port, host, () => { - logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`); + logInfo( + "[startup]", + `order-site-backend kuaishou-lite listening on http://${host}:${port}` + ); void bootstrapCoreServices( startupState, () => shutdownController?.isShutdownStarted() || false diff --git a/apps/backend/src/routes/admin-kuaishou.ts b/apps/backend/src/routes/admin-kuaishou.ts deleted file mode 100644 index d329014c..00000000 --- a/apps/backend/src/routes/admin-kuaishou.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router } from "express"; - -import authRouter from "./admin/auth.js"; -import auditLogsRouter from "./admin/audit-logs.js"; -import dashboardRouter from "./admin/dashboard.js"; -import inventoryRouter from "./admin/inventory-kuaishou.js"; -import messageDeliveriesRouter from "./admin/message-deliveries.js"; -import ordersRouter from "./admin/orders.js"; -import platformConfigRouter from "./admin/platform-config-kuaishou.js"; -import { requireAdminSession } from "./admin/shared.js"; -import tasksRouter from "./admin/tasks-kuaishou.js"; -import usersRouter from "./admin/users.js"; -import webhookEventsRouter from "./admin/webhook-events-kuaishou.js"; -import { buildNotFoundPayload } from "../utils/http.js"; - -const router = Router(); - -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(inventoryRouter); -router.use(messageDeliveriesRouter); -router.use(webhookEventsRouter); - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); - -export default router; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index a0e80c04..6a8e3e2c 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -1,36 +1,34 @@ -import { Router } from 'express' +import { Router } from "express"; -import authRouter from './admin/auth.js' -import auditLogsRouter from './admin/audit-logs.js' -import dashboardRouter from './admin/dashboard.js' -import inventoryRouter from './admin/inventory.js' -import manualRedeemRouter from './admin/manual-redeem.js' -import messageDeliveriesRouter from './admin/message-deliveries.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' +import authRouter from "./admin/auth.js"; +import auditLogsRouter from "./admin/audit-logs.js"; +import dashboardRouter from "./admin/dashboard.js"; +import inventoryRouter from "./admin/inventory.js"; +import messageDeliveriesRouter from "./admin/message-deliveries.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() +const router = Router(); -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(manualRedeemRouter) -router.use(inventoryRouter) -router.use(messageDeliveriesRouter) -router.use(webhookEventsRouter) +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(inventoryRouter); +router.use(messageDeliveriesRouter); +router.use(webhookEventsRouter); router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) + res.status(404).json(buildNotFoundPayload(req)); +}); -export default router +export default router; diff --git a/apps/backend/src/routes/admin/inventory-kuaishou.ts b/apps/backend/src/routes/admin/inventory-kuaishou.ts deleted file mode 100644 index 652c7899..00000000 --- a/apps/backend/src/routes/admin/inventory-kuaishou.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Router } from "express"; - -import { - createAdminInventoryItem, - importAdminInventoryItems, - invalidateAdminInventoryItem, - releaseAdminInventoryItem, -} from "../../services/admin/write/inventory.js"; -import { - getAdminInventoryItems, - getAdminInventorySkuSuggestions, -} from "../../services/admin/admin-read-service.js"; -import { createJsonHandler, requireAdminRoles } from "./shared.js"; -import type { - AdminInventoryCreateRouteBody, - AdminInventoryImportRouteBody, - AdminInventoryInvalidateRouteBody, - AdminInventoryRouteParams, - AdminInventoryRouteQuery, - AdminInventorySkuSuggestionRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js"; - -const router = Router(); - -router.use("/inventory", requireAdminRoles(["admin", "operator"])); - -router.get( - "/inventory", - createJsonHandler( - (req) => - getAdminInventoryItems( - req.query as AdminInventoryRouteQuery, - req.adminSession || null - ), - { - successMessage: "ok", - errorMessage: "读取库存列表失败", - scope: "[admin-kuaishou/inventory]", - } - ) -); - -router.get( - "/inventory/sku-suggestions", - createJsonHandler( - (req) => - getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null - ), - { - successMessage: "ok", - errorMessage: "读取内部 SKU 建议失败", - scope: "[admin-kuaishou/inventory/sku-suggestions]", - } - ) -); - -router.post( - "/inventory", - createJsonHandler( - (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), - { - successMessage: "库存项已新增", - errorMessage: "新增库存项失败", - scope: "[admin-kuaishou/inventory]", - } - ) -); - -router.post( - "/inventory/import", - createJsonHandler( - (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), - { - successMessage: "导入完成", - errorMessage: "导入库存凭据失败", - scope: "[admin-kuaishou/inventory/import]", - } - ) -); - -router.post( - "/inventory/:inventoryItemId/release", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - releaseAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - { - successMessage: "库存项已释放", - errorMessage: "释放库存项失败", - scope: "[admin-kuaishou/inventory/:inventoryItemId/release]", - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse; - - return { - action: "inventory_item_released", - targetType: "inventory_item", - targetId: String( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - displayValue: result.inventoryItem?.displayValue, - }, - }; - }, - } - ) -); - -router.post( - "/inventory/:inventoryItemId/invalidate", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - invalidateAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId, - req.body as AdminInventoryInvalidateRouteBody - ), - { - successMessage: "库存项已作废", - errorMessage: "作废库存项失败", - scope: "[admin-kuaishou/inventory/:inventoryItemId/invalidate]", - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse; - - return { - action: "inventory_item_invalidated", - targetType: "inventory_item", - targetId: String( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - invalidReason: result.inventoryItem?.invalidReason, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/inventory.ts b/apps/backend/src/routes/admin/inventory.ts index 8f135c02..b446d580 100644 --- a/apps/backend/src/routes/admin/inventory.ts +++ b/apps/backend/src/routes/admin/inventory.ts @@ -1,16 +1,16 @@ -import { Router } from 'express' +import { Router } from "express"; import { createAdminInventoryItem, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem, -} from '../../services/admin/admin-write-service.js' +} from "../../services/admin/write/inventory.js"; import { getAdminInventoryItems, getAdminInventorySkuSuggestions, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +} from "../../services/admin/admin-read-service.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; import type { AdminInventoryCreateRouteBody, AdminInventoryImportRouteBody, @@ -18,100 +18,132 @@ import type { AdminInventoryRouteParams, AdminInventoryRouteQuery, AdminInventorySkuSuggestionRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js' +} from "../../types/admin-route-inputs.js"; +import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js"; -const router = Router() +const router = Router(); -router.use('/inventory', requireAdminRoles(['admin', 'operator'])) +router.use("/inventory", requireAdminRoles(["admin", "operator"])); -router.get('/inventory', createJsonHandler( - (req) => getAdminInventoryItems( - req.query as AdminInventoryRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取库存列表失败', - scope: '[admin/inventory]', - }, -)) +router.get( + "/inventory", + createJsonHandler( + (req) => + getAdminInventoryItems( + req.query as AdminInventoryRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取库存列表失败", + scope: "[admin/inventory]", + } + ) +); -router.get('/inventory/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取内部 SKU 建议失败', - scope: '[admin/inventory/sku-suggestions]', - }, -)) +router.get( + "/inventory/sku-suggestions", + createJsonHandler( + (req) => + getAdminInventorySkuSuggestions( + req.query as AdminInventorySkuSuggestionRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取内部 SKU 建议失败", + scope: "[admin/inventory/sku-suggestions]", + } + ) +); -router.post('/inventory', createJsonHandler( - (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), - { - successMessage: '库存项已新增', - errorMessage: '新增库存项失败', - scope: '[admin/inventory]', - }, -)) +router.post( + "/inventory", + createJsonHandler( + (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), + { + successMessage: "库存项已新增", + errorMessage: "新增库存项失败", + scope: "[admin/inventory]", + } + ) +); -router.post('/inventory/import', createJsonHandler( - (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), - { - successMessage: '导入完成', - errorMessage: '导入库存凭据失败', - scope: '[admin/inventory/import]', - }, -)) +router.post( + "/inventory/import", + createJsonHandler( + (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), + { + successMessage: "导入完成", + errorMessage: "导入库存凭据失败", + scope: "[admin/inventory/import]", + } + ) +); -router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId), - { - successMessage: '库存项已释放', - errorMessage: '释放库存项失败', - scope: '[admin/inventory/:inventoryItemId/release]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_released', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - displayValue: result.inventoryItem?.displayValue, - }, - } - }, - }, -)) +router.post( + "/inventory/:inventoryItemId/release", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + releaseAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + { + successMessage: "库存项已释放", + errorMessage: "释放库存项失败", + scope: "[admin/inventory/:inventoryItemId/release]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; -router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( - (req) => invalidateAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId, - req.body as AdminInventoryInvalidateRouteBody, - ), - { - successMessage: '库存项已作废', - errorMessage: '作废库存项失败', - scope: '[admin/inventory/:inventoryItemId/invalidate]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_invalidated', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - invalidReason: result.inventoryItem?.invalidReason, - }, - } - }, - }, -)) + return { + action: "inventory_item_released", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + displayValue: result.inventoryItem?.displayValue, + }, + }; + }, + } + ) +); -export default router +router.post( + "/inventory/:inventoryItemId/invalidate", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + invalidateAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId, + req.body as AdminInventoryInvalidateRouteBody + ), + { + successMessage: "库存项已作废", + errorMessage: "作废库存项失败", + scope: "[admin/inventory/:inventoryItemId/invalidate]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; + + return { + action: "inventory_item_invalidated", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + invalidReason: result.inventoryItem?.invalidReason, + }, + }; + }, + } + ) +); + +export default router; diff --git a/apps/backend/src/routes/admin/manual-redeem.ts b/apps/backend/src/routes/admin/manual-redeem.ts deleted file mode 100644 index dd4af358..00000000 --- a/apps/backend/src/routes/admin/manual-redeem.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Router } from 'express' - -import { - closeAdminManualRedeemSession, - closeAdminManualRedeemTask, - confirmAdminManualRedeemRole, - createAdminManualRedeemSession, - createAdminManualRedeemTask, - getAdminManualRedeemDetail, - getAdminManualRedeemSessionSummary, - reloadAdminManualRedeemSession, - redeemAdminManualRedeemTask, -} from '../../services/admin/admin-manual-redeem-service.js' -import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' -import type { - AdminInventorySkuSuggestionRouteQuery, - AdminManualRedeemCreateRouteBody, - AdminManualRedeemRouteParams, -} from '../../types/admin-route-inputs.js' - -type AdminManualRedeemSessionRouteBody = { - loginType?: string - forceRecreate?: boolean -} - -type AdminManualRedeemMutationResult = { - task?: { - taskId?: number - taskNo?: string - status?: string - deliveryStatus?: string - } - manualRequest?: { proofValue?: string } - order?: { platformOrderId?: string } - orderItem?: { skuCode?: string } -} - -const router = Router() - -router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support'])) - -router.get('/manual-redeem/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换 SKU 建议失败', - scope: '[admin/manual-redeem/sku-suggestions]', - }, -)) - -router.post('/manual-redeem', createJsonHandler( - (req) => createAdminManualRedeemTask( - req.body as AdminManualRedeemCreateRouteBody, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已创建', - errorMessage: '创建人工兑换任务失败', - scope: '[admin/manual-redeem]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_created', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '', - skuCode: result.orderItem?.skuCode || '', - }, - } - }, - }, -)) - -router.get('/manual-redeem/:taskId', createJsonHandler( - (req) => getAdminManualRedeemDetail( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换详情失败', - scope: '[admin/manual-redeem/:taskId]', - }, -)) - -router.post('/manual-redeem/:taskId/session', createJsonHandler( - (req) => createAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.body as AdminManualRedeemSessionRouteBody, - req.adminSession || null, - ), - { - successMessage: '人工兑换登录会话已创建', - errorMessage: '创建人工兑换登录会话失败', - scope: '[admin/manual-redeem/:taskId/session]', - }, -)) - -router.get('/manual-redeem/:taskId/session/summary', createJsonHandler( - (req) => getAdminManualRedeemSessionSummary( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换会话摘要失败', - scope: '[admin/manual-redeem/:taskId/session/summary]', - }, -)) - -router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler( - (req) => reloadAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换会话已刷新', - errorMessage: '刷新人工兑换会话失败', - scope: '[admin/manual-redeem/:taskId/session/refresh]', - }, -)) - -router.delete('/manual-redeem/:taskId/session', createJsonHandler( - (req) => closeAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换会话已关闭', - errorMessage: '关闭人工兑换会话失败', - scope: '[admin/manual-redeem/:taskId/session]', - }, -)) - -router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler( - (req) => confirmAdminManualRedeemRole( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '角色已确认', - errorMessage: '确认人工兑换角色失败', - scope: '[admin/manual-redeem/:taskId/confirm-role]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_role_confirmed', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - }, - } - }, - }, -)) - -router.post('/manual-redeem/:taskId/redeem', createJsonHandler( - (req) => redeemAdminManualRedeemTask( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已启动', - errorMessage: '执行人工兑换失败', - scope: '[admin/manual-redeem/:taskId/redeem]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_started', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - deliveryStatus: result.task?.deliveryStatus, - }, - } - }, - }, -)) - -router.post('/manual-redeem/:taskId/close', createJsonHandler( - (req) => closeAdminManualRedeemTask( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已关闭', - errorMessage: '关闭人工兑换任务失败', - scope: '[admin/manual-redeem/:taskId/close]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_closed', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - }, - } - }, - }, -)) - -export default router diff --git a/apps/backend/src/routes/admin/platform-config-kuaishou.ts b/apps/backend/src/routes/admin/platform-config-kuaishou.ts deleted file mode 100644 index f5cb0345..00000000 --- a/apps/backend/src/routes/admin/platform-config-kuaishou.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Router } from "express"; - -import { requireAdminRoles } from "./shared.js"; -import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; -import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; -import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; -import ninetyoneRouter from "./platform-config/ninetyone.js"; -import notificationsRouter from "./platform-config/notifications.js"; - -const router = Router(); - -router.use("/platform-config", requireAdminRoles(["admin"])); -router.use("/platform-config", notificationsRouter); -router.use("/platform-config", kuaishouEticketRouter); -router.use("/platform-config", ninetyoneRouter); -router.use("/platform-config", cloudtentaclesRouter); -router.use("/platform-config", kuaishouCloudFulfillmentRouter); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 3be79e58..f5cb0345 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -1,9 +1,7 @@ import { Router } from "express"; import { requireAdminRoles } from "./shared.js"; -import agisoRouter from "./platform-config/agiso.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; -import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js"; import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; import ninetyoneRouter from "./platform-config/ninetyone.js"; @@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js"; const router = Router(); router.use("/platform-config", requireAdminRoles(["admin"])); -router.use("/platform-config", agisoRouter); router.use("/platform-config", notificationsRouter); router.use("/platform-config", kuaishouEticketRouter); router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", cloudtentaclesRouter); -router.use("/platform-config", fulfillmentBindingsRouter); router.use("/platform-config", kuaishouCloudFulfillmentRouter); export default router; diff --git a/apps/backend/src/routes/admin/platform-config/agiso.ts b/apps/backend/src/routes/admin/platform-config/agiso.ts deleted file mode 100644 index d92a7738..00000000 --- a/apps/backend/src/routes/admin/platform-config/agiso.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Router } from "express"; - -import { - getAdminAgisoShopConfigs, - updateAdminAgisoShopConfigs, -} from "../../../services/admin/platform-config/service.js"; -import type { AdminAgisoShopConfigRouteBody } from "../../../types/admin-route-inputs.js"; -import type { AdminAgisoShopConfigSaveResponse } from "../../../types/admin-write-models.js"; -import { createJsonHandler } from "../shared.js"; - -const router = Router(); - -router.get( - "/agiso-shops", - createJsonHandler(() => getAdminAgisoShopConfigs(), { - successMessage: "ok", - errorMessage: "读取 Agiso 店铺配置失败", - scope: "[admin/platform-config/agiso-shops]", - }) -); - -router.post( - "/agiso-shops", - createJsonHandler( - (req) => - updateAdminAgisoShopConfigs(req.body as AdminAgisoShopConfigRouteBody), - { - successMessage: "Agiso 店铺配置已保存", - errorMessage: "保存 Agiso 店铺配置失败", - scope: "[admin/platform-config/agiso-shops]", - audit: (_req, data) => { - const result = data as AdminAgisoShopConfigSaveResponse; - return { - action: "platform_shop_config_updated", - targetType: "platform_config", - targetId: "agiso_shops", - data: { - shopCount: result.shops.length, - filePath: result.filePath, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 054b92dc..00000000 --- a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Router } from "express"; - -import { - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminFulfillmentBindingConfigs, -} from "../../../services/admin/platform-config/service.js"; -import type { - AdminFulfillmentBindingConfigRouteBody, - AdminFulfillmentBindingLookupRouteBody, -} from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; - -const router = Router(); - -router.get( - "/fulfillment-bindings", - createJsonHandler(() => getAdminFulfillmentBindingConfigs(), { - successMessage: "ok", - errorMessage: "读取履约配置失败", - scope: "[admin/platform-config/fulfillment-bindings]", - }) -); - -router.post( - "/fulfillment-bindings/lookup-order", - createJsonHandler( - (req) => - lookupAdminFulfillmentBindingOrder( - req.body as AdminFulfillmentBindingLookupRouteBody - ), - { - successMessage: "订单商品查询成功", - errorMessage: "手动查询订单商品失败", - scope: "[admin/platform-config/fulfillment-bindings/lookup-order]", - audit: (req, data) => { - const result = data as JsonRecord; - return { - action: "platform_fulfillment_order_lookup", - targetType: "platform_config", - targetId: [ - result.order?.provider || "unknown", - result.order?.platform || "unknown", - result.order?.shopId || "unknown", - result.order?.platformOrderId || "unknown", - ].join(":"), - data: { - itemCount: Array.isArray(result.items) ? result.items.length : 0, - shopId: - result.order?.shopId || - String( - (req.body as AdminFulfillmentBindingLookupRouteBody)?.shopId || - "" - ).trim(), - platformOrderId: - result.order?.platformOrderId || - String( - (req.body as AdminFulfillmentBindingLookupRouteBody) - ?.platformOrderId || "" - ).trim(), - }, - }; - }, - } - ) -); - -router.post( - "/fulfillment-bindings", - createJsonHandler( - (req) => - updateAdminFulfillmentBindingConfigs( - req.body as AdminFulfillmentBindingConfigRouteBody - ), - { - successMessage: "履约配置已保存", - errorMessage: "保存履约配置失败", - scope: "[admin/platform-config/fulfillment-bindings]", - audit: (_req, data) => { - const result = data as JsonRecord; - return { - action: "platform_fulfillment_config_updated", - targetType: "platform_config", - targetId: "fulfillment_bindings", - data: { - bindingCount: Array.isArray(result.bindings) - ? result.bindings.length - : 0, - filePath: result.filePath || "", - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/tasks-kuaishou.ts b/apps/backend/src/routes/admin/tasks-kuaishou.ts deleted file mode 100644 index 564b274f..00000000 --- a/apps/backend/src/routes/admin/tasks-kuaishou.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Request } from "express"; -import { Router } from "express"; - -import { - getAdminTaskDetail, - getAdminTasks, -} from "../../services/admin/admin-read-service.js"; -import { - dispatchAdminTaskKuaishouCloudFulfillment, - prepareAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - returnNumberAdminTaskKuaishouCloudFulfillment, -} from "../../services/admin/write/kuaishou-cloud-actions.js"; -import { - createJsonHandler, - requireAdminRoles, -} from "./shared.js"; -import type { - AdminTaskKuaishouCloudDispatchRouteBody, - AdminTaskRouteParams, - AdminTaskRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; - -const router = Router(); - -function getTaskId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).taskId; -} - -router.get( - "/tasks", - createJsonHandler( - (req) => - getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), - { - successMessage: "ok", - errorMessage: "读取任务列表失败", - scope: "[admin-kuaishou/tasks]", - } - ) -); - -router.get( - "/tasks/:taskId", - createJsonHandler( - (req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), - { - successMessage: "ok", - errorMessage: "读取任务详情失败", - scope: "[admin-kuaishou/tasks/:taskId]", - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/prepare", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - prepareAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "绑定资源已准备完成", - errorMessage: "准备绑定资源失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/refresh-role-info", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - refreshAdminTaskKuaishouCloudRoleInfo( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "角色信息已刷新", - errorMessage: "刷新角色信息失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/dispatch", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - dispatchAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.body as AdminTaskKuaishouCloudDispatchRouteBody, - req.adminSession || null - ), - { - successMessage: "已完成绑定确认并发货", - errorMessage: "执行发货失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; - - return { - action: "task_kuaishou_cloud_dispatch", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/return-number", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - returnNumberAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "号码已退还", - errorMessage: "退还号码失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_return_number", req, data), - } - ) -); - -function buildTaskAudit(action: string, req: Request, data: unknown) { - const result = data as AdminTaskActionResponse; - - return { - action, - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; -} - -export default router; diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 8a167c1c..038d89af 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -1,42 +1,26 @@ +import type { Request } from "express"; import { Router } from "express"; -import { - closeAdminTask, - completeAdminTaskManualDispatch, - confirmAdminTaskAssistedRole, - dispatchAdminTaskKuaishouCloudFulfillment, - markAdminTaskManualReview, - prepareAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - regenerateAdminTaskClaimLink, - redeemAdminTaskAssisted, - releaseAdminTaskInventoryBinding, - releaseAdminTaskInventory, - returnNumberAdminTaskKuaishouCloudFulfillment, - retryAdminTask, -} from "../../services/admin/admin-write-service.js"; import { getAdminTaskDetail, getAdminTasks, } from "../../services/admin/admin-read-service.js"; -import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js"; import { - createFileHandler, + dispatchAdminTaskKuaishouCloudFulfillment, + prepareAdminTaskKuaishouCloudFulfillment, + refreshAdminTaskKuaishouCloudRoleInfo, + returnNumberAdminTaskKuaishouCloudFulfillment, +} from "../../services/admin/write/kuaishou-cloud-actions.js"; +import { createJsonHandler, requireAdminRoles, } from "./shared.js"; -import type { Request } from "express"; import type { AdminTaskKuaishouCloudDispatchRouteBody, - AdminTaskManualDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, } from "../../types/admin-route-inputs.js"; -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from "../../types/admin-write-models.js"; +import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; const router = Router(); @@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined { return (req.params as AdminTaskRouteParams).taskId; } -function getBindingId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).bindingId; -} - router.get( "/tasks", createJsonHandler( @@ -73,239 +53,6 @@ router.get( ) ); -router.get( - "/tasks/:taskId/screenshot", - createFileHandler( - (req) => - getAdminTaskScreenshotPathWithTencentFallback(getTaskId(req), req.adminSession || null), - { - errorMessage: "读取任务截图失败", - scope: "[admin/tasks/:taskId/screenshot]", - } - ) -); - -router.post( - "/tasks/:taskId/retry", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => retryAdminTask(getTaskId(req)), { - successMessage: "任务已重试", - errorMessage: "重试任务失败", - scope: "[admin/tasks/:taskId/retry]", - }) -); - -router.post( - "/tasks/:taskId/release-inventory", - requireAdminRoles(["admin"]), - createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), { - successMessage: "库存凭据已释放", - errorMessage: "释放库存凭据失败", - scope: "[admin/tasks/:taskId/release-inventory]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_release_inventory", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - inventoryItemId: result.task.inventoryItemId, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/inventory-bindings/:bindingId/release", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)), - { - successMessage: "库存绑定已释放", - errorMessage: "释放库存绑定失败", - scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]", - audit: (req, data) => { - const result = data as AdminTaskBindingReleaseResponse; - return { - action: "task_release_inventory_binding", - targetType: "task_inventory_binding", - targetId: String(getBindingId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - bindingId: result.bindingId, - inventoryItemId: result.inventoryItemId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/regenerate-claim-link", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null), - { - successMessage: "领取链接已重新生成", - errorMessage: "重新生成领取链接失败", - scope: "[admin/tasks/:taskId/regenerate-claim-link]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_regenerate_claim_link", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - primaryClaimTokenId: result.task.primaryClaimTokenId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-confirm-role", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null), - { - successMessage: "角色已确认", - errorMessage: "客服确认角色失败", - scope: "[admin/tasks/:taskId/support-confirm-role]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_confirm_role", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-redeem", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null), - { - successMessage: "兑换任务已启动", - errorMessage: "客服发起兑换失败", - scope: "[admin/tasks/:taskId/support-redeem]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_redeem", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/close", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler((req) => closeAdminTask(getTaskId(req)), { - successMessage: "任务已关闭", - errorMessage: "关闭任务失败", - scope: "[admin/tasks/:taskId/close]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_closed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/mark-manual-review", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), { - successMessage: "任务已转人工处理", - errorMessage: "标记人工处理失败", - scope: "[admin/tasks/:taskId/mark-manual-review]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_mark_manual_review", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/complete-manual-dispatch", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - completeAdminTaskManualDispatch( - getTaskId(req), - req.body as AdminTaskManualDispatchRouteBody, - req.adminSession || null - ), - { - successMessage: "人工履约结果已回写", - errorMessage: "回写人工履约结果失败", - scope: "[admin/tasks/:taskId/complete-manual-dispatch]", - audit: (req, data) => { - const result = data as AdminTaskManualDispatchResponse; - return { - action: "task_complete_manual_dispatch", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - outcome: result.outcome, - deliveryStatus: result.task.deliveryStatus, - resultCode: result.task.resultCode, - }, - }; - }, - } - ) -); - router.post( "/tasks/:taskId/kuaishou-cloud/prepare", requireAdminRoles(["admin", "operator"]), @@ -319,19 +66,7 @@ router.post( successMessage: "绑定资源已准备完成", errorMessage: "准备绑定资源失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_prepare", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), } ) ); @@ -349,19 +84,8 @@ router.post( successMessage: "角色信息已刷新", errorMessage: "刷新角色信息失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_role_info_refreshed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), } ) ); @@ -383,6 +107,7 @@ router.post( audit: (req, data) => { const result = data as AdminTaskActionResponse; const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; + return { action: "task_kuaishou_cloud_dispatch", targetType: "task", @@ -413,21 +138,25 @@ router.post( successMessage: "号码已退还", errorMessage: "退还号码失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_return_number", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_return_number", req, data), } ) ); +function buildTaskAudit(action: string, req: Request, data: unknown) { + const result = data as AdminTaskActionResponse; + + return { + action, + targetType: "task", + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + }; +} + export default router; diff --git a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts deleted file mode 100644 index b60970a9..00000000 --- a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Router } from "express"; - -import { - getAdminWebhookEventDetail, - getAdminWebhookEvents, -} from "../../services/admin/admin-read-service.js"; -import { createHttpError } from "../../utils/http.js"; -import { createJsonHandler, requireAdminRoles } from "./shared.js"; -import type { - AdminWebhookEventRouteParams, - AdminWebhookEventRouteQuery, -} from "../../types/admin-route-inputs.js"; - -const router = Router(); - -router.use("/webhook-events", requireAdminRoles(["admin", "operator"])); - -router.get( - "/webhook-events", - createJsonHandler( - (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), - { - successMessage: "ok", - errorMessage: "读取 webhook 列表失败", - scope: "[admin-kuaishou/webhook-events]", - } - ) -); - -router.get( - "/webhook-events/:eventId", - createJsonHandler( - (req) => - getAdminWebhookEventDetail( - (req.params as AdminWebhookEventRouteParams).eventId - ), - { - successMessage: "ok", - errorMessage: "读取 webhook 详情失败", - scope: "[admin-kuaishou/webhook-events/:eventId]", - } - ) -); - -router.post( - "/webhook-events/:eventId/replay", - requireAdminRoles(["admin"]), - createJsonHandler( - () => { - throw createHttpError("快手轻量后端不支持重放旧 Webhook", { - statusCode: 409, - errorCode: "admin_webhook_replay_disabled", - }); - }, - { - successMessage: "Webhook 已重放", - errorMessage: "重放 webhook 失败", - scope: "[admin-kuaishou/webhook-events/:eventId/replay]", - audit: (req, data) => { - return { - action: "webhook_replayed", - targetType: "webhook_event", - targetId: String( - (req.params as AdminWebhookEventRouteParams).eventId - ), - data: { - disabled: true, - result: data, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/webhook-events.ts b/apps/backend/src/routes/admin/webhook-events.ts index 212b6aee..aff97045 100644 --- a/apps/backend/src/routes/admin/webhook-events.ts +++ b/apps/backend/src/routes/admin/webhook-events.ts @@ -1,60 +1,76 @@ -import { Router } from 'express' +import { Router } from "express"; -import { - replayAdminWebhookEvent, -} from '../../services/admin/admin-write-service.js' import { getAdminWebhookEventDetail, getAdminWebhookEvents, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +} from "../../services/admin/admin-read-service.js"; +import { createHttpError } from "../../utils/http.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; import type { AdminWebhookEventRouteParams, AdminWebhookEventRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js' +} from "../../types/admin-route-inputs.js"; -const router = Router() +const router = Router(); -router.use('/webhook-events', requireAdminRoles(['admin', 'operator'])) +router.use("/webhook-events", requireAdminRoles(["admin", "operator"])); -router.get('/webhook-events', createJsonHandler( - (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), - { - successMessage: 'ok', - errorMessage: '读取 webhook 列表失败', - scope: '[admin/webhook-events]', - }, -)) +router.get( + "/webhook-events", + createJsonHandler( + (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), + { + successMessage: "ok", + errorMessage: "读取 webhook 列表失败", + scope: "[admin/webhook-events]", + } + ) +); -router.get('/webhook-events/:eventId', createJsonHandler( - (req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'ok', - errorMessage: '读取 webhook 详情失败', - scope: '[admin/webhook-events/:eventId]', - }, -)) +router.get( + "/webhook-events/:eventId", + createJsonHandler( + (req) => + getAdminWebhookEventDetail( + (req.params as AdminWebhookEventRouteParams).eventId + ), + { + successMessage: "ok", + errorMessage: "读取 webhook 详情失败", + scope: "[admin/webhook-events/:eventId]", + } + ) +); -router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( - (req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'Webhook 已重放', - errorMessage: '重放 webhook 失败', - scope: '[admin/webhook-events/:eventId/replay]', - audit: (req, data) => { - const result = data as AdminWebhookReplayResponse - return { - action: 'webhook_replayed', - targetType: 'webhook_event', - targetId: String((req.params as AdminWebhookEventRouteParams).eventId), - data: { - eventId: result.eventId, - replayed: result.replayed, - }, - } +router.post( + "/webhook-events/:eventId/replay", + requireAdminRoles(["admin"]), + createJsonHandler( + () => { + throw createHttpError("快手轻量后端不支持重放旧 Webhook", { + statusCode: 409, + errorCode: "admin_webhook_replay_disabled", + }); }, - }, -)) + { + successMessage: "Webhook 已重放", + errorMessage: "重放 webhook 失败", + scope: "[admin/webhook-events/:eventId/replay]", + audit: (req, data) => { + return { + action: "webhook_replayed", + targetType: "webhook_event", + targetId: String( + (req.params as AdminWebhookEventRouteParams).eventId + ), + data: { + disabled: true, + result: data, + }, + }; + }, + } + ) +); -export default router +export default router; diff --git a/apps/backend/src/routes/claims-kuaishou.ts b/apps/backend/src/routes/claims-kuaishou.ts deleted file mode 100644 index 7c2632b7..00000000 --- a/apps/backend/src/routes/claims-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Router } from "express"; - -import { - confirmKuaishouCloudClaimRole, - getKuaishouCloudClaimDetail, - getKuaishouCloudClaimGuideAssetPath, - redeemKuaishouCloudClaim, - verifyKuaishouCloudClaimTicket, -} from "../services/claim/kuaishou-cloud-claim-service.js"; -import { - buildNotFoundPayload, - createRouteFileHandler, - createRouteHandler, -} from "../utils/http.js"; - -const router = Router(); - -router.get( - "/:token", - createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { - errorMessage: "查询快手领取详情失败", - scope: "[claims-kuaishou/:token]", - }) -); - -router.post( - "/:token/kuaishou-cloud/verify-ticket", - createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { - successMessage: "核销码校验成功", - errorMessage: "校验核销码失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]", - } - ) -); - -router.post( - "/:token/kuaishou-cloud/confirm-role", - createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { - successMessage: "角色已确认", - errorMessage: "确认角色失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]", - }) -); - -router.post( - "/:token/kuaishou-cloud/redeem", - createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { - successMessage: "兑换请求已提交", - errorMessage: "兑换失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]", - }) -); - -router.get( - "/assets/kuaishou-cloud/:filename", - createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { - errorMessage: "读取指引图片失败", - scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]", - } - ) -); - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); - -export default router; diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index f1654d19..659b9573 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -1,97 +1,71 @@ -import { Router } from 'express' +import { Router } from "express"; -import { - closeClaimSession, - confirmClaimRole, - createClaimSession, - getClaimDetail, - getClaimScreenshotPath, - getClaimSessionSummary, - reloadClaimSession, - redeemClaimTask, -} from '../services/claim/claim-session-service.js' import { confirmKuaishouCloudClaimRole, + getKuaishouCloudClaimDetail, getKuaishouCloudClaimGuideAssetPath, redeemKuaishouCloudClaim, verifyKuaishouCloudClaimTicket, -} from '../services/claim/kuaishou-cloud-claim-service.js' +} from "../services/claim/kuaishou-cloud-claim-service.js"; import { buildNotFoundPayload, - buildSuccessPayload, createRouteFileHandler, createRouteHandler, - sendRouteError, -} from '../utils/http.js' +} from "../utils/http.js"; -const router = Router() +const router = Router(); -router.get('/:token', createRouteHandler( - (req) => getClaimDetail(req.params.token), - { errorMessage: '查询领取详情失败', scope: '[claims/:token]' }, -)) +router.get( + "/:token", + createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { + errorMessage: "查询快手领取详情失败", + scope: "[claims/:token]", + }) +); -router.post('/:token/session', createRouteHandler( - (req) => createClaimSession(req.params.token, req.body), - { successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' }, -)) +router.post( + "/:token/kuaishou-cloud/verify-ticket", + createRouteHandler( + (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), + { + successMessage: "核销码校验成功", + errorMessage: "校验核销码失败", + scope: "[claims/:token/kuaishou-cloud/verify-ticket]", + } + ) +); -router.get('/:token/session/summary', createRouteHandler( - (req) => getClaimSessionSummary(req.params.token), - { errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' }, -)) +router.post( + "/:token/kuaishou-cloud/confirm-role", + createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { + successMessage: "角色已确认", + errorMessage: "确认角色失败", + scope: "[claims/:token/kuaishou-cloud/confirm-role]", + }) +); -router.post('/:token/session/refresh', async (req, res) => { - try { - const data = await reloadClaimSession(req.params.token) - res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置')) - } catch (error) { - sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]') - } -}) +router.post( + "/:token/kuaishou-cloud/redeem", + createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { + successMessage: "兑换请求已提交", + errorMessage: "兑换失败", + scope: "[claims/:token/kuaishou-cloud/redeem]", + }) +); -router.delete('/:token/session', createRouteHandler( - (req) => closeClaimSession(req.params.token), - { successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' }, -)) - -router.post('/:token/confirm-role', createRouteHandler( - (req) => confirmClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' }, -)) - -router.post('/:token/redeem', createRouteHandler( - (req) => redeemClaimTask(req.params.token), - { successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' }, -)) - -router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' }, -)) - -router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler( - (req) => confirmKuaishouCloudClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' }, -)) - -router.post('/:token/kuaishou-cloud/redeem', createRouteHandler( - (req) => redeemKuaishouCloudClaim(req.params.token), - { successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' }, -)) - -router.get('/:token/screenshot', createRouteFileHandler( - (req) => getClaimScreenshotPath(req.params.token), - { errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' }, -)) - -router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' }, -)) +router.get( + "/assets/kuaishou-cloud/:filename", + createRouteFileHandler( + (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), + { + errorMessage: "读取指引图片失败", + scope: "[claims/assets/kuaishou-cloud/:filename]", + } + ) +); router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) + res.status(404).json(buildNotFoundPayload(req)); +}); -export default router +export default router; diff --git a/apps/backend/src/routes/tencent.ts b/apps/backend/src/routes/tencent.ts deleted file mode 100644 index a4125e5d..00000000 --- a/apps/backend/src/routes/tencent.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Router } from 'express' - -import { - closeTencentBrowserSession, - createTencentBrowserSession, - getTencentBrowserSession, - getTencentBrowserSessionSummary, - getTencentBrowserSessionScreenshotPath, - reloadTencentBrowserSession, - redeemTencentBrowserSession, -} from '../services/session/session.js' -import { - buildNotFoundPayload, - buildSuccessPayload, - createRouteFileHandler, - createRouteHandler, - sendRouteError, -} from '../utils/http.js' - -const router = Router() - -router.post('/browser/session', createRouteHandler( - (req) => createTencentBrowserSession(req.body), - { successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' }, -)) - -router.get('/browser/session/:sessionId', async (req, res) => { - try { - const data = await getTencentBrowserSession(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || 'ok')) - } catch (error) { - sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]') - } -}) - -router.get('/browser/session/:sessionId/summary', async (req, res) => { - try { - const data = await getTencentBrowserSessionSummary(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || 'ok')) - } catch (error) { - sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]') - } -}) - -router.post('/browser/session/:sessionId/refresh', async (req, res) => { - try { - const data = await reloadTencentBrowserSession(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新')) - } catch (error) { - sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]') - } -}) - -router.post('/browser/session/:sessionId/redeem', async (req, res) => { - try { - const data = await redeemTencentBrowserSession(req.params.sessionId, req.body) - res.json(buildSuccessPayload(data, data.notice || '兑换完成')) - } catch (error) { - sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]') - } -}) - -router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler( - (req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId), - { errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' }, -)) - -router.delete('/browser/session/:sessionId', createRouteHandler( - (req) => closeTencentBrowserSession(req.params.sessionId), - { successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' }, -)) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/routes/webhooks.ts b/apps/backend/src/routes/webhooks.ts deleted file mode 100644 index 9c2c3aee..00000000 --- a/apps/backend/src/routes/webhooks.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Router } from 'express' - -import { processAgisoTradeWebhook } from '../services/order/webhook-service.js' -import { createRequestId, logWebhook } from '../utils/logger.js' -import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' - -const router = Router() - -router.post('/agiso/trade', async (req, res) => { - const requestId = createRequestId('wh') - const startedAt = Date.now() - - logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', { - requestId, - method: req.method, - originalUrl: req.originalUrl, - ip: req.ip, - headers: req.headers, - query: req.query, - body: req.body, - }) - - try { - const data = await processAgisoTradeWebhook({ - requestId, - headers: req.headers, - query: req.query, - body: req.body, - }) - - logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', { - requestId, - durationMs: Date.now() - startedAt, - result: data, - }) - - res.json(buildSuccessPayload(data, 'success')) - } catch (error) { - logWebhook( - '[webhooks/agiso/trade]', - 'Agiso webhook 处理失败', - { - requestId, - durationMs: Date.now() - startedAt, - error, - }, - { level: 'error' }, - ) - sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]') - } -}) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.ts b/apps/backend/src/services/admin/admin-manual-redeem-service.ts deleted file mode 100644 index bd58651a..00000000 --- a/apps/backend/src/services/admin/admin-manual-redeem-service.ts +++ /dev/null @@ -1,434 +0,0 @@ -// @ts-check - -import { createTaskClaimToken } from '../claim/claim-service.js' -import { - closeClaimSessionForAdminTask, - createClaimSessionForAdminTask, - getClaimDetailForAdminTask, - getClaimSessionSummaryForAdminTask, - reloadClaimSessionForAdminTask, -} from '../claim/claim-session-service.js' -import { reserveInventoryForTask } from '../order/inventory-service.js' -import { createHttpError } from '../../utils/http.js' -import { replaceOrderItems } from '../../repositories/order-item-repo.js' -import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js' -import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js' -import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js' -import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js' -import { createTaskEvent } from '../../repositories/task-event-repo.js' -import { nowIso } from '../../utils/time.js' -import { randomId } from '../../utils/random.js' -import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js' -import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../types/admin-read-inputs.js' -import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js' -import type { TaskRow } from '../../types/repository-rows.js' - -const MANUAL_PROVIDER = 'manual' -const MANUAL_PLATFORM = 'manual_redeem' -const MANUAL_SOURCE_TYPE = 'admin_manual_redeem' -const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted' - -type JsonObject = Record - -export async function createAdminManualRedeemTask( - payload: AdminManualRedeemCreateInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const viewerContext = createAdminViewerContext(session) - const proofValue = normalizeManualProofValue(payload.proofValue) - const skuCode = String(payload.skuCode || '').trim() - const skuName = String(payload.skuName || '').trim() || skuCode - const remark = String(payload.remark || '').trim() - - if (!proofValue) { - throw createHttpError('请先输入唯一凭据', { - statusCode: 400, - errorCode: 'admin_manual_redeem_missing_proof', - }) - } - - if (!skuCode) { - throw createHttpError('请先选择内部履约 SKU', { - statusCode: 400, - errorCode: 'admin_manual_redeem_missing_sku', - }) - } - - const existingOrder = await findOrderByPlatformOrderId({ - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - platformOrderId: proofValue, - }) - - if (existingOrder) { - throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { - statusCode: 409, - errorCode: 'admin_manual_redeem_duplicate_proof', - }) - } - - const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY) - if (!profile) { - throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', { - statusCode: 500, - errorCode: 'admin_manual_redeem_profile_missing', - }) - } - - const requirements = await listFulfillmentProfileRequirements(profile.id) - const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null - const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code' - const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code' - const availableInventory = await findFirstAvailableInventoryItemBySkuCode( - skuCode, - credentialType, - viewerContext.allowedInventoryGroupCodes, - ) - - if (!availableInventory) { - if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) { - throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_unbound', - }) - } - - throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', { - statusCode: 409, - errorCode: 'admin_manual_redeem_inventory_unavailable', - }) - } - - const now = nowIso() - const manualContext = { - sourceType: MANUAL_SOURCE_TYPE, - proofValue, - remark, - createdAt: now, - createdBy: session - ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } - : null, - } - - let order - try { - order = await createOrder({ - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - shopName: '人工兑换', - platformOrderId: proofValue, - orderStatus: 'manual_created', - payStatus: 'paid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - rawPayloadJson: JSON.stringify({ - manualRedeem: manualContext, - }), - paidAt: now, - createdAt: now, - updatedAt: now, - }) - } catch (error) { - if (String(error?.code || '') === '23505') { - throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { - statusCode: 409, - errorCode: 'admin_manual_redeem_duplicate_proof', - }) - } - - throw error - } - - if (!order) { - throw createHttpError('人工兑换订单创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_order_create_failed', - }) - } - - const orderItems = await replaceOrderItems(order.id, [ - { - skuCode, - skuName, - quantity: 1, - specJson: JSON.stringify({ - title: skuName, - sourceType: MANUAL_SOURCE_TYPE, - }), - itemSnapshotJson: JSON.stringify({ - manualRedeem: manualContext, - skuCode, - skuName, - }), - createdAt: now, - updatedAt: now, - }, - ]) - - const orderItem = orderItems[0] - if (!orderItem) { - throw createHttpError('人工兑换订单商品创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_order_item_create_failed', - }) - } - - const createdTask = await createTask({ - orderId: order.id, - orderItemId: orderItem.id, - unitIndex: 1, - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - shopName: '人工兑换', - platformOrderId: proofValue, - taskNo: randomId('MR'), - profileId: Number(profile.id), - executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY), - taskStatus: 'paid', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - resultCode: '', - resultMessage: '', - claimToken: '', - claimExpiresAt: null, - automationMode: 'manual', - requiresClaim: true, - userActionStatus: 'pending_claim', - attemptCount: 0, - lastError: '', - contextJson: JSON.stringify({ - profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY), - profileName: String(profile.name || ''), - skuCode, - skuName, - inventorySkuCode: skuCode, - primaryRequirement: primaryRequirement - ? { - roleKey, - credentialType, - } - : null, - inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(), - manualRedeem: manualContext, - }), - createdAt: now, - updatedAt: now, - }) - - if (!createdTask) { - throw createHttpError('人工兑换任务创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_task_create_failed', - }) - } - - const reservedInventory = await reserveInventoryForTask({ - skuCode, - taskId: createdTask.id, - credentialType, - roleKey, - inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, - }) - - if (!reservedInventory) { - await updateTask(createdTask.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '库存不足,无法为人工兑换任务预占库存', - updated_at: nowIso(), - }) - - throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', { - statusCode: 409, - errorCode: 'admin_manual_redeem_inventory_race_lost', - }) - } - - const claimToken = await createTaskClaimToken(createdTask.id) - await updateTask(createdTask.id, { - task_status: 'link_generated', - inventory_status: 'reserved', - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - last_error: '', - context_json: JSON.stringify({ - ...parseTaskContext(createdTask), - inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), - }), - updated_at: nowIso(), - }) - - await createTaskEvent(createdTask.id, 'manual_redeem_created', { - sourceType: MANUAL_SOURCE_TYPE, - proofValue, - skuCode, - skuName, - inventoryItemId: Number(reservedInventory.id || 0) || null, - inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), - createdBy: manualContext.createdBy, - remark, - }, nowIso()) - - return getAdminManualRedeemDetail(createdTask.id, session) -} - -export async function getAdminManualRedeemDetail( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await getClaimDetailForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function createAdminManualRedeemSession( - taskId: AdminEntityIdInput, - payload: { loginType?: string, forceRecreate?: boolean } = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await createClaimSessionForAdminTask(task.id, payload) - return decorateManualRedeemDetail(task, detail) -} - -export async function getAdminManualRedeemSessionSummary( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await getClaimSessionSummaryForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function reloadAdminManualRedeemSession( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await reloadClaimSessionForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function closeAdminManualRedeemSession( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await closeClaimSessionForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function closeAdminManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await closeAdminTask(task.id) - return getAdminManualRedeemDetail(task.id, session) -} - -export async function confirmAdminManualRedeemRole( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await confirmAdminTaskAssistedRole(task.id, session) - return getAdminManualRedeemDetail(task.id, session) -} - -export async function redeemAdminManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await redeemAdminTaskAssisted(task.id, session) - return getAdminManualRedeemDetail(task.id, session) -} - -async function getRequiredManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('人工兑换任务不存在', { - statusCode: 404, - errorCode: 'admin_manual_redeem_task_not_found', - }) - } - - const taskContext = parseTaskContext(task) - const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim() - - if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) { - throw createHttpError('当前任务不是人工兑换任务', { - statusCode: 409, - errorCode: 'admin_manual_redeem_task_invalid', - }) - } - - const viewerContext = createAdminViewerContext(session) - if (viewerContext.restrictInventoryGroups) { - if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) { - throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_unbound', - }) - } - - const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() - - if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) { - throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_denied', - }) - } - } - - return task -} - -function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject { - const taskContext = parseTaskContext(task) - const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object' - ? taskContext.manualRedeem - : {} - - return { - ...detail, - claimUrl: '', - manualRequest: { - sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE, - proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(), - remark: String(manualRedeem.remark || '').trim(), - }, - result: detail.result - ? { - ...detail.result, - screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '', - } - : null, - } -} - -function normalizeManualProofValue(value: unknown): string { - return String(value || '').replace(/\s+/g, ' ').trim() -} diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index bdda4330..f9e42c1f 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' import { - buildOrderAgisoAutoDeliverySummary, buildOrderBindingSummary, createTaskBindingSummaryFromBindings, getRequiredTask, @@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< ? order.raw_payload_json : JSON.parse(String(order.raw_payload_json || '{}')), bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), - agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), }, items: items.map((item) => ({ orderItemId: item.id, diff --git a/apps/backend/src/services/admin/admin-service.ts b/apps/backend/src/services/admin/admin-service.ts deleted file mode 100644 index 6a5767ab..00000000 --- a/apps/backend/src/services/admin/admin-service.ts +++ /dev/null @@ -1,86 +0,0 @@ -export { - getRequiredInventoryItem, - mapAdminInventoryListItem, -} from './admin-inventory-read-helpers.js' -export { - mapAdminOrderListItem, - summarizeOrderItems, -} from './admin-order-read-helpers.js' -export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' - -export { - buildOrderAgisoAutoDeliverySummary, - buildOrderBindingSummary, - createEmptyTaskBindingSummary, - createTaskBindingSummaryFromBindings, - getRequiredTask, - getTaskBindingSummary, - getTaskBindingSummaryMap, - mapAdminTaskEvent, - mapAdminTaskInventoryBinding, - mapAdminTaskListItem, - mapAdminTaskSummary, - mapTaskActionPayload, -} from './admin-task-read-helpers.js' - -export { - canRegenerateClaimLinkForViewer, - canViewerConfirmAssistedRole, - canViewerRedeemAssistedTask, - createAdminViewerContext, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, - isManualDispatchTask, - mapManualDispatchContext, - mapRedeemResolutionContext, - parseTaskContext, - parseTaskState, - resolveOrderItemDeliveryMode, - resolveOrderItemTitle, - resolveAdminTaskScreenshotUrl, - resolveDisplayShopName, -} from './admin-read-shared-helpers.js' - -export { - getAdminInventoryItems, - getAdminInventorySkuSuggestions, - getAdminOrderDetail, - getAdminOrders, - getAdminTaskDetail, - getAdminTasks, - getAdminTaskScreenshotPath, - getAdminWebhookEventDetail, - getAdminWebhookEvents, -} from './admin-read-service.js' - -export { - closeAdminTask, - completeAdminTaskManualDispatch, - confirmAdminTaskAssistedRole, - createAdminInventoryItem, - dispatchAdminTaskKuaishouCloudFulfillment, - importAdminInventoryItems, - invalidateAdminInventoryItem, - markAdminTaskManualReview, - prepareAdminTaskKuaishouCloudFulfillment, - redeemAdminTaskAssisted, - regenerateAdminTaskClaimLink, - releaseAdminInventoryItem, - releaseAdminTaskInventory, - releaseAdminTaskInventoryBinding, - replayAdminWebhookEvent, - returnNumberAdminTaskKuaishouCloudFulfillment, - retryAdminTask, -} from './admin-write-service.js' - -export { - getAdminAgisoShopConfigs, - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminAgisoShopConfigs, - updateAdminFulfillmentBindingConfigs, -} from './platform-config/service.js' - -export { getAdminDashboardSummary } from './admin-dashboard-service.js' -export { getAdminMessageDeliveries } from './admin-message-delivery-service.js' diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index ae9092db..b13b9e01 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -10,13 +10,11 @@ import { } from './admin-read-shared-helpers.js' import type { - AdminAgisoAutoDeliveryStatus, AdminTaskBindingSummary, AdminTaskListItem, } from '../../types/admin-read-models.js' import type { AdminTaskActionPayload } from '../../types/admin-write-models.js' import type { - OrderRow, TaskEventRow, TaskInventoryBindingRow, TaskInventoryBindingSummaryRow, @@ -47,24 +45,11 @@ type OrderBindingSummary = { userBindingStatus: string } -type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string - totalTaskCount: number - deliveredTaskCount: number -} - -type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string -} - export function mapAdminTaskSummary( task: TaskRow, bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(), ): JsonRecord { const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) return { taskId: task.id, @@ -81,7 +66,6 @@ export function mapAdminTaskSummary( lastError: task.last_error, retryCount: getTaskRetryCount(task), bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), createdAt: task.created_at, updatedAt: task.updated_at, } @@ -124,7 +108,6 @@ export function mapAdminTaskListItem( viewerContext: AdminViewerContext = createAdminViewerContext(), ): AdminTaskListItem { const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) return { taskId: task.id, @@ -148,7 +131,6 @@ export function mapAdminTaskListItem( redeemedAt: task.redeemed_at, retryCount: getTaskRetryCount(task), bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, @@ -315,63 +297,6 @@ export function buildOrderBindingSummary( } } -export function buildOrderAgisoAutoDeliverySummary( - order: Partial | null | undefined, - tasks: TaskRow[] = [], -): OrderAgisoAutoDeliverySummary | null { - if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { - return null - } - - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - const totalTaskCount = normalizedTasks.length - const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length - const latest = normalizedTasks.reduce((best, task) => { - const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) - - if (!autoDelivery) { - return best - } - - const candidate = { - ...autoDelivery, - sourceTaskId: Number(task.id || 0) || null, - sourceTaskNo: String(task.task_no || '').trim(), - } - const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) - const bestTime = Date.parse(String(best?.updatedAt || '')) - - if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { - return candidate - } - - return best - }, null) - - if (latest) { - return { - ...latest, - totalTaskCount, - deliveredTaskCount, - } - } - - return { - status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', - trigger: '', - reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: 0, - errorMessage: '', - requestId: '', - updatedAt: null, - sourceTaskId: null, - sourceTaskNo: '', - totalTaskCount, - deliveredTaskCount, - } -} - function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean { if (!task || !binding) { return false @@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean { return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) } -function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null { - if (!value || typeof value !== 'object') { - return null - } - - const record = value as JsonRecord - return { - status: String(record.status || '').trim(), - trigger: String(record.trigger || '').trim(), - reason: String(record.reason || '').trim(), - platformOrderId: String(record.platformOrderId || '').trim(), - responseStatus: Number(record.responseStatus || 0), - errorMessage: String(record.errorMessage || '').trim(), - requestId: String(record.requestId || '').trim(), - updatedAt: record.updatedAt || null, - } -} - function normalizeRecord(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } diff --git a/apps/backend/src/services/admin/admin-task-screenshot-service.ts b/apps/backend/src/services/admin/admin-task-screenshot-service.ts deleted file mode 100644 index ffb2d698..00000000 --- a/apps/backend/src/services/admin/admin-task-screenshot-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' -import { createHttpError } from '../../utils/http.js' -import { getRequiredTask } from './admin-task-read-helpers.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../types/admin-read-inputs.js' - -export async function getAdminTaskScreenshotPathWithTencentFallback( - taskId: AdminEntityIdInput, - _session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - - if (task.screenshot_path) { - return task.screenshot_path - } - - if (task.browser_session_id) { - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - - throw createHttpError('当前任务还没有可查看截图', { - statusCode: 404, - errorCode: 'admin_task_screenshot_not_found', - }) -} diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts b/apps/backend/src/services/admin/admin-webhook-read-helpers.ts index 76827730..d7fddcc0 100644 --- a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-webhook-read-helpers.ts @@ -1,6 +1,5 @@ import { listTasksByOrderId } from '../../repositories/task-repo.js' import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js' -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js' import { safeParseJson } from './admin-query-utils.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js' @@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent( const mapped = { eventId: item.id, - provider: item.provider || 'agiso', + provider: item.provider || '', platform: item.platform, platformRaw: pickFirstNonEmpty([ query.fromPlatform, @@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent( visibilityLevel: resolveWebhookVisibilityLevel(item.process_error), relatedOrderId: item.related_order_id, createdAt: item.created_at, - platformOrderId: resolveAgisoTradePlatformOrderId(payload), + platformOrderId: resolveWebhookPlatformOrderId(item, payload), buyerId: pickFirstNonEmpty([ payload.buyer_id, payload.buyerId, @@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent( } function extractWebhookPayload(body: JsonRecord): JsonRecord { - return extractAgisoTradePayload(body) + const candidates = [ + body.data, + body.Data, + body.payload, + body.Payload, + body.message, + body.Message, + ] + + for (const candidate of candidates) { + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + return normalizeRecord(candidate) + } + } + + return body +} + +function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string { + const fromPayload = pickFirstNonEmpty([ + payload.biz_order_id, + payload.bizOrderId, + payload.BizOrderId, + payload.tid, + payload.Tid, + payload.order_id, + payload.orderId, + payload.OrderId, + payload.oid, + payload.Oid, + ]) + + if (fromPayload) { + return fromPayload + } + + const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean) + return parts.find((part) => /^\d{6,}$/.test(part)) || '' } function extractWebhookItemSources(payload: unknown): JsonRecord[] { diff --git a/apps/backend/src/services/admin/admin-write-service.test.ts b/apps/backend/src/services/admin/admin-write-service.test.ts deleted file mode 100644 index 0ada829a..00000000 --- a/apps/backend/src/services/admin/admin-write-service.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { closeAdminTaskWithDeps } from './admin-write-service.js' - -test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => { - const calls = { - updateToken: [], - releaseReserved: [], - closeSession: [], - updateTask: [], - createEvent: [], - } - const task = { - id: 30, - task_status: 'link_generated', - delivery_status: 'pending', - inventory_status: 'reserved', - user_action_status: 'pending_claim', - browser_session_id: 'browser-session-30', - primary_claim_token_id: 9, - claim_expires_at: '2026-04-15T10:00:00.000Z', - last_error: '', - updated_at: '2026-04-14T10:00:00.000Z', - } - const now = '2026-04-14T10:16:18.000Z' - - const result = await closeAdminTaskWithDeps(task.id, { - getRequiredTask: async () => task, - listTaskInventoryBindingsByTaskId: async () => ([ - { inventory_item_id: 17, binding_status: 'reserved' }, - { inventory_item_id: 18, binding_status: 'released' }, - { inventory_item_id: 17, binding_status: 'reserved' }, - ]), - releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => { - calls.releaseReserved.push({ inventoryItemId, updatedAt }) - return { id: inventoryItemId } - }, - updateClaimToken: async (tokenId, patch) => { - calls.updateToken.push({ tokenId, patch }) - return { id: tokenId, ...patch } - }, - closeTencentBrowserSession: async (sessionId) => { - calls.closeSession.push(sessionId) - return { sessionId, closed: true } - }, - updateTask: async (taskId, patch) => { - calls.updateTask.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - calls.createEvent.push({ taskId, eventType, payload, createdAt }) - return null - }, - nowIso: () => now, - }) - - assert.equal(calls.updateToken.length, 1) - assert.deepEqual(calls.updateToken[0], { - tokenId: 9, - patch: { - status: 'revoked', - expired_at: now, - updated_at: now, - }, - }) - assert.deepEqual(calls.releaseReserved, [ - { inventoryItemId: 17, updatedAt: now }, - ]) - assert.deepEqual(calls.closeSession, ['browser-session-30']) - assert.equal(calls.updateTask.length, 1) - assert.equal(calls.updateTask[0].patch.task_status, 'closed') - assert.equal(calls.updateTask[0].patch.delivery_status, 'closed') - assert.equal(calls.updateTask[0].patch.inventory_status, 'pending') - assert.equal(calls.updateTask[0].patch.user_action_status, 'closed') - assert.equal(calls.updateTask[0].patch.browser_session_id, '') - assert.equal(calls.updateTask[0].patch.claim_expires_at, now) - assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/) - assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/) - assert.equal(calls.createEvent.length, 1) - assert.deepEqual(calls.createEvent[0], { - taskId: 30, - eventType: 'task_closed', - payload: { - claimTokenRevoked: true, - releasedInventoryItemIds: [17], - releasedInventoryCount: 1, - browserSessionClosed: true, - }, - createdAt: now, - }) - assert.equal(result.task.status, 'closed') -}) - -test('closeAdminTaskWithDeps rejects redeemed tasks', async () => { - await assert.rejects( - () => closeAdminTaskWithDeps(99, { - getRequiredTask: async () => ({ - id: 99, - task_status: 'redeemed', - }), - }), - /已兑换任务不能关闭/, - ) -}) diff --git a/apps/backend/src/services/admin/admin-write-service.ts b/apps/backend/src/services/admin/admin-write-service.ts deleted file mode 100644 index 9deebe15..00000000 --- a/apps/backend/src/services/admin/admin-write-service.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - createAdminInventoryItem, - importAdminInventoryItems, - releaseAdminInventoryItem, - invalidateAdminInventoryItem, -} from './write/inventory.js' - -export { - replayAdminWebhookEvent, -} from './write/webhook-events.js' - -export { - releaseAdminTaskInventory, - releaseAdminTaskInventoryBinding, - regenerateAdminTaskClaimLink, - confirmAdminTaskAssistedRole, - redeemAdminTaskAssisted, - closeAdminTask, - closeAdminTaskWithDeps, - markAdminTaskManualReview, - retryAdminTask, - completeAdminTaskManualDispatch, -} from './write/task-actions.js' - -export { - prepareAdminTaskKuaishouCloudFulfillment, - dispatchAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - returnNumberAdminTaskKuaishouCloudFulfillment, -} from './write/kuaishou-cloud-actions.js' diff --git a/apps/backend/src/services/admin/platform-config/agiso-service.ts b/apps/backend/src/services/admin/platform-config/agiso-service.ts deleted file mode 100644 index 2c7cdcd6..00000000 --- a/apps/backend/src/services/admin/platform-config/agiso-service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { query } from '../../../db/client.js' -import { - getAgisoMessagingDefaults, - getAgisoShopConfigMap, - getAgisoShopsFilePath, - saveAgisoMessagingConfig, -} from '../../platforms/agiso/shop-config-service.js' -import { resolveDisplayShopName } from '../admin-read-shared-helpers.js' -import { - mapAdminAgisoMessagingDefaults, - mapAdminAgisoShopConfigItem, -} from './domain.js' -import { buildAdminAgisoMessagingSavePayload } from './writes.js' - -type JsonObject = Record - -export async function getAdminAgisoShopConfigs() { - const configMap = getAgisoShopConfigMap() - const defaults = getAgisoMessagingDefaults() - 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(), - defaults: mapAdminAgisoMessagingDefaults(defaults), - shops: Object.entries(configMap) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), - observedShops: rows.map((row) => ({ - shopId: String(row.shop_id || '').trim(), - detectedShopName: String(row.detected_shop_name || '').trim(), - displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name), - latestSeenAt: row.latest_seen_at || null, - webhookEventCount: Number(row.webhook_event_count || 0), - configured: Boolean(configMap[String(row.shop_id || '').trim()]), - })), - } -} - -export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) { - const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, { - currentDefaults: getAgisoMessagingDefaults(), - currentMap: getAgisoShopConfigMap(), - })) - - return { - filePath: getAgisoShopsFilePath(), - defaults: mapAdminAgisoMessagingDefaults(saved.defaults), - shops: Object.entries(saved.shops) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), - } -} diff --git a/apps/backend/src/services/admin/platform-config/agiso-template.ts b/apps/backend/src/services/admin/platform-config/agiso-template.ts deleted file mode 100644 index 0aec7536..00000000 --- a/apps/backend/src/services/admin/platform-config/agiso-template.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function normalizeAgisoMessageTemplate(value: unknown): string { - return String(value || "") - .replaceAll("\\r\\n", "\n") - .replaceAll("\\n", "\n") - .replaceAll("\r\n", "\n"); -} diff --git a/apps/backend/src/services/admin/platform-config/domain.test.ts b/apps/backend/src/services/admin/platform-config/domain.test.ts index 3f69a6a6..4670b7d4 100644 --- a/apps/backend/src/services/admin/platform-config/domain.test.ts +++ b/apps/backend/src/services/admin/platform-config/domain.test.ts @@ -2,101 +2,15 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - applyOptionalStringField, findMatchingObservedBinding, - mapAdminAgisoMessagingDefaults, - mapAdminAgisoShopConfigItem, - mapAdminFulfillmentBindingConfigItem, mapAdminObservedProductItem, matchesObservedProduct, } from './domain.js' -test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => { - assert.deepEqual( - mapAdminAgisoMessagingDefaults({ - messageTemplate: ' hello\\nworld ', - autoDeliveryMessageTemplate: ' done\\nnow ', - }), - { - messageTemplate: 'hello\nworld', - autoDeliveryMessageTemplate: 'done\nnow', - }, - ) -}) - -test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => { - assert.deepEqual( - mapAdminAgisoShopConfigItem('1001', { - shopName: ' 店铺A ', - accessToken: 'abcdef1234567890', - enabled: true, - messageTemplate: ' hi\\nall ', - autoDeliveryMessageTemplate: ' ok ', - appSecret: 'secret-1', - apiVersion: ' v2 ', - sendMessageEndpoint: ' /send ', - }), - { - shopId: '1001', - shopName: '店铺A', - accessToken: 'abcdef1234567890', - accessTokenMasked: 'abcdef****567890', - enabled: true, - messageTemplate: 'hi\nall', - autoDeliveryMessageTemplate: 'ok', - appSecretConfigured: true, - apiVersion: 'v2', - sendMessageEndpoint: '/send', - }, - ) -}) - -test('applyOptionalStringField updates and clears normalized template fields', () => { - const target = { messageTemplate: 'old' } - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' }) - assert.deepEqual(target, { messageTemplate: 'next\nline' }) - - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' }) - assert.deepEqual(target, {}) -}) - -test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => { - assert.deepEqual( - mapAdminFulfillmentBindingConfigItem({ - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - skuCode: ' sku-1 ', - priority: '80', - match: { - externalSkuCode: ' ext-1 ', - }, - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '', - skuCode: 'sku-1', - skuName: '', - profileKey: '', - enabled: true, - priority: 80, - config: {}, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }, - ) -}) - test('matchesObservedProduct honors provider platform shop and external fields', () => { const binding = { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', match: { externalSkuCode: 'sku-ext-1', @@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', assert.equal( matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', externalSkuCode: 'sku-ext-1', }), @@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', assert.equal( matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-2', externalSkuCode: 'sku-ext-1', }), @@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', test('mapAdminObservedProductItem attaches matched binding summary', () => { const bindings = [ { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', skuCode: 'sku-1', skuName: 'SKU 1', @@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => { }, ] const observed = { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', shopName: '店铺1', externalItemId: '', @@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => { assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0]) assert.deepEqual(mapAdminObservedProductItem(observed, bindings), { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', shopName: '店铺1', externalItemId: '', diff --git a/apps/backend/src/services/admin/platform-config/domain.ts b/apps/backend/src/services/admin/platform-config/domain.ts index a84c41d0..690ac297 100644 --- a/apps/backend/src/services/admin/platform-config/domain.ts +++ b/apps/backend/src/services/admin/platform-config/domain.ts @@ -1,70 +1,5 @@ -import { normalizeAgisoMessageTemplate } from './agiso-template.js' -import { maskSecret } from './mappers.js' - type JsonObject = Record -export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) { - return { - messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(defaults.autoDeliveryMessageTemplate || '').trim(), - ), - } -} - -export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) { - return { - shopId, - shopName: String(config.shopName || '').trim(), - accessToken: String(config.accessToken || '').trim(), - accessTokenMasked: maskSecret(config.accessToken), - enabled: typeof config.enabled === 'boolean' ? config.enabled : null, - messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(config.autoDeliveryMessageTemplate || '').trim(), - ), - appSecretConfigured: Boolean(String(config.appSecret || '').trim()), - apiVersion: String(config.apiVersion || '').trim(), - sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), - } -} - -export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) { - if (!source || typeof source[key] !== 'string') { - return - } - - const value = String(source[key] || '').trim() - if (value) { - target[key] = normalizeAgisoMessageTemplate(value) - return - } - - delete target[key] -} - -export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) { - const match = item?.match || {} - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - shopName: String(item?.shopName || '').trim(), - skuCode: String(item?.skuCode || '').trim(), - skuName: String(item?.skuName || '').trim(), - profileKey: String(item?.profileKey || '').trim(), - enabled: item?.enabled !== false, - priority: Number(item?.priority || 100), - config: item?.config || {}, - match: { - externalSkuCode: String(match.externalSkuCode || '').trim(), - externalItemId: String(match.externalItemId || '').trim(), - externalSkuName: String(match.externalSkuName || '').trim(), - config: match.config || {}, - }, - } -} - export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) { const provider = String(binding?.provider || '').trim() const platform = String(binding?.platform || '').trim() diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts deleted file mode 100644 index 8606a908..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js' -import { - filterLegacyOrderFulfillmentBindings, - getOrderFulfillmentBindingsFilePath, - getLegacyOrderFulfillmentBindingConfigs, - saveOrderFulfillmentBindingConfigs, -} from '../../order/fulfillment-binding-config-service.js' -import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js' -import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' -import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' -import { listAdminObservedProducts } from './observed-products.js' -import { validateAdminFulfillmentBindingConfigs } from './validation.js' -import { mapAdminFulfillmentBindingConfigItem } from './domain.js' - -import type { - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, -} from '../../../types/admin-write-inputs.js' - -type JsonObject = Record - -export async function getAdminFulfillmentBindingConfigs() { - const bindings = getLegacyOrderFulfillmentBindingConfigs() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), - observedProducts: await listAdminObservedProducts(bindings), - } -} - -export async function lookupAdminFulfillmentBindingOrder( - payload: AdminFulfillmentBindingLookupInput = {}, -) { - const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload) - - const detailResult = await enrichAgisoXianyuTradeOrder({ - provider, - platform, - shopId, - shopName: '', - platformOrderId, - orderStatus: 'created', - payStatus: 'unpaid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - paidAt: null, - rawPayload: {}, - items: [], - }, { - requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, - }) - - const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId) - const bindings = getLegacyOrderFulfillmentBindingConfigs() - return buildFulfillmentLookupResult({ - provider, - platform, - shopId, - platformOrderId, - detail, - detailResult, - bindings, - fallbackShopName: getAgisoShopConfig(shopId)?.shopName, - }) -} - -export async function updateAdminFulfillmentBindingConfigs( - payload: AdminFulfillmentBindingConfigSaveInput = {}, -) { - const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] - const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, { - getFulfillmentProfileByKey, - }) - assertLegacyBindingConfigsOnly(normalizedBindings) - const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings) - await syncConfiguredFulfillmentBindings() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: saved.map(mapAdminFulfillmentBindingConfigItem), - } -} - -function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) { - if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) { - return - } - - throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved', - }) -} diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts b/apps/backend/src/services/admin/platform-config/fulfillment.test.ts deleted file mode 100644 index 1f43e4df..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' - -test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => { - assert.deepEqual( - normalizeFulfillmentLookupPayload({ - shopId: ' shop-1 ', - platformOrderId: ' order-1 ', - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - }, - ) - - assert.throws( - () => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }), - /请先填写店铺 ID/, - ) -}) - -test('resolveFulfillmentLookupDetail maps missing config into readable error', () => { - assert.throws( - () => - resolveFulfillmentLookupDetail( - { - reason: 'missing_config', - parsed: { items: [] }, - }, - 'order-1', - ), - /当前店铺缺少订单详情查询配置/, - ) -}) - -test('buildFulfillmentLookupResult assembles order and item matching payload', () => { - const result = buildFulfillmentLookupResult({ - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - detail: { - shopName: '店铺A', - buyerName: '买家A', - totalAmount: 12345, - paidAt: '2026-05-04T10:00:00.000Z', - items: [ - { - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - skuName: '礼包A', - quantity: 2, - }, - ], - }, - detailResult: { - enriched: true, - reason: 'ok', - errorMessage: '', - }, - bindings: [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - match: { - externalSkuCode: 'sku-1', - }, - }, - ], - fallbackShopName: '备用店铺', - }) - - assert.deepEqual(result, { - order: { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - platformOrderId: 'order-1', - buyerName: '买家A', - totalAmountFen: 12345, - totalAmount: '123.45', - paidAt: '2026-05-04T10:00:00.000Z', - enriched: true, - enrichReason: 'ok', - errorMessage: '', - }, - items: [ - { - lineId: 'order-1:1:sku-1:item-1', - itemTitle: '礼包A', - quantity: 2, - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - latestSeenAt: null, - orderItemCount: 2, - configured: true, - matchedBinding: { - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - }, - }, - ], - }) -}) diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.ts b/apps/backend/src/services/admin/platform-config/fulfillment.ts deleted file mode 100644 index 76806ae7..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { formatFenToAmount } from '../../../utils/money.js' -import { isPlainObject, pickFirstNonEmpty } from './context.js' -import { mapAdminObservedProductItem } from './domain.js' - -type JsonObject = Record - -export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) { - const provider = String(payload.provider || 'agiso').trim() || 'agiso' - const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' - const shopId = String(payload.shopId || '').trim() - const platformOrderId = String(payload.platformOrderId || '').trim() - - if (!shopId) { - throw createHttpError('请先填写店铺 ID', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_shop_id', - }) - } - - if (!platformOrderId) { - throw createHttpError('请先填写平台订单号', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', - }) - } - - if (provider !== 'agiso' || platform !== 'xianyu') { - throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_platform_not_supported', - }) - } - - return { provider, platform, shopId, platformOrderId } -} - -export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) { - const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} - const items = Array.isArray(detail.items) ? detail.items : [] - - if (items.length > 0) { - return { detail, items } - } - - const detailReason = String(detailResult?.reason || '').trim() - const detailMessage = String(detailResult?.errorMessage || '').trim() - let message = detailMessage - - if (!message && detailReason === 'missing_config') { - message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' - } - - if (!message) { - message = `未查询到订单 ${platformOrderId} 的商品明细` - } - - throw createHttpError(message, { - statusCode: 404, - errorCode: 'admin_fulfillment_lookup_order_items_not_found', - }) -} - -export function buildFulfillmentLookupResult({ - provider = '', - platform = '', - shopId = '', - platformOrderId = '', - detail = {}, - detailResult = {}, - bindings = [], - fallbackShopName = '', -}: { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string - detail?: JsonObject - detailResult?: JsonObject - bindings?: JsonObject[] - fallbackShopName?: string -} = {}) { - const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName]) - const items = Array.isArray(detail.items) ? detail.items : [] - - return { - order: { - provider, - platform, - shopId, - shopName: resolvedShopName, - platformOrderId, - buyerName: String(detail.buyerName || '').trim(), - totalAmountFen: Number(detail.totalAmount || 0), - totalAmount: formatFenToAmount(detail.totalAmount), - paidAt: detail.paidAt || null, - enriched: Boolean(detailResult?.enriched), - enrichReason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - }, - items: items.map((item, index) => { - const observed = { - provider, - platform, - shopId, - shopName: resolvedShopName, - externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), - externalSkuCode: pickFirstNonEmpty([ - item?.externalSkuCode, - item?.skuCode, - item?.externalItemId, - item?.itemId, - ]), - externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), - latestSeenAt: null, - orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), - } - - return { - lineId: [ - platformOrderId, - index + 1, - observed.externalSkuCode || 'na', - observed.externalItemId || 'na', - ].join(':'), - itemTitle: pickFirstNonEmpty([ - item?.skuName, - item?.externalSkuName, - item?.externalSkuCode, - item?.externalItemId, - ]), - quantity: observed.orderItemCount, - ...mapAdminObservedProductItem(observed, bindings), - } - }), - } -} diff --git a/apps/backend/src/services/admin/platform-config/service.ts b/apps/backend/src/services/admin/platform-config/service.ts deleted file mode 100644 index 273c4f35..00000000 --- a/apps/backend/src/services/admin/platform-config/service.ts +++ /dev/null @@ -1,61 +0,0 @@ -export { - getAdminAgisoShopConfigs, - updateAdminAgisoShopConfigs, -} from "./agiso-service.js"; - -export { - getAdminNinetyoneOrders, - retryAdminNinetyoneOrder, - failAdminNinetyoneOrder, -} from "./ninetyone-service.js"; - -export { - getAdminKuaishouEticketSourceConfig, - updateAdminKuaishouEticketSourceConfig, - queryAdminKuaishouEticketDetail, - queryAdminKuaishouEticketShopInfo, - consumeAdminKuaishouEticket, -} from "./kuaishou-eticket-service.js"; - -export { - listAdminCloudtentaclesSources, - deleteAdminCloudtentaclesSource, - updateAdminCloudtentaclesSourceConfig, - sendAdminCloudtentaclesSmsCode, - testAdminCloudtentaclesLogin, - validateAdminCloudtentaclesSession, - getAdminCloudtentaclesAsset, - getAdminCloudtentaclesCategories, - getAdminCloudtentaclesSkuList, - buyAdminCloudtentaclesSku, - useAdminCloudtentaclesSku, - getAdminCloudtentaclesKnapsack, - listAdminCloudtentaclesVirtualNumbers, - appointAdminCloudtentaclesVirtualNumber, - generateAdminCloudtentaclesLoginCode, - fetchAdminCloudtentaclesVirtualNumberCode, - verifyAdminCloudtentaclesLoginCode, - getAdminCloudtentaclesBindUrl, - backAdminCloudtentaclesVirtualNumber, - runAdminCloudtentaclesFullFlow, -} from "./cloudtentacles-service.js"; - -export { - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminFulfillmentBindingConfigs, -} from "./fulfillment-bindings-service.js"; - -export { - getAdminKuaishouCloudFulfillmentConfig, - updateAdminKuaishouCloudFulfillmentConfig, -} from "./kuaishou-cloud-fulfillment-service.js"; - -export { - getAdminNotificationConfig, - updateAdminNotificationConfig, - testAdminNotification, - getAdminScheduledJobsConfig, - updateAdminScheduledJobsConfig, - runAdminScheduledJobNow, -} from "./notification-service.js"; diff --git a/apps/backend/src/services/admin/platform-config/validation.test.ts b/apps/backend/src/services/admin/platform-config/validation.test.ts deleted file mode 100644 index 9be59f24..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - assertAdminFulfillmentBindingsInput, - buildAdminFulfillmentBindingUniqueKey, - normalizeAdminFulfillmentBindingItem, - validateAdminFulfillmentBindingConfigs, -} from './validation.js' - -test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => { - assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([])) - assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/) -}) - -test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => { - const result = normalizeAdminFulfillmentBindingItem( - { - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - shopName: ' 店铺 ', - skuCode: 'sku-1', - match: { - externalSkuCode: 'ext-1', - }, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - }, - { index: 0 }, - ) - - assert.deepEqual(result, { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺', - skuCode: 'sku-1', - skuName: '', - profileKey: 'manual_review', - enabled: true, - priority: undefined, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }) -}) - -test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => { - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - match: { - externalSkuCode: 'ext-1', - }, - }, - { index: 1 }, - ), - /第 2 条规则缺少内部履约 SKU/, - ) - - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - skuCode: 'sku-1', - }, - { index: 2 }, - ), - /第 3 条规则至少需要一种外部匹配条件/, - ) -}) - -test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => { - assert.equal( - buildAdminFulfillmentBindingUniqueKey({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: 'shop-a', - skuCode: 'sku-1', - match: { - externalItemId: '', - externalSkuCode: '', - externalSkuName: ' 礼包 A ', - }, - }), - '91kaquan::kuaishou::shop-a::::::礼包 a::sku-1', - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'missing-profile', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return null - }, - }, - ), - /第 1 条规则使用了不存在的履约方式: missing-profile/, - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: ' ext-1 ', - }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: ' shop-1 ', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return { key: 'manual_review' } - }, - }, - ), - /第 2 条规则与其它规则重复/, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/validation.ts b/apps/backend/src/services/admin/platform-config/validation.ts deleted file mode 100644 index 49a51cb2..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { normalizeProductName } from '../../order/product-match-service.js' -import { isPlainObject } from './context.js' - -type JsonObject = Record - -export function assertAdminFulfillmentBindingsInput(bindings: unknown) { - if (Array.isArray(bindings)) { - return - } - - throw createHttpError('履约配置格式不正确', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_payload', - }) -} - -export function normalizeAdminFulfillmentBindingItem( - rawBinding: unknown, - options: JsonObject = {}, -) { - const index = Number(options.index || 0) - if (!isPlainObject(rawBinding)) { - throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_item', - }) - } - - const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' - const platform = String(rawBinding.platform || '').trim() - const shopId = String(rawBinding.shopId || '').trim() - const shopName = String(rawBinding.shopName || '').trim() - const skuCode = String(rawBinding.skuCode || '').trim() - const skuName = String(rawBinding.skuName || '').trim() - const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' - const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {} - - if (!skuCode) { - throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_sku_code', - }) - } - - if (!externalItemId && !externalSkuCode && !externalSkuName) { - throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_match_condition', - }) - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled: rawBinding.enabled !== false, - priority: rawBinding.priority, - config, - match: { - externalSkuCode, - externalItemId, - externalSkuName, - config: isPlainObject(match.config) ? match.config : {}, - }, - } -} - -export function buildAdminFulfillmentBindingUniqueKey(binding) { - return [ - String(binding?.provider || '').trim(), - String(binding?.platform || '').trim(), - String(binding?.shopId || '').trim(), - String(binding?.match?.externalItemId || '').trim(), - String(binding?.match?.externalSkuCode || '').trim(), - normalizeProductName(String(binding?.match?.externalSkuName || '').trim()), - String(binding?.skuCode || '').trim(), - ].join('::') -} - -export async function validateAdminFulfillmentBindingConfigs( - bindings: JsonObject[], - options: JsonObject = {}, -) { - assertAdminFulfillmentBindingsInput(bindings) - - const getFulfillmentProfileByKey = ( - options.getFulfillmentProfileByKey || (async () => null) - ) as (profileKey: string) => Promise - const seenKeys = new Set() - const normalizedBindings: JsonObject[] = [] - - for (const [index, rawBinding] of bindings.entries()) { - const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index }) - - const profile = await getFulfillmentProfileByKey(normalized.profileKey) - if (!profile) { - throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_profile_key', - }) - } - - const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized) - if (seenKeys.has(uniqueKey)) { - throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { - statusCode: 409, - errorCode: 'admin_fulfillment_bindings_duplicate_rule', - }) - } - - seenKeys.add(uniqueKey) - normalizedBindings.push(normalized) - } - - return normalizedBindings -} diff --git a/apps/backend/src/services/admin/platform-config/writes.test.ts b/apps/backend/src/services/admin/platform-config/writes.test.ts deleted file mode 100644 index cb63cd67..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildAdminAgisoMessagingSavePayload, - buildAdminAgisoShopConfigUpdate, - normalizeAdminCloudtentaclesSourceConfigPayload, -} from './writes.js' - -test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => { - assert.deepEqual( - buildAdminAgisoShopConfigUpdate( - { - shopId: ' shop-1 ', - shopName: ' ', - accessToken: ' new-token ', - messageTemplate: ' ', - autoDeliveryMessageTemplate: ' done ', - enabled: false, - }, - { - shopName: '旧店铺', - accessToken: 'old-token', - messageTemplate: 'old-template', - autoDeliveryMessageTemplate: 'old-auto', - appSecret: 'keep-secret', - }, - ), - { - shopId: 'shop-1', - config: { - accessToken: 'new-token', - autoDeliveryMessageTemplate: 'done', - appSecret: 'keep-secret', - enabled: false, - }, - }, - ) -}) - -test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => { - assert.deepEqual( - buildAdminAgisoMessagingSavePayload( - { - defaults: { - messageTemplate: ' next\\nline ', - autoDeliveryMessageTemplate: ' ', - }, - shops: [ - { - shopId: ' shop-1 ', - shopName: '店铺一', - accessToken: '', - enabled: true, - }, - { - shopId: 'shop-2', - shopName: ' 店铺二 ', - accessToken: ' token-2 ', - messageTemplate: ' hi ', - apiVersion: ' v2 ', - }, - { - shopId: '', - accessToken: 'skip', - }, - ], - }, - { - currentDefaults: { - messageTemplate: 'old', - autoDeliveryMessageTemplate: 'old-auto', - }, - currentMap: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - }, - }, - }, - ), - { - defaults: { - messageTemplate: 'next\nline', - }, - shops: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - enabled: true, - shopName: '店铺一', - }, - 'shop-2': { - accessToken: 'token-2', - apiVersion: 'v2', - messageTemplate: 'hi', - shopName: '店铺二', - }, - }, - }, - ) -}) - -test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => { - assert.deepEqual( - normalizeAdminCloudtentaclesSourceConfigPayload({ - enabled: false, - baseUrl: ' ', - username: ' user ', - password: ' pass ', - phone: ' 13800138000 ', - deviceId: ' ', - deviceType: '2', - }), - { - key: 'default', - label: '', - enabled: false, - baseUrl: 'https://123.207.217.176', - username: 'user', - password: 'pass', - phone: '13800138000', - deviceId: '-', - deviceType: 2, - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/writes.ts b/apps/backend/src/services/admin/platform-config/writes.ts deleted file mode 100644 index 68c7a9bb..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { applyOptionalStringField } from "./domain.js"; - -type JsonObject = Record - -export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - return null; - } - - const next: JsonObject = { ...current }; - const shopName = String(item?.shopName || "").trim(); - const accessToken = String(item?.accessToken || "").trim(); - const messageTemplate = String(item?.messageTemplate || "").trim(); - const autoDeliveryMessageTemplate = String( - item?.autoDeliveryMessageTemplate || "" - ).trim(); - const appSecret = String(item?.appSecret || "").trim(); - const apiVersion = String(item?.apiVersion || "").trim(); - const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim(); - - if (shopName) { - next.shopName = shopName; - } else { - delete next.shopName; - } - if (accessToken) { - next.accessToken = accessToken; - } - if (messageTemplate) { - next.messageTemplate = messageTemplate; - } else if (typeof item?.messageTemplate === "string") { - delete next.messageTemplate; - } - if (autoDeliveryMessageTemplate) { - next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate; - } else if (typeof item?.autoDeliveryMessageTemplate === "string") { - delete next.autoDeliveryMessageTemplate; - } - if (appSecret) { - next.appSecret = appSecret; - } - if (apiVersion) { - next.apiVersion = apiVersion; - } - if (sendMessageEndpoint) { - next.sendMessageEndpoint = sendMessageEndpoint; - } - if (typeof item?.enabled === "boolean") { - next.enabled = item.enabled; - } - - if (!next.accessToken) { - return null; - } - - return { shopId, config: next }; -} - -export function buildAdminAgisoMessagingSavePayload( - payload: JsonObject = {}, - options: JsonObject = {} -) { - const rawItems = Array.isArray(payload.shops) ? payload.shops : []; - const currentDefaults: JsonObject = options.currentDefaults || {}; - const currentMap: Record = options.currentMap || {}; - const nextDefaults: JsonObject = { - ...currentDefaults, - }; - const nextMap: Record = {}; - - applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults); - applyOptionalStringField( - nextDefaults, - "autoDeliveryMessageTemplate", - payload.defaults - ); - - for (const item of rawItems) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - continue; - } - - const updated = buildAdminAgisoShopConfigUpdate( - item, - currentMap[shopId] || {} - ); - if (!updated) { - continue; - } - - nextMap[updated.shopId] = updated.config; - } - - return { - defaults: nextDefaults, - shops: nextMap, - }; -} diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts deleted file mode 100644 index b65f6ad3..00000000 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { - markInventoryItemDelivered, - releaseReservedInventoryItem, -} from '../../../repositories/inventory-repo.js' -import { updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - getTaskInventoryBindingById, - listTaskInventoryBindingsByTaskId, -} from '../../../repositories/task-inventory-binding-repo.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { createTaskClaimToken } from '../../claim/claim-service.js' -import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { closeTencentBrowserSession } from '../../session/session.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { - createAdminViewerContext, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, - isManualDispatchTask, - parseTaskContext, -} from '../admin-read-shared-helpers.js' -import { - getRequiredTask, - mapTaskActionPayload, -} from '../admin-task-read-helpers.js' -import { - ensureViewerCanOperateAssistedTask, - getTaskClaimExpiresAt, - isRecoverableTaskSessionCloseError, - maskCode, - normalizeManualDispatchOutcome, - resolveTaskInventoryGroupCodes, -} from './shared.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../../types/admin-read-inputs.js' -import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js' -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from '../../../types/admin-write-models.js' -import type { TaskRow } from '../../../types/repository-rows.js' -import type { TaskUpdatePatch } from '../../../types/repository-inputs.js' - -type CloseAdminTaskDeps = { - getRequiredTask?: (taskId: AdminEntityIdInput) => Promise - listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise - releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise - updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record) => Promise - updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise - createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record, createdAt: string) => Promise - closeTencentBrowserSession?: (sessionId: string) => Promise - nowIso?: () => string -} - -export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) - - if (!primaryInventoryItemId) { - throw createHttpError('当前任务没有预占库存项', { - statusCode: 409, - errorCode: 'admin_task_no_reserved_inventory', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存项', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(primaryInventoryItemId, now) - const updatedTask = await updateTask(task.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '已手动释放预占库存项', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function releaseAdminTaskInventoryBinding( - taskId: AdminEntityIdInput, - bindingId: AdminEntityIdInput, -): Promise { - const task = await getRequiredTask(taskId) - const binding = await getTaskInventoryBindingById(Number(bindingId)) - - if (!binding || Number(binding.task_id) !== Number(task.id)) { - throw createHttpError('任务库存绑定不存在', { - statusCode: 404, - errorCode: 'admin_task_inventory_binding_not_found', - }) - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - throw createHttpError('当前库存绑定不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_task_inventory_binding_release_not_allowed', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存绑定', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(binding.inventory_item_id, now) - - const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) - const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) - const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') - const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') - const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') - const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) - ? 'waiting_inventory' - : task.task_status - - if (!hasReservedBindings) { - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), - updated_at: now, - }) - - await createTaskEvent(task.id, 'inventory_binding_released', { - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - roleKey: String(binding.role_key || '').trim(), - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - } -} - -export async function regenerateAdminTaskClaimLink( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - const viewerContext = createAdminViewerContext(session) - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_claim_not_allowed', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重新生成领取链接', { - statusCode: 409, - errorCode: 'admin_task_regenerate_not_allowed', - }) - } - - if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { - throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { - statusCode: 403, - errorCode: 'admin_task_regenerate_permission_denied', - }) - } - - const now = nowIso() - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - - const claimToken = await createTaskClaimToken(task.id) - const updatedTask = await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - task_status: 'link_generated', - last_error: '', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - claimUrl: claimToken.claimUrl, - token: claimToken.token, - } -} - -export async function confirmAdminTaskAssistedRole( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') - await confirmClaimRoleForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function redeemAdminTaskAssisted( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') - await redeemClaimTaskForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function closeAdminTask(taskId: AdminEntityIdInput): Promise { - return closeAdminTaskWithDeps(taskId) -} - -export async function closeAdminTaskWithDeps( - taskId: AdminEntityIdInput, - { - getRequiredTask: getTask = getRequiredTask, - listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId, - releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, - updateClaimToken: updateToken = updateClaimToken, - updateTask: updateTaskRecord = updateTask, - createTaskEvent: createEvent = createTaskEvent, - closeTencentBrowserSession: closeSession = closeTencentBrowserSession, - nowIso: getNowIso = nowIso, - }: CloseAdminTaskDeps = {}, -): Promise { - const task = await getTask(taskId) - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能关闭', { - statusCode: 409, - errorCode: 'admin_task_close_not_allowed', - }) - } - - const now = getNowIso() - const bindings = await listBindings(task.id) - const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved') - const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0))) - const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed') - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - let browserSessionClosed = false - - if (primaryClaimTokenId) { - await updateToken(primaryClaimTokenId, { - status: 'revoked', - expired_at: now, - updated_at: now, - }) - } - - if (task.browser_session_id) { - try { - await closeSession(task.browser_session_id) - browserSessionClosed = true - } catch (error) { - if (!isRecoverableTaskSessionCloseError(error)) { - throw error - } - } - } - - for (const inventoryItemId of releasedInventoryItemIds) { - await releaseReserved(inventoryItemId, now) - } - - const closeReasonParts = ['已手动关闭任务'] - if (primaryClaimTokenId) { - closeReasonParts.push('领取链接已失效') - } - if (releasedInventoryItemIds.length > 0) { - closeReasonParts.push('预占库存已释放') - } - - const updatedTask = await updateTaskRecord(task.id, { - task_status: 'closed', - inventory_status: hasConsumedBindings ? 'consumed' : 'pending', - delivery_status: 'closed', - user_action_status: 'closed', - claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at, - browser_session_id: '', - last_error: task.last_error || closeReasonParts.join(','), - updated_at: now, - }) - - await createEvent(task.id, 'task_closed', { - claimTokenRevoked: Boolean(primaryClaimTokenId), - releasedInventoryItemIds, - releasedInventoryCount: releasedInventoryItemIds.length, - browserSessionClosed, - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const updatedTask = await updateTask(task.id, { - task_status: 'manual_review', - delivery_status: task.delivery_status || 'pending', - last_error: task.last_error || '已转人工处理', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function retryAdminTask(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const now = nowIso() - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_retry_not_allowed', - }) - } - - if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重试', { - statusCode: 409, - errorCode: 'admin_task_retry_not_allowed', - }) - } - - const taskContext = parseTaskContext(task) - const primaryRequirement = taskContext.primaryRequirement || null - const orderItems = await listOrderItemsByOrderId(task.order_id) - const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null - let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task) - let claimTokenId = getTaskPrimaryClaimTokenId(task) - let nextStatus = 'link_generated' - let lastError = '' - let claimExpiresAt = getTaskClaimExpiresAt(task) - let claimUrl = '' - let token = '' - - if (!reservedInventoryItemId) { - const reserved = await reserveInventoryForTask({ - skuCode: orderItem?.sku_code || '', - taskId: task.id, - credentialType: primaryRequirement?.credentialType || 'tencent_code', - roleKey: primaryRequirement?.roleKey || 'primary_code', - inventoryGroupCodes: resolveTaskInventoryGroupCodes(task), - }) - - if (!reserved) { - nextStatus = 'waiting_inventory' - lastError = '库存不足,等待可用库存凭据' - } else { - reservedInventoryItemId = reserved.id - } - } - - if (nextStatus === 'link_generated' && !claimTokenId) { - const claimToken = await createTaskClaimToken(task.id) - claimTokenId = claimToken.id - claimExpiresAt = claimToken.expired_at - claimUrl = claimToken.claimUrl - token = claimToken.token - } - - const updatedTask = await updateTask(task.id, { - task_status: nextStatus, - inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', - claim_token: token || task.claim_token || '', - claim_expires_at: claimExpiresAt, - last_error: lastError, - updated_at: now, - }) - - const response: AdminTaskActionResponse = { - task: mapTaskActionPayload(updatedTask), - } - - if (claimUrl) { - response.claimUrl = claimUrl - response.token = token - } - - return response -} - -export async function completeAdminTaskManualDispatch( - taskId: AdminEntityIdInput, - payload: AdminTaskManualDispatchInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - - if (!isManualDispatchTask(task)) { - throw createHttpError('当前任务不是人工履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_manual_dispatch', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_already_completed', - }) - } - - const now = nowIso() - const outcome = normalizeManualDispatchOutcome(payload.outcome) - const resultMessage = String(payload.resultMessage || '').trim() - const deliveryReference = String(payload.deliveryReference || '').trim() - const deliveredCredential = String(payload.deliveredCredential || '').trim() - const context = parseTaskContext(task) - const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' - const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' - const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' - const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' - const nextInventoryStatus = outcome === 'delivered' && inventoryItemId - ? 'consumed' - : task.inventory_status || 'not_required' - const manualDispatch = { - outcome, - deliveryReference, - deliveredCredential, - resultMessage: resultMessage || fallbackMessage, - completedAt: now, - completedBy: session - ? { - userId: Number(session.userId || 0), - username: String(session.username || ''), - role: String(session.role || ''), - } - : null, - } - - if (outcome === 'delivered' && inventoryItemId) { - await markInventoryItemDelivered(inventoryItemId, now) - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - delivery_status: nextDeliveryStatus, - result_code: resultCode, - result_message: resultMessage || fallbackMessage, - user_action_status: 'not_required', - last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', - redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, - context_json: JSON.stringify({ - ...context, - manualDispatch, - }), - updated_at: now, - }) - - await createTaskEvent(task.id, 'manual_dispatch_completed', { - outcome, - resultCode, - resultMessage: resultMessage || fallbackMessage, - deliveryReference, - deliveredCredentialMasked: maskCode(deliveredCredential), - completedBy: manualDispatch.completedBy, - }, now) - - const taskAfterAutoDelivery = outcome === 'delivered' - ? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: await getOrderById(task.order_id), - task: updatedTask, - trigger: 'manual_dispatch_completed', - })).task || updatedTask - : updatedTask - - return { - outcome, - task: mapTaskActionPayload(taskAfterAutoDelivery), - } -} diff --git a/apps/backend/src/services/admin/write/webhook-events.ts b/apps/backend/src/services/admin/write/webhook-events.ts deleted file mode 100644 index b0f7bdef..00000000 --- a/apps/backend/src/services/admin/write/webhook-events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js' -import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js' -import { createHttpError } from '../../../utils/http.js' - -import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js' -import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js' - -export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - if (String(event.provider || event.platform || '').trim() !== 'agiso') { - throw createHttpError('当前只支持重放 agiso webhook', { - statusCode: 409, - errorCode: 'admin_webhook_replay_not_supported', - }) - } - - const result = await replayAgisoTradeWebhookEvent(event) - - return { - eventId: event.id, - replayed: true, - result, - } -} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts deleted file mode 100644 index 0fb21e92..00000000 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js' - -test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - }), - ['91kaquan'], - ) -}) - -test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: 'agiso', - platform: 'xianyu', - shopId: '', - }), - [''], - ) -}) diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 8779d538..92f1c83f 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -8,7 +8,6 @@ import { import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' import { normalizeProductName } from '../order/product-match-service.js' import { query } from '../../db/client.js' -import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js' import { getKuaishouCloudFulfillmentConfig, mapKuaishouCloudFulfillmentItemsToBindings, @@ -108,10 +107,7 @@ async function ensureCoreProfiles() { export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) { const timestamp = nowIso() - const bindingsToApply = [ - ...getLegacyOrderFulfillmentBindingConfigs(), - ...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()), - ] + const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()) await query('DELETE FROM product_match_rules') await query('DELETE FROM sku_fulfillment_bindings') @@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = const bindingConfig = resolveBindingRuntimeConfig(binding) const matchShopIds = resolveBindingMatchShopIds(binding) - const match = binding.match || {} + const match: JsonObject = isPlainObject(binding.match) ? binding.match : {} const externalSkuName = String(match.externalSkuName || '').trim() const externalItemId = String(match.externalItemId || '').trim() const externalSkuCode = String(match.externalSkuCode || '').trim() @@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = } } -export function resolveBindingMatchShopIds(binding: JsonObject = {}) { +function resolveBindingMatchShopIds(binding: JsonObject = {}) { return [String(binding.shopId || '').trim()] } diff --git a/apps/backend/src/services/claim/claim-session-service.test.ts b/apps/backend/src/services/claim/claim-session-service.test.ts deleted file mode 100644 index 79307cc2..00000000 --- a/apps/backend/src/services/claim/claim-session-service.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js' - -test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => { - const events = [] - const invalidated = [] - const reserveCalls = [] - const reloadCalls = [] - - const context = { - task: { - id: 42, - browser_session_id: 'txbs-test', - }, - orderItem: { - sku_code: 'df-cdk', - }, - } - const initialInventoryItem = { - id: 1, - display_value: 'DJQFf7HgONCL4XD4EH', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - const replacementInventoryItem = { - id: 2, - display_value: 'ABCD1234EFGH5678', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - - let redeemCount = 0 - const result = await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - reloadTencentBrowserSession: async (sessionId) => { - reloadCalls.push(sessionId) - return { sessionId, status: 'ready_to_redeem' } - }, - redeemTencentBrowserSession: async (sessionId, payload) => { - redeemCount += 1 - if (redeemCount === 1) { - assert.equal(sessionId, 'txbs-test') - assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH') - return { - redeem: { - final: { - redeem: { - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }, - }, - }, - } - } - - assert.equal(payload.code, 'ABCD1234EFGH5678') - return { - redeem: { - final: { - redeem: { - iRet: 0, - sMsg: '领取成功', - }, - }, - }, - } - }, - invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => { - invalidated.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return replacementInventoryItem - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: (() => { - const values = [ - '2026-04-14T12:03:34.000Z', - '2026-04-14T12:03:35.000Z', - ] - let index = 0 - return () => values[index++] || values[values.length - 1] - })(), - }, - ) - - assert.equal(result.inventoryItem.id, 2) - assert.equal(result.classification.outcome, 'success') - assert.equal(result.attempts.length, 2) - assert.deepEqual( - result.attempts.map((attempt) => ({ - attempt: attempt.attempt, - inventoryItemId: attempt.inventoryItemId, - outcome: attempt.outcome, - resultCode: attempt.resultCode, - })), - [ - { - attempt: 1, - inventoryItemId: 1, - outcome: 'code_invalid', - resultCode: '-183', - }, - { - attempt: 2, - inventoryItemId: 2, - outcome: 'success', - resultCode: '0', - }, - ], - ) - assert.deepEqual(invalidated, [ - { - inventoryItemId: 1, - reason: '该CDKEY不存在,请您确认后输入!', - updatedAt: '2026-04-14T12:03:34.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk', - taskId: 42, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['A组'], - }, - ]) - assert.deepEqual(reloadCalls, ['txbs-test']) - assert.deepEqual( - events.map((event) => ({ - eventType: event.eventType, - taskId: event.taskId, - createdAt: event.createdAt, - payload: event.payload, - })), - [ - { - eventType: 'claim_redeem_code_invalid', - taskId: 42, - createdAt: '2026-04-14T12:03:34.000Z', - payload: { - inventoryItemId: 1, - codeMasked: 'DJQF****D4EH', - resultCode: '-183', - resultMessage: '该CDKEY不存在,请您确认后输入!', - }, - }, - { - eventType: 'claim_redeem_inventory_replaced', - taskId: 42, - createdAt: '2026-04-14T12:03:35.000Z', - payload: { - previousInventoryItemId: 1, - previousCodeMasked: 'DJQF****D4EH', - previousOutcome: 'code_invalid', - nextInventoryItemId: 2, - nextCodeMasked: 'ABCD****5678', - credentialType: 'tencent_code', - inventoryGroupCode: 'A组', - }, - }, - ], - ) -}) - -test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => { - const events = [] - const consumed = [] - const reserveCalls = [] - - const context = { - task: { - id: 57, - browser_session_id: 'txbs-used', - }, - orderItem: { - sku_code: 'df-cdk-used', - }, - } - const initialInventoryItem = { - id: 7, - display_value: 'USED1234CODE5678', - credential_type: 'tencent_code', - inventory_group_code: 'B组', - } - - let thrown = null - try { - await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - redeemTencentBrowserSession: async () => ({ - redeem: { - final: { - redeem: { - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }, - }, - }, - }), - markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => { - consumed.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return null - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: () => '2026-04-14T12:05:00.000Z', - }, - ) - } catch (error) { - thrown = error - } - - assert.ok(thrown) - assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted') - assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory') - assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending') - assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending') - assert.equal(thrown.redeemTaskState.attempts.length, 1) - assert.deepEqual(consumed, [ - { - inventoryItemId: 7, - reason: '兑换码已使用。', - updatedAt: '2026-04-14T12:05:00.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk-used', - taskId: 57, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['B组'], - }, - ]) - assert.deepEqual( - events.map((event) => ({ - taskId: event.taskId, - eventType: event.eventType, - payload: event.payload, - createdAt: event.createdAt, - })), - [ - { - taskId: 57, - eventType: 'claim_redeem_code_used', - payload: { - inventoryItemId: 7, - codeMasked: 'USED****5678', - resultCode: '', - resultMessage: '兑换码已使用。', - }, - createdAt: '2026-04-14T12:05:00.000Z', - }, - ], - ) -}) diff --git a/apps/backend/src/services/claim/claim-session-service.ts b/apps/backend/src/services/claim/claim-session-service.ts deleted file mode 100644 index aa0764e7..00000000 --- a/apps/backend/src/services/claim/claim-session-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { - getClaimDetail, - getClaimDetailForAdminTask, - getClaimSessionSummary, - getClaimSessionSummaryForAdminTask, - confirmClaimRole, - confirmClaimRoleForAdminTask, - redeemClaimTask, - redeemClaimTaskForAdminTask, - getClaimScreenshotPath, -} from './session/actions.js' - -export { - createClaimSession, - createClaimSessionForAdminTask, - reloadClaimSession, - reloadClaimSessionForAdminTask, - closeClaimSession, - closeClaimSessionForAdminTask, -} from './session/lifecycle.js' - -export { - getClaimContext, -} from './session/context.js' - -export { - redeemClaimTaskWithInventoryFallbackWithDeps, -} from './session/redeem.js' diff --git a/apps/backend/src/services/claim/session/shared.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts similarity index 62% rename from apps/backend/src/services/claim/session/shared.ts rename to apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index b71d661a..7aaf2c80 100644 --- a/apps/backend/src/services/claim/session/shared.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -1,32 +1,95 @@ -import { buildClaimUrl } from '../claim-service.js' -import { createHttpError } from '../../../utils/http.js' -import { maskCode as maskCodeValue } from '../../../utils/masking.js' -import { formatFenToAmount, normalizeFen } from '../../../utils/money.js' -import { - parseTaskContext as parseTaskContextValue, - parseTaskState as parseTaskStateValue, -} from '../../../utils/task-json.js' +import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js' +import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js' +import { getOrderItemById } from '../../repositories/order-item-repo.js' +import { getOrderById } from '../../repositories/order-repo.js' +import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' +import { createHttpError } from '../../utils/http.js' +import { formatFenToAmount, normalizeFen } from '../../utils/money.js' +import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' +import { nowIso } from '../../utils/time.js' +import { buildClaimUrl } from './claim-service.js' export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) -export const REDEEM_REPLACEMENT_LIMIT = 10 export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' -export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) { - const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) - const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : '' - const finalRedeem = session?.redeem?.final?.redeem || null +export async function getClaimContext(token: unknown) { + const normalized = String(token || '').trim() + + if (!normalized) { + throw createHttpError('缺少领取 token', { + statusCode: 400, + errorCode: 'missing_claim_token', + }) + } + + const claimToken = await findClaimTokenByToken(normalized) + + if (!claimToken) { + throw createHttpError('领取链接无效或不存在', { + statusCode: 404, + errorCode: 'claim_token_not_found', + }) + } + + const task = await findTaskByClaimTokenId(claimToken.id) + + if (!task) { + throw createHttpError('领取任务不存在', { + statusCode: 404, + errorCode: 'claim_task_not_found', + }) + } + + if (claimToken.status !== 'active') { + throw createHttpError('领取链接当前不可用', { + statusCode: 410, + errorCode: 'claim_token_inactive', + }) + } + + if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { + const expiredContext = await expireClaimContext(claimToken, task) + + throw createHttpError('领取链接已过期', { + statusCode: 410, + errorCode: 'claim_token_expired', + context: expiredContext, + }) + } + + const [order, orderItem] = await Promise.all([ + getOrderById(task.order_id), + getOrderItemById(task.order_item_id), + ]) + + if (!order || !orderItem) { + throw createHttpError('领取任务关联订单不完整', { + statusCode: 500, + errorCode: 'claim_order_incomplete', + }) + } + + return { + claimToken, + task, + order, + orderItem, + } +} + +export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) { const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), - flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', + flowType: 'kuaishou_cloud', task: { taskId: task.id, taskNo: task.task_no, status: task.task_status, executorKey: task.executor_key || '', - requiresSupportReview: isAssistedClaimTask(task), + requiresSupportReview: false, expiresAt: claimToken.expired_at, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, @@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se skuName: orderItem.sku_name, quantity: orderItem.quantity, }, - session, + session: null, kuaishouCloudFulfillment, - result: task.redeemed_at || session?.status === 'redeemed' + result: task.redeemed_at ? { - resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), - resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), - screenshotReady, - screenshotUrl, + resultCode: String(task.result_code || ''), + resultMessage: String(task.result_message || ''), + screenshotReady: false, + screenshotUrl: '', } : null, } @@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) { } } -export function assertTaskCanProceed(task) { - if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { - throw createHttpError('当前任务已经结束,不能继续操作', { - statusCode: 410, - errorCode: 'claim_task_closed', - }) - } -} - -export function assertPublicClaimActionAllowed(task, action) { - if (!isAssistedClaimTask(task)) { - return - } - - const message = action === 'confirm' - ? '当前商品需要客服复核角色,请登录后联系人工继续' - : '当前商品需要客服确认后再执行兑换,请联系人工继续' - - throw createHttpError(message, { - statusCode: 409, - errorCode: 'claim_support_review_required', - }) -} - -export function mergeTaskContext(task, patch = {}) { - return { - ...parseTaskContext(task), - ...patch, - } -} - -export function parseTaskState(task) { - return parseTaskStateValue(task) -} - -export function isAssistedClaimTask(task) { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -export function parseTaskContext(task) { +function parseTaskContext(task) { return parseTaskContextValue(task) } -export function normalizeClaimLoginType(loginType) { - return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' -} +async function expireClaimContext(claimToken, task) { + const now = nowIso() + const nextClaimToken = await updateClaimToken(claimToken.id, { + status: 'expired', + updated_at: now, + }) -export function isRecoverableSessionError(error) { - const errorCode = String(error?.errorCode || error?.code || '').trim() - return errorCode === 'session_not_found' || errorCode === 'session_closed' -} + let nextTask = task -export function maskCode(value) { - return maskCodeValue(value, { shortMask: '****' }) + if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { + if (task.primary_inventory_item_id) { + await releaseReservedInventoryItem(task.primary_inventory_item_id, now) + } + + nextTask = await updateTask(task.id, { + task_status: 'expired', + inventory_status: 'pending', + user_action_status: 'expired', + last_error: '领取链接已过期,预占库存项已释放', + updated_at: now, + }) + } + + return { + claimToken: nextClaimToken, + task: nextTask, + } } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index ad535898..8209cacc 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -18,8 +18,7 @@ import { prepareKuaishouCloudFulfillmentTask, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud-task-service.js' -import { buildClaimDetailPayload } from './session/shared.js' -import { getClaimContext } from './session/context.js' +import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') @@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { task, order: context.order, orderItem: context.orderItem, - session: null, }) } diff --git a/apps/backend/src/services/claim/session/actions.ts b/apps/backend/src/services/claim/session/actions.ts deleted file mode 100644 index 7c7bcf0e..00000000 --- a/apps/backend/src/services/claim/session/actions.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionScreenshotPath, -} from '../../session/session.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - getInventoryItemById, - markInventoryItemDelivered, -} from '../../../repositories/inventory-repo.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js' -import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { loadTaskSession, syncTaskWithSession } from './runtime.js' -import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js' -import { - assertPublicClaimActionAllowed, - assertTaskCanProceed, - buildClaimDetailPayload, - maskCode, - mergeTaskContext, -} from './shared.js' - -export async function getClaimDetail(token, { includeQrImage = true } = {}) { - const context = await getClaimContext(token) - let { task, session } = await loadTaskSession(context.task, { includeQrImage }) - - if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { - task = await syncKuaishouCloudRoleInfo(task) - } - - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) { - const context = await getClaimContextByTaskId(taskId) - const { task, session } = await loadTaskSession(context.task, { includeQrImage }) - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimSessionSummary(token) { - return getClaimSessionSummaryByContextLoader(() => getClaimContext(token)) -} - -export async function getClaimSessionSummaryForAdminTask(taskId) { - return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function confirmClaimRole(token) { - const context = await getClaimContext(token) - assertPublicClaimActionAllowed(context.task, 'confirm') - - return finalizeClaimRoleConfirmation(context) -} - -export async function confirmClaimRoleForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - - return finalizeClaimRoleConfirmation(context) -} - -export async function redeemClaimTask(token) { - const context = await getClaimContext(token) - assertTaskCanProceed(context.task) - assertPublicClaimActionAllowed(context.task, 'redeem') - - return finalizeClaimTaskRedeem(context) -} - -export async function redeemClaimTaskForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - assertTaskCanProceed(context.task) - - return finalizeClaimTaskRedeem(context) -} - -export async function getClaimScreenshotPath(token) { - const context = await getClaimContext(token) - - if (context.task.screenshot_path) { - return context.task.screenshot_path - } - - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有兑换截图', { - statusCode: 404, - errorCode: 'claim_screenshot_not_ready', - }) - } - - return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id) -} - -async function getClaimSessionSummaryByContextLoader(loadContext) { - const context = await loadContext() - const { task, session } = await loadTaskSession(context.task, { - includeQrImage: false, - }) - - if (!session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const syncedTask = await syncTaskWithSession(task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimRoleConfirmation(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - const session = await getTencentBrowserSession(context.task.browser_session_id) - const activityInfo = session.activityInfo || null - - if (!activityInfo?.role?.ready) { - throw createHttpError('当前角色信息还没有准备好', { - statusCode: 409, - errorCode: 'claim_role_not_ready', - }) - } - - 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: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimTaskRedeem(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') { - throw createHttpError('当前任务还未确认角色,不能开始兑换', { - statusCode: 409, - errorCode: 'claim_role_not_confirmed', - }) - } - - const inventoryItem = context.task.primary_inventory_item_id - ? await getInventoryItemById(context.task.primary_inventory_item_id) - : null - - if ( - !inventoryItem || - String(inventoryItem.status || '').trim() !== 'reserved' || - !String(inventoryItem.display_value || '').trim() - ) { - throw createHttpError('当前任务没有可用的预占库存凭据', { - statusCode: 409, - errorCode: 'claim_inventory_not_reserved', - }) - } - - await updateTask(context.task.id, { - task_status: 'redeeming', - delivery_status: 'processing', - updated_at: nowIso(), - last_error: '', - }) - - try { - const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem) - const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult - const finalRedeem = session.redeem?.final?.redeem || null - const finishedAt = nowIso() - const updatedTask = await updateTask(context.task.id, { - task_status: 'redeemed', - inventory_status: 'consumed', - delivery_status: 'delivered', - result_code: resolveTencentRedeemResultCode(finalRedeem, classification), - result_message: classification.message, - screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', - artifacts_json: JSON.stringify(session.artifacts || {}), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'success', - attempts, - replacementCount: Math.max(0, attempts.length - 1), - finishedAt, - }, - })), - redeemed_at: finishedAt, - updated_at: finishedAt, - last_error: '', - }) - - await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt) - await createTaskEvent(context.task.id, 'claim_redeem_completed', { - inventoryItemId: deliveredInventoryItem.id, - codeMasked: maskCode(deliveredInventoryItem.display_value), - replacementCount: Math.max(0, attempts.length - 1), - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - }, finishedAt) - const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: context.order, - task: updatedTask, - trigger: 'claim_redeemed', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: autoDeliveryResult.task || updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) - } catch (error) { - const now = nowIso() - const nextRetryCount = Number(context.task.attempt_count || 0) + 1 - const failureState = ( - error && typeof error === 'object' - ? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState - : null - ) || { - taskStatus: 'retry_pending', - inventoryStatus: inventoryItem ? 'reserved' : 'pending', - deliveryStatus: 'pending', - lastError: error instanceof Error ? error.message : String(error || ''), - attempts: [], - classification: null, - } - const failedTask = await updateTask(context.task.id, { - task_status: failureState.taskStatus, - inventory_status: failureState.inventoryStatus, - delivery_status: failureState.deliveryStatus, - result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '', - result_message: String(failureState.lastError || ''), - attempt_count: nextRetryCount, - last_error: String(failureState.lastError || ''), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'failed', - taskStatus: failureState.taskStatus, - attempts: failureState.attempts || [], - replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1), - finishedAt: now, - }, - })), - updated_at: now, - }) - - if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) { - await notifyClaimRedeemNeedsAttention({ - task: failedTask, - order: context.order, - status: failureState.taskStatus, - errorMessage: failureState.lastError, - }) - } - - throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { - task: failedTask, - }) - } -} diff --git a/apps/backend/src/services/claim/session/context.ts b/apps/backend/src/services/claim/session/context.ts deleted file mode 100644 index de77bca6..00000000 --- a/apps/backend/src/services/claim/session/context.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { getOrderItemById } from '../../../repositories/order-item-repo.js' -import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js' -import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { CLAIM_TERMINAL_STATUSES } from './shared.js' - -export async function getClaimContext(token) { - const normalized = String(token || '').trim() - - if (!normalized) { - throw createHttpError('缺少领取 token', { - statusCode: 400, - errorCode: 'missing_claim_token', - }) - } - - const claimToken = await findClaimTokenByToken(normalized) - - if (!claimToken) { - throw createHttpError('领取链接无效或不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const task = await findTaskByClaimTokenId(claimToken.id) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - if (claimToken.status !== 'active') { - throw createHttpError('领取链接当前不可用', { - statusCode: 410, - errorCode: 'claim_token_inactive', - }) - } - - if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { - const expiredContext = await expireClaimContext(claimToken, task) - - throw createHttpError('领取链接已过期', { - statusCode: 410, - errorCode: 'claim_token_expired', - context: expiredContext, - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -export async function getClaimContextByTaskId(taskId) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - const claimTokenId = Number(task.primary_claim_token_id || 0) - - if (!claimTokenId) { - throw createHttpError('当前任务还没有领取链接', { - statusCode: 409, - errorCode: 'claim_token_missing', - }) - } - - const claimToken = await getClaimTokenById(claimTokenId) - - if (!claimToken) { - throw createHttpError('领取链接不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -async function expireClaimContext(claimToken, task) { - const now = nowIso() - const nextClaimToken = await updateClaimToken(claimToken.id, { - status: 'expired', - updated_at: now, - }) - - let nextTask = task - - if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { - if (task.primary_inventory_item_id) { - await releaseReservedInventoryItem(task.primary_inventory_item_id, now) - } - - nextTask = await updateTask(task.id, { - task_status: 'expired', - inventory_status: 'pending', - user_action_status: 'expired', - last_error: '领取链接已过期,预占库存项已释放', - updated_at: now, - }) - } - - return { - claimToken: nextClaimToken, - task: nextTask, - } -} diff --git a/apps/backend/src/services/claim/session/lifecycle.ts b/apps/backend/src/services/claim/session/lifecycle.ts deleted file mode 100644 index 3b39f57b..00000000 --- a/apps/backend/src/services/claim/session/lifecycle.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - closeTencentBrowserSession, - createTencentBrowserSession, - reloadTencentBrowserSession, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js' -import { - assertTaskCanProceed, - buildClaimDetailPayload, - isRecoverableSessionError, - normalizeClaimLoginType, -} from './shared.js' - -type JsonObject = Record - -export async function createClaimSession(token: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContext(token), - payload, - ) -} - -export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContextByTaskId(taskId), - payload, - ) -} - -export async function reloadClaimSession(token: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function reloadClaimSessionForAdminTask(taskId: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function closeClaimSession(token: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function closeClaimSessionForAdminTask(taskId: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -async function createClaimSessionWithContextLoader(loadContext: () => Promise, payload: JsonObject = {}) { - const context = await loadContext() - assertTaskCanProceed(context.task) - const requestedLoginType = normalizeClaimLoginType(payload.loginType) - const forceRecreate = Boolean(payload.forceRecreate) - let task = context.task - - if (task.browser_session_id) { - const existing = await loadTaskSession(task, { includeQrImage: true }) - task = existing.task - - if (existing.session) { - const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) - - if (!forceRecreate && existingLoginType === requestedLoginType) { - const syncedTask = await syncTaskWithSession(task, existing.session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session: existing.session, - }) - } - - try { - await closeTencentBrowserSession(existing.session.sessionId) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - } - } - - const session = await createTencentBrowserSession({ - loginType: requestedLoginType, - }) - const updatedTask = await updateTask(task.id, { - task_status: 'claimed', - browser_session_id: session.sessionId, - login_type: session.loginType, - user_action_status: 'claimed', - claimed_at: task.claimed_at || nowIso(), - updated_at: nowIso(), - last_error: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function reloadClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - - const active = await loadTaskSession(context.task, { - includeQrImage: true, - }) - - if (!active.session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: active.task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const session = await reloadTencentBrowserSession(active.session.sessionId) - const syncedTask = await syncTaskWithSession(active.task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function closeClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - let task = context.task - - if (!task.browser_session_id) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - try { - await closeTencentBrowserSession(task.browser_session_id) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) -} diff --git a/apps/backend/src/services/claim/session/redeem.ts b/apps/backend/src/services/claim/session/redeem.ts deleted file mode 100644 index cb50ecc2..00000000 --- a/apps/backend/src/services/claim/session/redeem.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js' -import { classifyTencentRedeemResult } from '../../session/session-redeem.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { - invalidateReservedInventoryItem, - markInventoryItemConsumed, -} from '../../../repositories/inventory-repo.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js' -import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js' - -type ClaimRedeemContext = { - task: Pick - orderItem: Pick -} - -type TencentRedeemPayload = { - [key: string]: unknown - iRet?: unknown - ret?: unknown -} - -type TencentRedeemSession = { - [key: string]: unknown - sessionId?: string - artifacts?: { - [key: string]: unknown - hasScreenshot?: boolean - } | null - redeem?: { - final?: { - redeem?: TencentRedeemPayload | null - } | null - } | null -} - -type TencentRedeemClassification = { - success: boolean - outcome: string - message: string - retCode?: unknown - [key: string]: unknown -} - -type RedeemAttemptSummary = { - attempt: number - inventoryItemId: number - codeMasked: string - credentialType: string - outcome: string - resultCode: string - resultMessage: string -} - -type RedeemTaskState = { - taskStatus: string - inventoryStatus: string - deliveryStatus: string - lastError: string - attempts: RedeemAttemptSummary[] - classification: TencentRedeemClassification | null -} - -type RedeemTaskStateError = Error & { - statusCode?: number - errorCode?: string - redeemTaskState: RedeemTaskState -} - -type RedeemTaskStateErrorPayload = { - statusCode?: number - errorCode?: string - taskStatus?: string - inventoryStatus?: string - deliveryStatus?: string - attempts?: RedeemAttemptSummary[] - classification?: TencentRedeemClassification | null -} - -type RedeemClaimTaskDeps = { - reloadTencentBrowserSession?: (sessionId: string) => Promise - redeemTencentBrowserSession?: ( - sessionId: string, - payload: { code: string }, - ) => Promise - classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification - markInventoryItemConsumed?: ( - inventoryItemId: number | string, - reason: string, - consumedAt: string, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - invalidateReservedInventoryItem?: ( - inventoryItemId: number | string, - reason: string, - updatedAt: string, - ) => Promise - reserveInventoryForTask?: (payload: { - skuCode: string - taskId: number | string - credentialType?: string - roleKey?: string - inventoryGroupCodes?: string[] | null - }) => Promise - nowIso?: () => string - redeemReplacementLimit?: number -} - -type RedeemClaimTaskResult = { - session: TencentRedeemSession - inventoryItem: InventoryItemRow - classification: TencentRedeemClassification - attempts: RedeemAttemptSummary[] -} - -export async function redeemClaimTaskWithInventoryFallback( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, -): Promise { - return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) -} - -export async function redeemClaimTaskWithInventoryFallbackWithDeps( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, - { - reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession, - redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession, - classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult, - markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed, - createTaskEvent: createRedeemTaskEvent = createTaskEvent, - invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem, - reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask, - nowIso: getNowIso = nowIso, - redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT, - }: RedeemClaimTaskDeps = {}, -): Promise { - const attempts: RedeemAttemptSummary[] = [] - let currentInventoryItem = initialInventoryItem - let shouldReloadBeforeNextAttempt = false - - for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) { - if (shouldReloadBeforeNextAttempt) { - await reloadRedeemSession(context.task.browser_session_id) - shouldReloadBeforeNextAttempt = false - } - - const session = await redeemSession(context.task.browser_session_id, { - code: currentInventoryItem.display_value, - }) - const finalRedeem = session.redeem?.final?.redeem || null - const classification = classifyRedeemResult(finalRedeem) - const attemptSummary = { - attempt: attemptIndex, - inventoryItemId: currentInventoryItem.id, - codeMasked: maskCode(currentInventoryItem.display_value), - credentialType: String(currentInventoryItem.credential_type || ''), - outcome: classification.outcome, - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - } - - attempts.push(attemptSummary) - - if (classification.success) { - return { - session, - inventoryItem: currentInventoryItem, - classification, - attempts, - } - } - - if (classification.outcome === 'code_used') { - const updatedAt = getNowIso() - await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else if (classification.outcome === 'code_invalid') { - const updatedAt = getNowIso() - await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else { - throw createRedeemTaskStateError(classification.message, { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'reserved', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - if (attemptIndex >= redeemReplacementLimit) { - throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', { - errorCode: 'claim_redeem_replacement_limit_reached', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacement = await reserveReplacementInventory({ - skuCode: context.orderItem.sku_code, - taskId: context.task.id, - credentialType: currentInventoryItem.credential_type || 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: currentInventoryItem.inventory_group_code - ? [String(currentInventoryItem.inventory_group_code).trim()] - : null, - }) - - if (!replacement || !String(replacement.display_value || '').trim()) { - const exhaustedMessage = classification.outcome === 'code_used' - ? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试' - : '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试' - - throw createRedeemTaskStateError(exhaustedMessage, { - errorCode: 'claim_inventory_replacement_exhausted', - taskStatus: 'waiting_inventory', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacedAt = getNowIso() - await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { - previousInventoryItemId: currentInventoryItem.id, - previousCodeMasked: attemptSummary.codeMasked, - previousOutcome: classification.outcome, - nextInventoryItemId: replacement.id, - nextCodeMasked: maskCode(replacement.display_value), - credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), - inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(), - }, replacedAt) - currentInventoryItem = replacement - shouldReloadBeforeNextAttempt = true - } - - throw createRedeemTaskStateError('兑换失败,请稍后重试', { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification: null, - }) -} - -function createRedeemTaskStateError( - message: string, - payload: RedeemTaskStateErrorPayload = {}, -): RedeemTaskStateError { - const error = createHttpError(message, { - statusCode: payload.statusCode || 409, - errorCode: payload.errorCode || 'claim_redeem_failed', - }) as unknown as RedeemTaskStateError - - error.redeemTaskState = { - taskStatus: payload.taskStatus || 'retry_pending', - inventoryStatus: payload.inventoryStatus || 'pending', - deliveryStatus: payload.deliveryStatus || 'pending', - lastError: String(message || ''), - attempts: payload.attempts || [], - classification: payload.classification || null, - } - - return error -} - -export function resolveTencentRedeemResultCode( - finalRedeem: TencentRedeemPayload | null, - classification: Pick | null, -): string { - if (classification?.retCode != null) { - return String(classification.retCode) - } - - const value = finalRedeem?.iRet ?? finalRedeem?.ret - return value == null ? '' : String(value) -} diff --git a/apps/backend/src/services/claim/session/runtime.ts b/apps/backend/src/services/claim/session/runtime.ts deleted file mode 100644 index 7b3c0cfc..00000000 --- a/apps/backend/src/services/claim/session/runtime.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionSummary, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { isRecoverableSessionError, parseTaskState } from './shared.js' - -type JsonObject = Record - -export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) { - if (!task.browser_session_id) { - return { - task, - session: null, - } - } - - try { - const session = includeQrImage - ? await getTencentBrowserSession(task.browser_session_id) - : await getTencentBrowserSessionSummary(task.browser_session_id) - - return { - task, - session, - } - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - - const nextTask = await clearTaskSession(task, { - lastError: '浏览器会话已失效,请重新初始化登录', - }) - - return { - task: nextTask, - session: null, - } - } -} - -export async function syncTaskWithSession(task: JsonObject, session: JsonObject) { - const activityInfo = session.activityInfo || null - const patch: JsonObject = { - login_type: String(session.loginType || task.login_type || ''), - updated_at: nowIso(), - } - - if (activityInfo?.nickname) { - patch.nickname = String(activityInfo.nickname) - } - - if (activityInfo?.role?.ready) { - patch.role_id = String(activityInfo.role.roleId || '') - patch.role_name = String(activityInfo.role.roleName || '') - patch.area = String(activityInfo.role.area || '') - patch.partition_name = String(activityInfo.role.partition || '') - } - - if (task.task_status === 'link_generated') { - patch.task_status = 'claimed' - patch.user_action_status = 'claimed' - patch.claimed_at = task.claimed_at || nowIso() - } - - if (session.review?.capturedAt) { - patch.state_json = JSON.stringify({ - ...parseTaskState(task), - reviewScreenshotReady: true, - reviewCapturedAt: String(session.review.capturedAt || ''), - reviewRoleId: String(session.review.roleId || ''), - reviewRoleName: String(session.review.roleName || ''), - }) - } - - if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { - patch.screenshot_path = task.screenshot_path || '' - } - - return updateTask(task.id, patch) -} - -export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) { - const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || '')) - const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status - const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status - const patch = { - task_status: nextTaskStatus, - user_action_status: nextUserActionStatus, - browser_session_id: '', - login_type: '', - nickname: '', - role_id: '', - role_name: '', - area: '', - partition_name: '', - artifacts_json: '{}', - state_json: '{}', - role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at, - last_error: String(lastError || ''), - updated_at: nowIso(), - } - - return updateTask(task.id, patch) -} diff --git a/apps/backend/src/services/order/agiso-trade-parsing.test.ts b/apps/backend/src/services/order/agiso-trade-parsing.test.ts deleted file mode 100644 index 4f85b9d5..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js' - -test('extractAgisoTradePayload preserves large integer order ids from raw json', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(payload.biz_order_id, '4502259793206016214') - assert.equal(typeof payload.biz_order_id, 'string') -}) - -test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214') -}) - -test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => { - assert.equal( - resolveAgisoTradePlatformOrderId({ - tid: '4502251008019011224', - orders: [{ oid: '4502251008019011888' }], - }), - '4502251008019011224', - ) -}) diff --git a/apps/backend/src/services/order/agiso-trade-parsing.ts b/apps/backend/src/services/order/agiso-trade-parsing.ts deleted file mode 100644 index c21167f1..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { parseJsonObject } from '../../utils/json.js' - -type JsonObject = Record - -export function extractAgisoTradePayload(body: unknown): JsonObject { - const normalizedBody = normalizeRecord(body) - const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim() - - if (rawJson) { - return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true })) - } - - return normalizedBody -} - -export function resolveAgisoTradePlatformOrderId(payload: unknown): string { - const normalizedPayload = normalizeRecord(payload) - const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {} - - return pickFirstNonEmpty([ - normalizedPayload.biz_order_id, - normalizedPayload.Tid, - normalizedPayload.tid, - normalizedPayload.Oid, - normalizedPayload.oid, - normalizedPayload.order_id, - normalizedPayload.orderId, - firstItem.Oid, - firstItem.oid, - ]) -} - -export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] { - const normalizedPayload = normalizeRecord(payload) - const candidates = [ - normalizedPayload.items, - normalizedPayload.Items, - normalizedPayload.orders, - normalizedPayload.Orders, - normalizedPayload.order_list, - normalizedPayload.OrderList, - ] - - for (const current of candidates) { - if (Array.isArray(current) && current.length > 0) { - return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) - } - } - - if (Object.keys(normalizedPayload).length > 0) { - return [normalizedPayload] - } - - return [] -} - -function normalizeRecord(value: unknown): JsonObject { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - } - - return '' -} diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts deleted file mode 100644 index a886995c..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - filterLegacyOrderFulfillmentBindings, - isKuaishouCloudFulfillmentBinding, -} from './fulfillment-binding-config-service.js' - -test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => { - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: '91kaquan', - platform: 'kuaishou', - }), - true, - ) - - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: 'agiso', - platform: 'xianyu', - }), - false, - ) -}) - -test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => { - assert.deepEqual( - filterLegacyOrderFulfillmentBindings([ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - { - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - skuCode: 'sku-b', - }, - ]), - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - ], - ) -}) diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.ts deleted file mode 100644 index ed8511fa..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import path from 'node:path' - -import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' - -const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json') - -type JsonObject = Record - -export function getOrderFulfillmentBindingsFilePath() { - return ORDER_FULFILLMENT_BINDINGS_FILE_PATH -} - -export function getOrderFulfillmentBindingConfigs() { - return loadOrderFulfillmentBindingConfigsFromFile() -} - -export function getLegacyOrderFulfillmentBindingConfigs() { - return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs()) -} - -export function saveOrderFulfillmentBindingConfigs(rawValue) { - return writeJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - rawValue, - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function loadOrderFulfillmentBindingConfigsFromFile() { - return readJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - [], - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function normalizeOrderFulfillmentBindingConfigs(rawValue) { - if (!Array.isArray(rawValue)) { - return [] - } - - return rawValue - .map((item) => normalizeOrderFulfillmentBinding(item)) - .filter(Boolean) -} - -export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) { - return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item)) -} - -export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) { - return String(binding?.provider || '').trim() === '91kaquan' - && String(binding?.platform || '').trim() === 'kuaishou' -} - -function normalizeOrderFulfillmentBinding(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const provider = String(rawValue.provider || 'agiso').trim() || 'agiso' - const platform = String(rawValue.platform || '').trim() - const shopId = String(rawValue.shopId || '').trim() - const shopName = String(rawValue.shopName || '').trim() - const skuCode = String(rawValue.skuCode || '').trim() - const skuName = String(rawValue.skuName || '').trim() - const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review' - const priority = normalizePriority(rawValue.priority) - const enabled = rawValue.enabled !== false - const config = normalizeJsonObject(rawValue.config) - const match = normalizeOrderFulfillmentMatch(rawValue.match) - - if (!skuCode || !match) { - return null - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled, - priority, - config, - match, - } -} - -function normalizeOrderFulfillmentMatch(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const externalSkuCode = String(rawValue.externalSkuCode || '').trim() - const externalItemId = String(rawValue.externalItemId || '').trim() - const externalSkuName = String(rawValue.externalSkuName || '').trim() - - if (!externalSkuCode && !externalItemId && !externalSkuName) { - return null - } - - return { - externalSkuCode, - externalItemId, - externalSkuName, - config: normalizeJsonObject(rawValue.config), - } -} - -function normalizePriority(value) { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return 100 - } - - return Math.max(1, Math.round(parsed)) -} - -function normalizeJsonObject(value) { - return isPlainObject(value) ? value : {} -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/order/webhook-service.test.ts b/apps/backend/src/services/order/webhook-service.test.ts deleted file mode 100644 index 8bbf2b9a..00000000 --- a/apps/backend/src/services/order/webhook-service.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildWebhookEventInput, - executeAgisoTradeWebhookWithDeps, - parseAgisoTradeRequest, - resolveAgisoTradeIgnoreReason, - shouldEnrichAgisoXianyuTrade, - verifyAgisoSignatureWithSecret, -} from './webhook-service.js' - -function createParsedTrade(patch = {}) { - return { - provider: 'agiso', - platform: 'xianyu', - platformRaw: 'xianyu', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840', - signatureValid: true, - platformOrderId: '4502259793206016214', - shopId: 'shop-9', - shopName: '咸鱼店铺', - shopIdAliases: ['shop-9'], - orderStatus: 'paid', - payStatus: 'paid', - buyerId: 'buyer-1', - buyerName: '测试买家', - receiverContact: '', - totalAmount: 2550, - currency: 'CNY', - paidAt: '2026-04-14T12:30:40.000Z', - rawPayload: {}, - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - ...patch, - } -} - -test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => { - const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1713097840' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.provider, 'agiso') - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'payment_success') - assert.equal(parsed.signatureValid, true) - assert.equal(parsed.platformOrderId, '4502259793206016214') - assert.equal(parsed.shopId, 'shop-9') - assert.equal(parsed.shopName, '咸鱼店铺') - assert.equal(parsed.payStatus, 'paid') - assert.equal(parsed.orderStatus, 'paid') - assert.equal(parsed.totalAmount, 2550) - assert.equal(parsed.items.length, 1) - assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(parsed.items[0]?.quantity, 2) -}) - -test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => { - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 0, - buyerName: '', - shopName: '', - items: [], - }), true) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 2550, - buyerName: '测试买家', - shopName: '咸鱼店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - spec: { - title: 'DNF礼包', - }, - }, - ], - }), false) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'taobao', - totalAmount: 0, - items: [], - }), false) -}) - -test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => { - const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1776096226' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '16', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'AldsIdle', - }, - }) - - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => { - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp: '1776096226', - sign: '', - }, - body: { - json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}', - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => { - const appSecret = 'unit-test-secret' - const rawJson = '{"biz_order_id":"4502259793206016214"}' - const timestamp = '1713097840' - const documentedSign = crypto - .createHash('md5') - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') - .digest('hex') - const legacySign = crypto - .createHash('md5') - .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }), - false, - ) -}) - -test('buildWebhookEventInput preserves raw request payload for later replay', () => { - const input = buildWebhookEventInput( - { - headers: { 'x-request-id': 'req-1' }, - query: { timestamp: '1713097840', sign: 'bad-sign' }, - body: { json: '{"biz_order_id":"4502259793206016214"}' }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-9', - shopName: '咸鱼店铺', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:4502259793206016214:payment_success', - signatureValid: false, - }, - ) - - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.eventType, 'payment_success') - assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success') - assert.equal(input.signatureValid, false) - assert.equal(input.processed, false) - assert.equal(input.processError, '') - assert.equal(input.relatedOrderId, null) - assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' }) - assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' }) - assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' }) - assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/) -}) - -test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => { - const updates = [] - - await assert.rejects( - () => executeAgisoTradeWebhookWithDeps( - createParsedTrade({ signatureValid: false }), - 701, - { requestId: 'req-invalid-signature' }, - { - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - hasConfiguredOrderItems: async () => { - throw new Error('should not check configured items when signature is invalid') - }, - }, - ), - /验签失败/, - ) - - assert.deepEqual(updates, [ - { - webhookEventId: 701, - patch: { - processed: false, - process_error: '验签失败', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => { - const updates = [] - let upsertCalled = false - - const result = await executeAgisoTradeWebhookWithDeps( - createParsedTrade(), - 702, - { requestId: 'req-unconfigured' }, - { - hasConfiguredOrderItems: async (input) => { - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.shopId, 'shop-9') - assert.equal(input.items.length, 1) - return false - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - upsertOrderFromWebhook: async () => { - upsertCalled = true - return {} - }, - }, - ) - - assert.equal(result.accepted, true) - assert.equal(result.ignored, true) - assert.equal(result.ignoreReason, 'unconfigured_product') - assert.equal(result.orderId, null) - assert.equal(upsertCalled, false) - assert.deepEqual(updates, [ - { - webhookEventId: 702, - patch: { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => { - const updates = [] - const enrichCalls = [] - const upsertCalls = [] - const parsed = createParsedTrade({ - totalAmount: 0, - buyerName: '', - shopName: '', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'dnf-cdk-a', - quantity: 1, - spec: {}, - }, - ], - }) - - const result = await executeAgisoTradeWebhookWithDeps( - parsed, - 703, - { requestId: 'req-success' }, - { - hasConfiguredOrderItems: async () => true, - enrichAgisoXianyuTradeOrder: async (input, options) => { - enrichCalls.push({ input, options }) - return { - parsed: { - ...input, - totalAmount: 1990, - buyerName: '补查买家', - shopName: '补查店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - }, - enriched: true, - reason: '', - } - }, - upsertOrderFromWebhook: async (input) => { - upsertCalls.push(input) - return { - ignored: false, - ignoreReason: '', - order: { id: 801 }, - tasks: [ - { id: 901, task_no: 'DT901', task_status: 'paid' }, - ], - messageDeliveries: [{ id: 1001 }], - } - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - }, - ) - - assert.equal(enrichCalls.length, 1) - assert.equal(enrichCalls[0].options.requestId, 'req-success') - assert.equal(upsertCalls.length, 1) - assert.equal(upsertCalls[0].totalAmount, 1990) - assert.equal(upsertCalls[0].buyerName, '补查买家') - assert.equal(result.accepted, true) - assert.equal(result.ignored, false) - assert.equal(result.enriched, true) - assert.equal(result.orderId, 801) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.taskCount, 1) - assert.deepEqual(result.tasks, [ - { - taskId: 901, - taskNo: 'DT901', - status: 'paid', - }, - ]) - assert.deepEqual(updates, [ - { - webhookEventId: 703, - patch: { - processed: true, - process_error: '', - related_order_id: 801, - }, - }, - ]) -}) diff --git a/apps/backend/src/services/order/webhook-service.ts b/apps/backend/src/services/order/webhook-service.ts deleted file mode 100644 index 4f050c34..00000000 --- a/apps/backend/src/services/order/webhook-service.ts +++ /dev/null @@ -1,1038 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js' -import { buildClaimUrl } from '../claim/claim-service.js' -import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js' -import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' -import { - extractAgisoTradeOrderItemSources, - extractAgisoTradePayload, - resolveAgisoTradePlatformOrderId, -} from './agiso-trade-parsing.js' -import { upsertOrderFromSource, upsertOrderFromWebhook } from './order-service.js' -import { hasConfiguredOrderItems } from './product-match-service.js' -import { createHttpError } from '../../utils/http.js' -import { parseJsonObject } from '../../utils/json.js' -import { logWebhook } from '../../utils/logger.js' -import { parseAmountToFen } from '../../utils/money.js' -import { nowIso } from '../../utils/time.js' - -type AgisoTradeWebhookDeps = { - updateWebhookEvent?: typeof updateWebhookEvent - hasConfiguredOrderItems?: typeof hasConfiguredOrderItems - upsertOrderFromWebhook?: typeof upsertOrderFromWebhook - enrichAgisoXianyuTradeOrder?: typeof enrichAgisoXianyuTradeOrder -} - -export async function processAgisoTradeWebhook(requestLike) { - const parsed = parseAgisoTradeRequest(requestLike) - const webhookEvent = await createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) - - logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', { - requestId: requestLike.requestId || '', - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' }) -} - -export async function replayAgisoTradeWebhookEvent(webhookEvent) { - const requestLike = { - requestId: `replay-${webhookEvent.id}`, - headers: safeParseJson(webhookEvent.headers_json), - query: safeParseJson(webhookEvent.query_json), - body: safeParseJson(webhookEvent.body_json), - } - const parsed = parseAgisoTradeRequest(requestLike) - - logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', { - requestId: requestLike.requestId, - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) -} - -export function buildWebhookEventInput(requestLike, parsed) { - return { - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventType: parsed.eventType, - eventKey: parsed.eventKey, - signatureValid: parsed.signatureValid, - headersJson: JSON.stringify(requestLike.headers || {}), - queryJson: JSON.stringify(requestLike.query || {}), - bodyJson: JSON.stringify(requestLike.body || {}), - processed: false, - processError: '', - relatedOrderId: null, - createdAt: nowIso(), - } -} - -async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { - return executeAgisoTradeWebhookWithDeps(parsed, webhookEventId, { requestId }) -} - -export async function executeAgisoTradeWebhookWithDeps( - parsed, - webhookEventId, - { requestId = '' } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const updateEvent = deps.updateWebhookEvent || updateWebhookEvent - const hasConfiguredItems = deps.hasConfiguredOrderItems || hasConfiguredOrderItems - const upsertOrder = deps.upsertOrderFromWebhook || upsertAgisoOrderFromWebhook - - try { - if (!parsed.signatureValid) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'invalid_signature', - }) - } - - if (!parsed.platformOrderId) { - throw createHttpError('缺少平台订单号', { - statusCode: 400, - errorCode: 'missing_platform_order_id', - }) - } - - const ignoreReason = resolveAgisoTradeIgnoreReason(parsed) - if (ignoreReason) { - await updateEvent(webhookEventId, { - processed: true, - process_error: `ignored_${ignoreReason}`, - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:当前通知不应再次触发履约发送', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - ignoreReason, - }) - - return { - accepted: true, - ignored: true, - ignoreReason, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_ignored_event', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const shouldProcess = await hasConfiguredItems({ - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopIdAliases: parsed.shopIdAliases, - items: parsed.items, - }) - - if (!shouldProcess) { - await updateEvent(webhookEventId, { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:未命中任何已配置履约商品', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformOrderId: parsed.platformOrderId, - rawItemCount: Array.isArray(parsed.items) ? parsed.items.length : 0, - }) - - return { - accepted: true, - ignored: true, - ignoreReason: 'unconfigured_product', - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_unconfigured_product', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const enriched = await enrichTradeBeforeUpsert(parsed, { - requestId, - webhookEventId, - }, deps) - const result = await upsertOrder(enriched.parsed) - await updateEvent(webhookEventId, { - processed: true, - process_error: result.ignored ? 'ignored_unconfigured_product' : '', - related_order_id: result.order?.id || null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - taskCount: result.tasks.length, - messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, - }) - - return { - accepted: true, - provider: enriched.parsed.provider, - platform: enriched.parsed.platform, - shopId: enriched.parsed.shopId, - shopName: enriched.parsed.shopName, - platformRaw: enriched.parsed.platformRaw, - eventType: enriched.parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - taskCount: result.tasks.length, - messageDeliveries: result.messageDeliveries || [], - tasks: result.tasks.map((task) => ({ - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - })), - } - } catch (error) { - await updateEvent(webhookEventId, { - processed: false, - process_error: error instanceof Error ? error.message : String(error || ''), - related_order_id: null, - }) - - logWebhook( - '[webhook-service/agiso]', - 'Webhook 业务处理失败', - { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - error, - }, - { level: 'error' }, - ) - throw error - } -} - -async function upsertAgisoOrderFromWebhook(event) { - return upsertOrderFromSource(event, { - sourceLabel: 'webhook', - handleClaimLinkTask: async ({ order, task }) => { - const claimToken = await getClaimTokenById(task.primary_claim_token_id) - if (!claimToken) { - return null - } - - return ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl: buildClaimUrl(claimToken.token), - expiredAt: claimToken.expired_at, - }) - }, - }) -} - -async function enrichTradeBeforeUpsert( - parsed, - { requestId = '', webhookEventId = null } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const enrichOrder = deps.enrichAgisoXianyuTradeOrder || enrichAgisoXianyuTradeOrder - - if (parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return { parsed, enriched: false, reason: 'not_supported' } - } - - if (!shouldEnrichAgisoXianyuTrade(parsed)) { - return { parsed, enriched: false, reason: 'enough_fields_present' } - } - - const detailResult = await enrichOrder(parsed, { requestId }) - const nextParsed = detailResult?.parsed || parsed - - logWebhook('[webhook-service/agiso]', 'Agiso webhook 订单补查结束', { - requestId, - webhookEventId, - provider: nextParsed.provider, - platform: nextParsed.platform, - shopId: nextParsed.shopId, - platformOrderId: nextParsed.platformOrderId, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - totalAmountFen: Number(nextParsed.totalAmount || 0), - paidAt: nextParsed.paidAt || null, - errorMessage: String(detailResult?.errorMessage || ''), - }) - - return { - parsed: nextParsed, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - errorMessage: String(detailResult?.errorMessage || ''), - } -} - -export function shouldEnrichAgisoXianyuTrade(parsed) { - if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return false - } - - if (Number(parsed.totalAmount || 0) <= 0) { - return true - } - - if (!String(parsed.buyerName || '').trim()) { - return true - } - - if (!String(parsed.shopName || '').trim()) { - return true - } - - if (!hasDetailedOrderItems(parsed.items)) { - return true - } - - return false -} - -function hasDetailedOrderItems(items) { - const normalizedItems = Array.isArray(items) ? items : [] - - if (normalizedItems.length === 0) { - return false - } - - return normalizedItems.some((item) => { - const spec = isPlainObject(item?.spec) ? item.spec : {} - const title = pickFirstNonEmpty([ - spec.title, - spec.Title, - spec.itemTitle, - spec.item_title, - spec.goods_name, - spec.goodsName, - item?.skuName, - ]) - const skuCode = String(item?.skuCode || '').trim() - - return Boolean(title && title !== skuCode) - }) -} - -export function parseAgisoTradeRequest(requestLike) { - const query = normalizeRecord(requestLike.query) - const body = normalizeRecord(requestLike.body) - const rawJson = String(body.json || '').trim() - const payload = rawJson ? parsePayloadJson(rawJson) : extractAgisoTradePayload(body) - const timestamp = String(query.timestamp || '').trim() - const sign = String(query.sign || '').trim().toLowerCase() - const provider = 'agiso' - const platformRaw = pickFirstNonEmpty([ - query.fromPlatform, - query.from_platform, - body.fromPlatform, - body.from_platform, - payload.fromPlatform, - payload.FromPlatform, - payload.platform, - payload.Platform, - ]) - const platform = resolveBusinessPlatform(platformRaw) - const shop = resolveShop(payload) - const eventType = resolveEventType(query.aopic, payload) - const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) - const platformOrderId = resolveAgisoTradePlatformOrderId(payload) - - return { - provider, - platform, - platformRaw, - eventType, - eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'), - signatureValid, - platformOrderId, - shopId: shop.shopId, - shopName: shop.shopName, - orderStatus: resolveOrderStatus(eventType, payload), - payStatus: resolvePayStatus(eventType, payload), - buyerId: pickFirstNonEmpty([ - payload.buyer_id, - payload.buyerId, - payload.BuyerId, - payload.openid, - payload.BuyerOpenUid, - payload.buyer_open_uid, - payload.buyerOpenUid, - ]), - buyerName: pickFirstNonEmpty([ - payload.buyer_name, - payload.buyerName, - payload.BuyerName, - payload.nick, - payload.BuyerNick, - payload.buyer_nick, - payload.buyerNick, - ]), - receiverContact: pickFirstNonEmpty([ - payload.receiver_contact, - payload.receiverContact, - payload.mobile, - payload.phone, - payload.receiver_mobile, - payload.receiverMobile, - ]), - totalAmount: parseAmountToFen( - pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.Payment, - payload.payment, - ]), - ), - currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', - paidAt: resolvePaidAt(eventType, payload), - rawPayload: payload, - items: normalizeOrderItems(payload), - } -} - -export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) { - const normalizedSecret = String(appSecret || '').trim() - - if (!normalizedSecret) { - return true - } - - if (!rawJson || !timestamp || !sign) { - return false - } - - const documentedDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8') - .digest('hex') - .toLowerCase() - - if (documentedDigest === sign) { - return true - } - - const legacyDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - .toLowerCase() - - return legacyDigest === sign -} - -function verifyAgisoSignature({ rawJson, timestamp, sign }) { - const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() - return verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) -} - -function resolveEventType(aopic, payload) { - const normalized = String(aopic || '').trim() - - if (isTradeFinishedPayload(payload)) { - return 'buyer_confirm_goods' - } - - if (normalized === '1') { - return 'payment_success' - } - - if (normalized === '21') { - return 'trade_create' - } - - if (normalized === '32') { - return 'trade_create' - } - - if (normalized === '16' || normalized === '256') { - return 'buyer_confirm_goods' - } - - if (normalized === '128') { - return 'trade_closed' - } - - const status = resolveProviderStatus(payload) - if (status === 'closed') { - return 'trade_closed' - } - - return 'trade_event' -} - -export function resolveAgisoTradeIgnoreReason(parsed) { - if (!parsed || parsed.provider !== 'agiso') { - return '' - } - - if (parsed.eventType === 'buyer_confirm_goods') { - return 'buyer_confirm_goods' - } - - if (parsed.platform === 'xianyu' && isTradeFinishedPayload(parsed.rawPayload)) { - return 'buyer_confirm_goods' - } - - return '' -} - -function resolveOrderStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'closed' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'closed' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'created' -} - -function resolvePayStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'unpaid' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'unpaid' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'unpaid' -} - -function resolvePaidAt(eventType, payload) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - if (resolveProviderStatus(payload) === 'paid') { - return nowIso() - } - - if (eventType === 'payment_success') { - return nowIso() - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function parsePayloadJson(rawJson) { - if (!rawJson) { - return {} - } - - return parseJsonObject(rawJson, { - preserveLargeIntegers: true, - throwOnError: true, - }) -} - -function normalizeOrderItems(payload) { - const items = extractAgisoTradeOrderItemSources(payload) - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - payload._agisoTradeDetail?.sku, - ]), - ) - const rawSkuCandidates = [ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ] - const skuCode = resolveSkuCode(rawSkuCandidates) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function resolveBusinessPlatform(rawPlatform) { - const normalized = normalizePlatformKey(rawPlatform) - - if (!normalized) { - return 'unknown' - } - - const aliases = { - xianyu: 'xianyu', - idlefish: 'xianyu', - idle: 'xianyu', - aldsidle: 'xianyu', - taobaoidle: 'xianyu', - taobaoxianyu: 'xianyu', - taobao: 'taobao', - tb: 'taobao', - pdd: 'pdd', - pinduoduo: 'pdd', - douyin: 'douyin', - weidian: 'weidian', - jd: 'jd', - jingdong: 'jd', - } - - return aliases[normalized] || normalized -} - -function normalizePlatformKey(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/[\s_-]+/g, '') -} - -function resolveShop(payload) { - const shopName = pickFirstNonEmpty([ - payload.shop_name, - payload.shopName, - payload.ShopName, - payload.seller_name, - payload.sellerName, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]) - - return { - shopId: pickFirstNonEmpty([ - payload.shop_id, - payload.shopId, - payload.ShopId, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.PlatformUserId, - payload.platformUserId, - shopName, - ]), - shopName, - } -} - -function resolveProviderStatus(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - const statusText = pickFirstNonEmpty([ - payload.Status, - payload.status, - payload.order_status_text, - payload.orderStatusText, - ]).toUpperCase() - - if (!statusText) { - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' - } - - if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) { - return 'paid' - } - - if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) { - return 'closed' - } - - if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) { - return 'refunded' - } - - if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) { - return 'unpaid' - } - - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' -} - -function isTradeFinishedPayload(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload?.order_status, payload?.orderStatus, payload?.status]), - ) - - if (rawStatus === 4) { - return true - } - - const statusText = pickFirstNonEmpty([ - payload?.Status, - payload?.status, - payload?.order_status_text, - payload?.orderStatusText, - ]).toUpperCase() - - return statusText === 'TRADE_FINISHED' -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - const isoText = normalized.replace(' ', 'T') - const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/) - - if (utcMatch) { - const [, year, month, day, hours, minutes, seconds] = utcMatch - return new Date( - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hours) - 8, - Number(minutes), - Number(seconds), - ), - ).toISOString() - } - - const date = new Date(normalized) - return Number.isNaN(date.getTime()) ? null : date.toISOString() -} - -function resolveSkuCode(rawKey) { - const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] - - for (const candidate of candidates) { - if (typeof candidate === 'string' && candidate.trim()) { - return candidate.trim() - } - - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return String(candidate) - } - } - - return '' -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function normalizeRecord(value) { - return isPlainObject(value) ? value : {} -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} - -function safeParseJson(rawValue) { - if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) { - return rawValue - } - - const normalized = String(rawValue || '').trim() - - if (!normalized) { - return {} - } - - try { - const parsed = JSON.parse(normalized) - return isPlainObject(parsed) ? parsed : {} - } catch { - return {} - } -} diff --git a/apps/backend/src/services/platforms/agiso/README.md b/apps/backend/src/services/platforms/agiso/README.md deleted file mode 100644 index 8ad79c0a..00000000 --- a/apps/backend/src/services/platforms/agiso/README.md +++ /dev/null @@ -1,9 +0,0 @@ -## Agiso Platform Services - -Keep Agiso-wide shared helpers in this directory. - -Per-platform integrations live in subdirectories: - -- `xianyu/` -- `pdd/` -- `taobao/` diff --git a/apps/backend/src/services/platforms/agiso/pdd/README.md b/apps/backend/src/services/platforms/agiso/pdd/README.md deleted file mode 100644 index fc40152b..00000000 --- a/apps/backend/src/services/platforms/agiso/pdd/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## PDD - -Put all Agiso PDD-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/shop-config-service.ts b/apps/backend/src/services/platforms/agiso/shop-config-service.ts deleted file mode 100644 index decce936..00000000 --- a/apps/backend/src/services/platforms/agiso/shop-config-service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js' - -const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json') -const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate'] -const AGISO_SHOP_CONFIG_KEYS = [ - 'shopName', - 'accessToken', - 'messageTemplate', - 'autoDeliveryMessageTemplate', - 'appSecret', - 'apiVersion', - 'sendMessageEndpoint', - 'tradeDetailEndpoint', - 'tradeDetailApiVersion', - 'tradeDetailTimeoutMs', -] - -type JsonObject = Record - -export type AgisoMessagingDefaults = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AgisoShopConfig = { - enabled?: boolean - shopName?: string - accessToken?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string -} - -export type AgisoMessagingConfigDocument = { - defaults: AgisoMessagingDefaults - shops: Record -} - -export function getAgisoShopsFilePath(): string { - return AGISO_SHOPS_FILE_PATH -} - -export function getAgisoShopConfigMap(): Record { - const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {}) - const fileConfig = loadAgisoMessagingConfigDocumentFromFile() - - return { - ...envConfig, - ...fileConfig.shops, - } -} - -export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId) { - return null - } - - return getAgisoShopConfigMap()[normalizedShopId] || null -} - -export function getAgisoMessagingDefaults(): AgisoMessagingDefaults { - return loadAgisoMessagingConfigDocumentFromFile().defaults -} - -export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument { - const normalized = normalizeAgisoMessagingConfigDocument(rawValue) - fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true }) - fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') - return normalized -} - -function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument { - if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) { - return { - defaults: {}, - shops: {}, - } - } - - try { - const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8') - const parsed = JSON.parse(rawText) - return normalizeAgisoMessagingConfigDocument(parsed) - } catch { - return { - defaults: {}, - shops: {}, - } - } -} - -function normalizeAgisoShopConfigMap(rawValue: unknown): Record { - const output: Record = {} - - const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : [] - for (const [shopId, config] of entries) { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId || !isPlainObject(config)) { - continue - } - - const next: AgisoShopConfig = {} - const enabled = normalizeBooleanLike(config.enabled) - if (enabled !== null) { - next.enabled = enabled - } - - for (const key of AGISO_SHOP_CONFIG_KEYS) { - const value = String(config[key] || '').trim() - if (value) { - next[key] = value - } - } - - output[normalizedShopId] = next - } - - return output -} - -function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults { - const output: AgisoMessagingDefaults = {} - - if (!isPlainObject(rawValue)) { - return output - } - - for (const key of AGISO_MESSAGING_DEFAULT_KEYS) { - const value = String(rawValue[key] || '').trim() - if (value) { - output[key] = value - } - } - - return output -} - -function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument { - const normalizedValue = isPlainObject(rawValue) ? rawValue : {} - const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults') - || Object.prototype.hasOwnProperty.call(normalizedValue, 'shops') - - return { - defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}), - shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue), - } -} - -function normalizeBooleanLike(value: unknown): boolean | null { - if (typeof value === 'boolean') { - return value - } - - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) { - return null - } - - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return null -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/taobao/README.md b/apps/backend/src/services/platforms/agiso/taobao/README.md deleted file mode 100644 index c685793c..00000000 --- a/apps/backend/src/services/platforms/agiso/taobao/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Taobao - -Put all Agiso Taobao-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/README.md b/apps/backend/src/services/platforms/agiso/xianyu/README.md deleted file mode 100644 index b0a28579..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Xianyu - -Put all Agiso Xianyu-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts deleted file mode 100644 index 5284b13f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - hasAgisoAutoDeliverySucceeded, - ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps, - isOrderReadyForAgisoAutoDelivery, - isAgisoAutoDeliverySuccess, - resolveAgisoAutoDeliveryEndpoint, - resolveAgisoAutoDeliveryErrorMessage, -} from './auto-delivery-service.js' - -const agisoOrder = { - id: 101, - provider: 'agiso', - platform: 'xianyu', - shop_id: 'shop-auto-delivery-test', - shop_name: '自动发货测试店', - platform_order_id: 'P-AUTO-10001', -} - -function createDeliveredTask(patch = {}) { - return { - id: 201, - order_id: agisoOrder.id, - task_status: 'completed', - delivery_status: 'delivered', - context_json: '{}', - ...patch, - } -} - -test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => { - assert.equal(isOrderReadyForAgisoAutoDelivery([]), false) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'delivered' }, - ]), true) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'processing' }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: {} }, - { context_json: { agisoAutoDelivery: { status: 'success' } } }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: { agisoAutoDelivery: { status: 'failed' } } }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) }, - ]), false) -}) - -test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => { - assert.equal(isAgisoAutoDeliverySuccess(200, {}), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true) - assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false) -}) - -test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => { - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - { Error_Msg: '库存不足' }, - 'raw body', - 400, - ), - '库存不足', - ) - - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - {}, - 'fallback raw body', - 400, - ), - 'fallback raw body', - ) -}) - -test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => { - assert.equal( - resolveAgisoAutoDeliveryEndpoint(''), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) - - assert.equal( - resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => { - const calls = { - update: 0, - fetch: 0, - } - - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task: createDeliveredTask(), - }, { - listTasksByOrderId: async () => [ - createDeliveredTask(), - createDeliveredTask({ id: 202, delivery_status: 'processing' }), - ], - updateTask: async () => { - calls.update += 1 - return null - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'waiting_other_tasks') - assert.equal(calls.update, 0) - assert.equal(calls.fetch, 0) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = {} - - try { - const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) }) - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: { - ...agisoOrder, - shop_id: 'missing-auto-delivery-config-shop', - }, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 1 } - }, - fetch: async () => { - throw new Error('fetch should not be called without config') - }, - nowIso: () => '2026-05-21T12:00:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'missing_config') - assert.equal(updates.length, 1) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.existing, true) - assert.equal(context.agisoAutoDelivery.status, 'skipped') - assert.equal(context.agisoAutoDelivery.reason, 'missing_config') - assert.deepEqual({ - hasEndpoint: context.agisoAutoDelivery.hasEndpoint, - hasAccessToken: context.agisoAutoDelivery.hasAccessToken, - hasAppSecret: context.agisoAutoDelivery.hasAppSecret, - }, { - hasEndpoint: true, - hasAccessToken: false, - hasAppSecret: false, - }) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - const messages = [] - - runtimeConfig.platforms.agiso.appSecret = 'runtime-secret' - runtimeConfig.platforms.agiso.messaging.shops = { - [agisoOrder.shop_id]: { - accessToken: 'access-token', - appSecret: 'shop-secret', - apiVersion: '1', - }, - } - - try { - const task = createDeliveredTask() - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - fetch: async () => ({ - status: 200, - text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }), - }), - confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({ - shipped: false, - orderStatus: 2, - shipTime: 0, - reason: `not shipped: ${input.requestId}`, - }), - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 2 } - }, - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => { - messages.push(payload) - return { sent: true } - }, - nowIso: () => '2026-05-21T12:01:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, false) - assert.equal(result.reason, 'delivery_not_confirmed') - assert.equal(messages.length, 0) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.agisoAutoDelivery.status, 'failed') - assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed') - assert.equal(context.agisoAutoDelivery.requestId, 'req-1') - assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2) - assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts deleted file mode 100644 index a8aa9537..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts +++ /dev/null @@ -1,640 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js' -import { createTaskEvent } from '../../../../repositories/task-event-repo.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { queryAgisoXianyuOrderDetail } from './order-detail-service.js' -import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js' -import { parseJsonObject } from '../../../../utils/json.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import type { TaskRow } from '../../../../types/repository-rows.js' - -const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend' - -type JsonObject = Record - -type AutoDeliveryOrder = { - id?: number | string - provider?: string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type SupportedAgisoOrder = AutoDeliveryOrder & { - id: number | string - provider: 'agiso' - platform: 'xianyu' - platform_order_id: string -} - -type AutoDeliveryTask = Partial & { - id?: number | string - delivery_status?: string - task_status?: string - context_json?: string | JsonObject | null - [key: string]: unknown -} - -type AutoDeliveryTaskWithId = AutoDeliveryTask & { - id: number | string -} - -type EnsureAgisoXianyuAutoDeliveryInput = { - order?: AutoDeliveryOrder | null - task?: AutoDeliveryTask | null - trigger?: string -} - -type AutoDeliveryStatus = 'success' | 'failed' | 'skipped' - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type AgisoAutoDeliveryConfirmInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type AgisoAutoDeliveryConfirmResult = { - shipped: boolean - orderStatus: number - shipTime: number - reason: string -} - -type AutoDeliveryMessageResult = { - sent: boolean - skipped?: boolean - reason?: string - [key: string]: unknown -} - -type EnsureAgisoXianyuAutoDeliveryDeps = { - listTasksByOrderId?: (orderId: number | string) => Promise - fetch?: FetchLike - confirmAgisoXianyuAutoDeliveryShipped?: ( - input: AgisoAutoDeliveryConfirmInput, - ) => Promise - updateTask?: ( - taskId: number | string, - patch: { context_json: string; updated_at: string }, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - nowIso?: () => string - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: ( - input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null }, - ) => Promise -} - -type PersistAgisoAutoDeliveryResultInput = { - status: AutoDeliveryStatus - trigger?: string - reason?: string - order?: AutoDeliveryOrder | null - responseStatus?: number - response?: unknown - errorMessage?: string - detail?: JsonObject -} - -type AgisoAutoDeliveryConfig = { - enabled: boolean - endpoint: string - apiVersion: string - appSecret: string - accessToken: string -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}) { - return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger }) -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId - const sendRequest = deps.fetch || (fetch as FetchLike) - const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped - - if (!isAgisoXianyuOrder(order) || !task?.id) { - return { sent: false, skipped: true, reason: 'not_supported', task } - } - - const currentTask = task as AutoDeliveryTaskWithId - if (String(currentTask.delivery_status || '').trim() !== 'delivered') { - return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask } - } - - const orderTasks = await listOrderTasks(order.id) - if (hasAgisoAutoDeliverySucceeded(orderTasks)) { - return { sent: false, skipped: true, reason: 'already_sent', task: currentTask } - } - - if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) { - logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', { - trigger, - orderId: order.id, - taskId: currentTask.id, - platformOrderId: order.platform_order_id, - taskStatuses: orderTasks.map((current) => ({ - taskId: current.id, - taskStatus: current.task_status, - deliveryStatus: current.delivery_status, - })), - }) - - return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask } - } - - const config = resolveAgisoXianyuAutoDeliveryConfig(order) - if (!config.enabled) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'auto_delivery_disabled', - order, - }, deps) - } - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'missing_config', - order, - detail: { - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, - }, deps) - } - - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - const requestBody = buildRequestBody({ - platformOrderId: String(order.platform_order_id || '').trim(), - appSecret: config.appSecret, - }) - - logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - endpoint: config.endpoint, - }) - - try { - const response = await sendRequest(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = parseJsonObject(rawText) as JsonObject - const success = isAgisoAutoDeliverySuccess(response.status, parsed) - - if (success) { - const requestId = String(parsed?.RequestId || '').trim() - - // 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。 - const confirmResult = await confirmShipped({ - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - requestId, - }) - - if (!confirmResult.shipped) { - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - confirmReason: confirmResult.reason, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'delivery_not_confirmed', - order, - responseStatus: response.status, - response: parsed, - errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - } - - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }) - - const result = await persistAgisoAutoDeliveryResult(currentTask, { - status: 'success', - trigger, - reason: '', - order, - responseStatus: response.status, - response: parsed, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - - // 发货确认成功后,发送自定义消息通知。 - await sendAutoDeliveryMessage({ order, task: currentTask }, deps) - - return result - } - - const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status) - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - errorMessage, - response: parsed, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_failed', - order, - responseStatus: response.status, - response: parsed, - errorMessage, - }, deps) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败') - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - errorMessage, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_error', - order, - responseStatus: 0, - response: {}, - errorMessage, - }, deps) - } -} - -export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean { - return (Array.isArray(tasks) ? tasks : []).some((task) => { - const context = parseTaskContext(task) - const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {} - return String(autoDelivery.status || '').trim() === 'success' - }) -} - -export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean { - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - return normalizedTasks.length > 0 - && normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered') -} - -function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig { - const baseConfig = runtimeConfig.platforms.agiso.autoDelivery - const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {} - - return { - enabled: normalizeBooleanLike(baseConfig.enabled, true), - endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint), - apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1', - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - accessToken: String(shopConfig.accessToken || '').trim(), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: String(apiVersion || '1').trim() || '1', - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp: String(Math.floor(Date.now() / 1000)), - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = String(appSecret || '').trim() - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += String(appSecret || '').trim() - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -export function resolveAgisoAutoDeliveryErrorMessage( - payload: unknown, - rawText: string, - statusCode: number, -): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}` -} - -export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string { - return AGISO_DUMMY_SEND_ENDPOINT -} - -async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, { - status, - trigger, - reason, - order, - responseStatus = 0, - response = {}, - errorMessage = '', - detail = {}, -}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const getNowIso = deps.nowIso || nowIso - const patchTask = deps.updateTask || updateTask - const insertTaskEvent = deps.createTaskEvent || createTaskEvent - const now = getNowIso() - const currentContext = parseTaskContext(task) - const nextContext = { - ...currentContext, - agisoAutoDelivery: { - status, - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - response: isPlainObject(response) ? response : {}, - updatedAt: now, - ...detail, - }, - } - const updatedTask = await patchTask(task.id, { - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - if (status === 'success' || status === 'failed' || status === 'skipped') { - await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, { - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - ...detail, - }, now) - } - - return { - sent: status === 'success', - skipped: status === 'skipped', - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - response, - task: updatedTask || task, - } -} - -function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject { - const value = task?.context_json - - if (!value) { - return {} - } - - if (isPlainObject(value)) { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder { - return String(order?.provider || '').trim() === 'agiso' - && String(order?.platform || '').trim() === 'xianyu' - && Boolean(String(order?.platform_order_id || '').trim()) - && Number(order?.id || 0) > 0 -} - -function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'undefined' || value === null || value === '') { - return fallbackValue - } - - const normalized = String(value || '').trim().toLowerCase() - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return fallbackValue -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} - -/** - * 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。 - * 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过 - */ -async function confirmAgisoXianyuAutoDeliveryShipped({ - shopId = '', - platformOrderId = '', - requestId = '', -}: AgisoAutoDeliveryConfirmInput = {}): Promise { - try { - const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId }) - - if (detailResult.success && detailResult.shipped) { - return { - shipped: true, - orderStatus: detailResult.orderStatus, - shipTime: detailResult.shipTime, - reason: '', - } - } - - return { - shipped: false, - orderStatus: detailResult.orderStatus || 0, - shipTime: detailResult.shipTime || 0, - reason: detailResult.success ? 'order_not_shipped' : detailResult.reason, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', { - requestId, - shopId, - platformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return { - shipped: false, - orderStatus: 0, - shipTime: 0, - reason: 'confirm_query_failed', - } - } -} - -/** - * 自动发货成功后发送消息通知。 - */ -async function sendAutoDeliveryMessage( - { order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {}, - deps: EnsureAgisoXianyuAutoDeliveryDeps = {}, -): Promise { - try { - const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - || ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - const result = await deliverMessage({ order, task }) - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - sent: result.sent, - skipped: result.skipped, - reason: result.reason, - }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - errorMessage: message, - }, { level: 'warn' }) - - return { sent: false, skipped: true, reason: 'message_send_error' } - } -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts deleted file mode 100644 index 7d6395e1..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - deliverAgisoXianyuMessageForTaskWithDeps, - normalizeAgisoMessageTemplate, - renderAgisoAutoDeliveryMessage, -} from './message-service.js' - -test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => { - const originalMessaging = runtimeConfig.platforms.agiso.messaging - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - - runtimeConfig.platforms.agiso.messaging = { - ...originalMessaging, - enabled: true, - sendMessageEndpoint: 'https://example.com/send', - apiVersion: '1', - accessToken: 'access-token', - } - runtimeConfig.platforms.agiso.appSecret = 'app-secret' - - const calls = { - find: [], - create: 0, - fetch: 0, - } - - try { - const result = await deliverAgisoXianyuMessageForTaskWithDeps({ - order: { - id: 15, - platform: 'xianyu', - shop_id: '2209880145223', - platform_order_id: '4502280133178028841', - }, - task: { - id: 36, - task_no: 'DT7af76e9b2438', - }, - channel: 'agiso_im', - messageContent: '测试消息', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, { - findLatestSuccessfulMessageDelivery: async (input) => { - calls.find.push(input) - return { id: 16, task_id: null } - }, - createMessageDelivery: async () => { - calls.create += 1 - return { id: 999 } - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.deepEqual(calls.find, [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: '2209880145223', - platformOrderId: '4502280133178028841', - channel: 'agiso_im', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, - ]) - assert.equal(calls.create, 0) - assert.equal(calls.fetch, 0) - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'already_sent') - assert.equal(result.deliveryId, 16) - } finally { - runtimeConfig.platforms.agiso.messaging = originalMessaging - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => { - assert.equal( - normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'), - '第一行\n第二行\n第三行', - ) -}) - -test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => { - const message = renderAgisoAutoDeliveryMessage({ - order: { - platform_order_id: '4502280133178028841', - shop_id: '2209880145223', - shop_name: '大锤号商', - }, - task: { - task_no: 'DT7af76e9b2438', - }, - template: '订单 {platformOrderId}\\n结果:{resultMessage}', - }) - - assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts deleted file mode 100644 index 1a7aebe0..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts +++ /dev/null @@ -1,539 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js' -import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js' -import { - createMessageDelivery, - findLatestSuccessfulMessageDelivery, - updateMessageDelivery, -} from '../../../../repositories/message-delivery-repo.js' -import { nowIso } from '../../../../utils/time.js' - -const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im' -const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery' - -type JsonObject = Record - -type MessageOrder = { - id?: number | string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type MessageTask = { - id?: number | string - task_no?: string - [key: string]: unknown -} - -type AgisoMessagingConfig = { - enabled?: boolean - sendMessageEndpoint?: string - accessToken?: string - appSecret?: string - apiVersion?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - shopName?: string - [key: string]: unknown -} - -type EnsureClaimMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null - claimUrl?: string - expiredAt?: string -} - -type EnsureAutoDeliveryMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null -} - -type DeliverMessageInput = EnsureAutoDeliveryMessageInput & { - channel?: string - messageContent?: string - claimUrl?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type MessageDeliveryLike = { - id: number | string - [key: string]: unknown -} - -type MessageDeliveryCreateInput = { - provider: string - platform: string - shopId: string - shopName: string - channel: string - orderId?: number | string | null - taskId?: number | string | null - platformOrderId?: string - recipientKey?: string - messageContent: string - claimUrl: string - status: string - requestUrl: string - requestHeadersJson: string - requestBodyJson: string - responseStatus: number - responseJson: string - errorMessage: string - sentAt: string | null - createdAt: string - updatedAt: string -} - -type MessageDeliveryPatch = { - status?: string - response_status?: number - response_json?: string - error_message?: string - sent_at?: string | null - updated_at?: string -} - -type FindSuccessfulDeliveryInput = { - provider: string - platform: string - shopId: string - platformOrderId: string - channel: string - claimUrl: string -} - -type DeliverMessageDeps = { - nowIso?: () => string - findLatestSuccessfulMessageDelivery?: ( - input: FindSuccessfulDeliveryInput, - ) => Promise - createMessageDelivery?: ( - input: MessageDeliveryCreateInput, - ) => Promise - updateMessageDelivery?: ( - deliveryId: number | string, - patch: MessageDeliveryPatch, - ) => Promise - fetch?: FetchLike -} - -type RenderClaimMessageInput = EnsureClaimMessageInput & { - template?: string - shopName?: string -} - -type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & { - template?: string - shopName?: string -} - -type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & { - template?: string - claimUrl?: string - expiredAt?: string - resultMessage?: string -} - -export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl, - expiredAt, -}: EnsureClaimMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template: config.messageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_MESSAGE_CHANNEL, - messageContent, - claimUrl, - }) -} - -export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({ - order, - task, -}: EnsureAutoDeliveryMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoAutoDeliveryMessage({ - order, - task, - template: config.autoDeliveryMessageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL, - messageContent, - }) -} - -async function deliverAgisoXianyuMessageForTask({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}) { - return deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl, - }) -} - -export async function deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) { - const now = deps.nowIso || nowIso - const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery - const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery - const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery - const sendRequest = deps.fetch || (fetch as FetchLike) - - if (!order || !task || !messageContent) { - return { sent: false, skipped: true, reason: 'missing_message_context' } - } - - const config = resolveAgisoXianyuMessagingConfig(order) - if (!config.enabled) { - return { sent: false, skipped: true, reason: 'messaging_disabled' } - } - - const endpoint = String(config.sendMessageEndpoint || '').trim() - const accessToken = String(config.accessToken || '').trim() - const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim() - if (!endpoint || !accessToken || !appSecret) { - return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' } - } - - const successful = await findSuccessfulDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - platformOrderId: String(order.platform_order_id || '').trim(), - channel, - claimUrl: String(claimUrl || ''), - }) - if (successful) { - return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id } - } - - const url = buildRequestUrl(endpoint) - const requestBody = buildRequestBody({ - tid: String(order.platform_order_id || ''), - msg: messageContent, - appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken, - apiVersion: String(config.apiVersion || '1').trim() || '1', - }) - const createdAt = now() - const delivery = await insertMessageDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - shopName: String(order.shop_name || '').trim(), - channel, - orderId: order.id, - taskId: task.id, - platformOrderId: order.platform_order_id, - recipientKey: order.platform_order_id, - messageContent, - claimUrl, - status: 'pending', - requestUrl: url, - requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)), - requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)), - responseStatus: 0, - responseJson: '{}', - errorMessage: '', - sentAt: null, - createdAt, - updatedAt: createdAt, - }) - if (!delivery) { - return { sent: false, skipped: false, reason: 'delivery_create_failed' } - } - - try { - const response = await sendRequest(url, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = safeParseJson(rawText) - const success = isAgisoSendSuccess(response.status, parsed) - const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status) - const updated = await patchMessageDelivery(delivery.id, { - status: success ? 'success' : 'failed', - response_status: response.status, - response_json: JSON.stringify(parsed ?? { rawText }), - error_message: errorMessage, - sent_at: success ? now() : null, - updated_at: now(), - }) - - return { - sent: success, - skipped: false, - deliveryId: updated?.id || delivery.id, - responseStatus: response.status, - response: parsed ?? { rawText }, - errorMessage, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送消息失败') - await patchMessageDelivery(delivery.id, { - status: 'failed', - response_status: 0, - response_json: '{}', - error_message: message, - sent_at: null, - updated_at: now(), - }) - - return { - sent: false, - skipped: false, - deliveryId: delivery.id, - errorMessage: message, - } - } -} - -function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig { - const baseConfig = runtimeConfig.platforms.agiso.messaging - const fileDefaults = getAgisoMessagingDefaults() - const shopId = String(order?.shop_id || '').trim() - const shopConfigs = getAgisoShopConfigMap() - const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {} - - return { - ...baseConfig, - ...fileDefaults, - ...shopConfig, - } -} - -function buildRequestUrl(endpoint: string): string { - return new URL(endpoint).toString() -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - tid, - msg, - appSecret, -}: { tid: string; msg: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid, - msg, - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template, - shopName = '', -}: RenderClaimMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - claimUrl, - expiredAt: String(expiredAt || '尽快'), - }) -} - -export function renderAgisoAutoDeliveryMessage({ - order, - task, - template, - shopName = '', -}: RenderAutoDeliveryMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已完成自动发货,请注意查收。' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - resultMessage: '自动发货成功', - }) -} - -function renderAgisoMessageTemplate({ - order, - task, - template, - shopName = '', - claimUrl = '', - expiredAt = '', - resultMessage = '', -}: RenderMessageTemplateInput = {}): string { - const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim() - - return normalizeAgisoMessageTemplate(template) - .replaceAll('{platformOrderId}', String(order?.platform_order_id || '')) - .replaceAll('{taskNo}', String(task?.task_no || '')) - .replaceAll('{shopName}', resolvedShopName) - .replaceAll('{shopId}', String(order?.shop_id || '')) - .replaceAll('{claimUrl}', String(claimUrl || '')) - .replaceAll('{expiredAt}', String(expiredAt || '')) - .replaceAll('{resultMessage}', String(resultMessage || '')) -} - -function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object') { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}` -} - -function safeParseJson(rawText: string): JsonObject | null { - const normalized = String(rawText || '').trim() - - if (!normalized) { - return null - } - - try { - return JSON.parse(normalized) - } catch { - return null - } -} - -function maskHeadersForStorage(headers: Record): Record { - const output = { ...headers } - - if (output.Authorization) { - output.Authorization = '[masked]' - } - - return output -} - -function maskBodyForStorage(body: Record): Record { - const output = { ...body } - - if (output.sign) { - output.sign = '[masked]' - } - - return output -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts deleted file mode 100644 index 6277808f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - enrichAgisoXianyuTradeOrderWithDeps, - queryAgisoXianyuOrderDetailWithDeps, - resolveAgisoXianyuOrderDeliveryState, -} from './order-detail-service.js' - -test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => { - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), { - shipped: true, - shipTime: 1713097840, - orderStatus: 0, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), { - shipped: true, - shipTime: 0, - orderStatus: 3, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), { - shipped: false, - shipTime: 0, - orderStatus: 2, - }) -}) - -test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const calls = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - tradeDetailApiVersion: '2', - tradeDetailTimeoutMs: '3000', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10001', - requestId: 'req-detail', - }, { - fetchWithTimeout: async (url, options, timeoutMs) => { - calls.push({ url, options, timeoutMs }) - return { - status: 200, - text: async () => JSON.stringify({ - IsSuccess: true, - Data: { - total_fee: '25.50', - pay_time: '2026-04-14 20:30:40', - ship_time: 1713097840, - order_status: 3, - item: { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - }, - }), - } - }, - }) - - assert.equal(result.success, true) - assert.equal(result.responseStatus, 200) - assert.equal(result.totalAmountFen, 2550) - assert.equal(result.shipped, true) - assert.equal(result.shipTime, 1713097840) - assert.equal(result.orderStatus, 3) - assert.equal(calls[0]?.url, 'https://example.com/detail') - assert.equal(calls[0]?.timeoutMs, 3000) - assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token') - assert.equal(calls[0]?.options.headers.ApiVersion, '2') - assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/) - assert.match(String(calls[0]?.options.body), /sign=/) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10002', - }, { - fetchWithTimeout: async () => ({ - status: 200, - text: async () => JSON.stringify({ - IsSuccess: false, - Error_Msg: '订单不存在', - }), - }), - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'business_error') - assert.equal(result.errorMessage, '订单不存在') - assert.equal(result.responseStatus, 200) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - let fetchCalled = false - - runtimeConfig.platforms.agiso.messaging.shops = {} - runtimeConfig.platforms.agiso.appSecret = '' - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'missing-shop', - platformOrderId: 'P-DETAIL-10003', - }, { - fetchWithTimeout: async () => { - fetchCalled = true - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'missing_config') - assert.equal(fetchCalled, false) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => { - const parsed = { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-detail-test', - shopName: '', - platformOrderId: 'P-DETAIL-10004', - totalAmount: 0, - paidAt: null, - buyerId: '', - buyerName: '', - receiverContact: '', - rawPayload: {}, - items: [], - } - - const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, { - queryAgisoXianyuOrderDetail: async (input) => { - assert.deepEqual(input, { - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10004', - requestId: 'req-enrich', - }) - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: 200, - payload: {}, - detailPayload: { - total_fee: '19.90', - pay_time: '2026-04-14 20:30:40', - buyer_id: 'buyer-1', - buyer_name: '测试买家', - receiver_mobile: '13800138000', - shop_name: '详情店铺', - items: [ - { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - ], - }, - totalAmountFen: 1990, - shipped: false, - shipTime: 0, - orderStatus: 2, - } - }, - }) - - assert.equal(result.enriched, true) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.parsed.totalAmount, 1990) - assert.equal(result.parsed.buyerId, 'buyer-1') - assert.equal(result.parsed.buyerName, '测试买家') - assert.equal(result.parsed.receiverContact, '13800138000') - assert.equal(result.parsed.shopName, '详情店铺') - assert.equal(result.parsed.items.length, 1) - assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(result.parsed.items[0]?.quantity, 2) - assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts deleted file mode 100644 index 3905e842..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts +++ /dev/null @@ -1,803 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import { parseAmountToFen } from '../../../../utils/money.js' -import { parseJsonObject } from '../../../../utils/json.js' - -const DEFAULT_DETAIL_TIMEOUT_MS = 5000 - -type JsonObject = Record - -type AgisoParsedTrade = { - provider?: string - platform?: string - shopId?: string - shopName?: string - platformOrderId?: string - totalAmount?: number - paidAt?: string | null - buyerId?: string - buyerName?: string - receiverContact?: string - rawPayload?: JsonObject - items?: AgisoOrderItem[] - [key: string]: unknown -} - -type AgisoOrderItem = { - skuCode: string - skuName: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - quantity: number - spec: JsonObject - snapshot: { - externalItemId: string - externalSkuCode: string - externalSkuName: string - } -} - -type AgisoDetailQueryInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchWithTimeout = ( - url: string, - options: RequestInit, - timeoutMs: number, -) => Promise - -type AgisoDetailDeps = { - fetchWithTimeout?: FetchWithTimeout - queryAgisoXianyuOrderDetail?: ( - input: Required, - ) => Promise -} - -type AgisoTradeDetailConfig = { - endpoint: string - apiVersion: string - accessToken: string - appSecret: string - timeoutMs: number -} - -type AgisoShopConfig = { - accessToken?: string - appSecret?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string | number -} - -type DeliveryState = { - shipped: boolean - shipTime: number - orderStatus: number -} - -type AgisoDetailResult = { - success: boolean - reason: string - errorMessage: string - responseStatus: number - payload: JsonObject - detailPayload: JsonObject - totalAmountFen: number - shipped: boolean - shipTime: number - orderStatus: number -} - -type ExternalSkuDescriptor = { - raw: string - externalSkuCode: string - externalSkuName: string -} - -export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) { - return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId }) -} - -export async function enrichAgisoXianyuTradeOrderWithDeps( - parsed: AgisoParsedTrade, - { requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -) { - const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail - - if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) { - return { parsed, enriched: false, reason: 'not_needed' } - } - - const detailResult = await queryOrderDetail({ - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - requestId, - }) - - if (!detailResult.success) { - return { - parsed, - enriched: false, - reason: detailResult.reason, - errorMessage: detailResult.errorMessage, - } - } - - const detailPayload = detailResult.detailPayload - if (detailResult.totalAmountFen <= 0) { - return { - parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }), - enriched: false, - reason: 'amount_still_missing', - } - } - - const merged = mergeEnrichedTrade(parsed, detailPayload) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', { - requestId, - platform: parsed.platform, - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - totalAmountFen: merged.totalAmount, - paidAt: merged.paidAt, - }) - - return { - parsed: merged, - enriched: true, - totalAmountFen: merged.totalAmount, - } -} - -export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) { - return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId }) -} - -export async function queryAgisoXianyuOrderDetailWithDeps( - { shopId = '', platformOrderId = '', requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -): Promise { - const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout - const normalizedShopId = String(shopId || '').trim() - const normalizedPlatformOrderId = String(platformOrderId || '').trim() - const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId) - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, { level: 'warn' }) - - return createFailedDetailResult('missing_config') - } - - const requestBody = buildRequestBody({ - platformOrderId: normalizedPlatformOrderId, - appSecret: config.appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - - logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - endpoint: config.endpoint, - }) - - try { - const response = await requestWithTimeout(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }, config.timeoutMs) - const rawText = await response.text() - const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject - const detailPayload = extractAgisoDetailPayload(payload) - const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload) - const totalAmountFen = resolveTotalAmountFen(detailPayload) - - if (response.status < 200 || response.status >= 300) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('http_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isAgisoDetailSuccess(payload)) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - response: payload, - }, { level: 'warn' }) - - return createFailedDetailResult('business_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - }, { level: 'warn' }) - - return createFailedDetailResult('empty_detail_payload', { - responseStatus: response.status, - payload, - }) - } - - if (totalAmountFen <= 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - detailKeys: Object.keys(detailPayload), - }, { level: 'warn' }) - } - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: response.status, - payload: isPlainObject(payload) ? payload : {}, - detailPayload, - totalAmountFen, - shipped: delivery.shipped, - shipTime: delivery.shipTime, - orderStatus: delivery.orderStatus, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败') - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('request_failed', { - errorMessage: message, - }) - } -} - -function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean { - return parsed?.provider === 'agiso' - && parsed?.platform === 'xianyu' - && String(parsed?.platformOrderId || '').trim() - && Number(parsed?.totalAmount || 0) <= 0 -} - -function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig { - const baseConfig = runtimeConfig.platforms.agiso.tradeDetail - const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig - - return { - endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(), - apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1', - accessToken: String(shopConfig.accessToken || '').trim(), - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function extractAgisoDetailPayload(payload) { - if (!isPlainObject(payload)) { - return {} - } - - for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) { - if (isPlainObject(payload[key])) { - return payload[key] - } - } - - return payload -} - -export function resolveAgisoXianyuOrderDeliveryState(payload) { - const shipTime = normalizeTimestampValue(pickFirstNonEmpty([ - payload?.ship_time, - payload?.shipTime, - payload?.ShipTime, - payload?.delivery_time, - payload?.deliveryTime, - ])) - const orderStatus = normalizeInteger( - pickFirstNonEmpty([ - payload?.order_status, - payload?.orderStatus, - payload?.status, - ]), - ) - - return { - shipped: shipTime > 0 || orderStatus >= 3, - shipTime, - orderStatus, - } -} - -function resolveTotalAmountFen(payload) { - const fenAmount = normalizeFenInteger(pickFirstNonEmpty([ - payload.payment, - payload.Payment, - payload.post_fee, - payload.postFee, - payload.item?.price, - ])) - - if (fenAmount > 0) { - return fenAmount - } - - return parseAmountToFen(pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.PayFee, - payload.actual_fee, - payload.actualFee, - payload.ActualFee, - payload.total_amount, - payload.totalAmount, - payload.Amount, - payload.amount, - ])) -} - -function resolvePaidAt(payload, fallbackValue) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - fallbackValue, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) { - const mergedPayload = { - ...parsed.rawPayload, - _agisoTradeDetail: detailPayload, - } - const totalAmountFen = resolveTotalAmountFen(detailPayload) - const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items)) - - return { - ...parsed, - buyerId: pickFirstNonEmpty([ - parsed.buyerId, - detailPayload.encryption_buyer_id, - detailPayload.buyer_id, - detailPayload.buyerId, - detailPayload.BuyerId, - detailPayload.BuyerOpenUid, - detailPayload.buyer_open_uid, - ]), - buyerName: pickFirstNonEmpty([ - parsed.buyerName, - detailPayload.buyer_name, - detailPayload.buyerName, - detailPayload.BuyerName, - detailPayload.buyer_nick, - detailPayload.nick, - detailPayload.BuyerNick, - ]), - receiverContact: pickFirstNonEmpty([ - parsed.receiverContact, - detailPayload.receiver_contact, - detailPayload.receiverContact, - detailPayload.receiver_mobile, - detailPayload.receiverMobile, - detailPayload.mobile, - detailPayload.phone, - ]), - shopName: pickFirstNonEmpty([ - parsed.shopName, - detailPayload.shop_name, - detailPayload.shopName, - detailPayload.ShopName, - detailPayload.seller_name, - detailPayload.sellerName, - detailPayload.seller_nick, - detailPayload.sellerNick, - detailPayload.SellerNick, - ]), - totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount), - paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt), - rawPayload: mergedPayload, - items: mergedItems, - } -} - -function normalizeOrderItems(payload, fallbackItems = []) { - const candidates = [ - payload.item ? [payload.item] : null, - payload.items, - payload.Items, - payload.orders, - payload.Orders, - payload.order_list, - payload.OrderList, - ] - const items = candidates.find((item) => Array.isArray(item) && item.length > 0) - - if (!Array.isArray(items) || items.length === 0) { - return Array.isArray(fallbackItems) ? fallbackItems : [] - } - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - ]), - ) - const skuCode = pickFirstNonEmpty([ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ]) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function isAgisoDetailSuccess(payload) { - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return false - } - - if (payload.IsSuccess === true) { - return true - } - - if (Number(payload.Error_Code) === 0) { - return true - } - - if (Number(payload.code) === 0) { - return true - } - - if (Number(payload.success) === 1 || payload.success === true) { - return true - } - - return false -} - -function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) { - if (payload && typeof payload === 'object') { - for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}` -} - -async function fetchWithTimeout(url, options, timeoutMs) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs) - - try { - return await fetch(url, { - ...options, - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - if (/^\d{10,13}$/.test(normalized)) { - const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000 - - if (Number.isFinite(timestamp)) { - return new Date(timestamp).toISOString() - } - } - - const isoLike = normalized.replace(' ', 'T') - const parsed = Date.parse(isoLike) - - if (Number.isNaN(parsed)) { - return null - } - - return new Date(parsed).toISOString() -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function normalizePositiveInteger(value, fallbackValue) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue -} - -function normalizeFenInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function normalizeTimestampValue(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function createFailedDetailResult(reason, { - responseStatus = 0, - payload = {}, - detailPayload = {}, - errorMessage = '', - totalAmountFen = 0, - delivery = { shipped: false, shipTime: 0, orderStatus: 0 }, -} = {}) { - return { - success: false, - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - payload: isPlainObject(payload) ? payload : {}, - detailPayload: isPlainObject(detailPayload) ? detailPayload : {}, - totalAmountFen: Number(totalAmountFen || 0), - shipped: Boolean(delivery?.shipped), - shipTime: Number(delivery?.shipTime || 0), - orderStatus: Number(delivery?.orderStatus || 0), - } -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/session/ocr.ts b/apps/backend/src/services/session/ocr.ts deleted file mode 100644 index 9c46b480..00000000 --- a/apps/backend/src/services/session/ocr.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -const DEFAULT_TIMEOUT_MS = 60_000 - -type OcrAction = 'recognize' | 'batch' - -type OcrRequestPayload = Record - -type OcrHttpResult = Record & { - code: number - msg?: string -} - -export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 }) -} - -export async function warmupOcrService(): Promise { - await warmupOcrViaHttp() -} - -export async function closeOcrService(): Promise { - // HTTP 模式无持久连接,无需关闭。 -} - -// ── HTTP Call ─────────────────────────────────────────────────────────────── - -async function callOcrViaHttp( - action: OcrAction, - payload: OcrRequestPayload, - { timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {}, -): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务') - } - - const url = `${baseUrl}/ocr/${action}` - - let response - try { - response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(timeoutMs), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - - if (!response.ok) { - throw new Error(`OCR 服务返回错误:HTTP ${response.status}`) - } - - const result = await response.json() - if (!result || typeof result !== 'object' || Array.isArray(result)) { - throw new Error('OCR 服务返回了无效响应') - } - const ocrResult = result as OcrHttpResult - if (ocrResult.code !== 0) { - throw new Error(`OCR 服务返回错误:${ocrResult.msg || '未知错误'}`) - } - - return ocrResult -} - -async function warmupOcrViaHttp(): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查') - } - - let response - try { - response = await fetch(`${baseUrl}/health`, { - signal: AbortSignal.timeout(30_000), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - if (!response.ok) { - throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`) - } -} diff --git a/apps/backend/src/services/session/session-activity.ts b/apps/backend/src/services/session/session-activity.ts deleted file mode 100644 index 5010cf5a..00000000 --- a/apps/backend/src/services/session/session-activity.ts +++ /dev/null @@ -1,308 +0,0 @@ -/// - -import { extractHostState, reloadActivityPageForPresentation } from './session-page.js' -import { shouldRefreshLoggedInPresentation } from './session-state.js' - -type BrowserPageLike = { - evaluate: (callback: any) => Promise - waitForTimeout: (milliseconds: number) => Promise -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -export type ActivityInfo = { - nickname: string - role: { - ready: boolean - roleName: string - roleId: string - area: string - partition: string - platId: string - md5str: string - checkparam: string - } - form: { - cdkeyInputId: string - cdkeyValue: string - verifyInputId: string - verifyValue: string - verifyImgId: string - submitId: string - } - verify: { - visible: boolean - src: string - naturalWidth: number - naturalHeight: number - } - popup: { - visible: boolean - text: string - detail: string - } -} - -type SessionForPresentation = { - page: BrowserPageLike - presentationSyncAttempts: number -} - -type EnsureLoggedInPresentationOptions = { - hostState?: HostState | null - credentialReady?: boolean - activityUrl?: string - extractHostState?: (page: BrowserPageLike) => Promise - reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise - shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean -} - -type EnsureActivityInfoReadyOptions = { - triggerRender?: boolean - timeoutMs?: number - extractActivityInfo?: (page: BrowserPageLike) => Promise - triggerActivityRoleRender?: (page: BrowserPageLike) => Promise - now?: () => number -} - -declare global { - interface Window { - renderCdkey?: () => void - roleData?: Record - isRole?: unknown - } -} - -export function createEmptyActivityInfo(): ActivityInfo { - return { - nickname: '', - role: { - ready: false, - roleName: '', - roleId: '', - area: '', - partition: '', - platId: '', - md5str: '', - checkparam: '', - }, - form: { - cdkeyInputId: '', - cdkeyValue: '', - verifyInputId: '', - verifyValue: '', - verifyImgId: '', - submitId: '', - }, - verify: { - visible: false, - src: '', - naturalWidth: 0, - naturalHeight: 0, - }, - popup: { - visible: false, - text: '', - detail: '', - }, - } -} - -export async function ensureLoggedInPresentation( - session: SessionForPresentation, - { - hostState = null, - credentialReady = false, - activityUrl = '', - extractHostState: extractHostStateImpl = extractHostState, - reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation, - shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation, - }: EnsureLoggedInPresentationOptions = {}, -): Promise<{ reloaded: boolean; hostState: HostState }> { - let nextHostState = hostState || (await extractHostStateImpl(session.page)) - - if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - if (session.presentationSyncAttempts >= 2) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - session.presentationSyncAttempts += 1 - await reloadActivityPageForPresentationImpl(session.page, activityUrl) - nextHostState = await extractHostStateImpl(session.page) - - return { - reloaded: true, - hostState: nextHostState, - } -} - -export async function ensureActivityInfoReady( - page: BrowserPageLike, - { - triggerRender = false, - timeoutMs = 8_000, - extractActivityInfo: extractActivityInfoImpl = extractActivityInfo, - triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender, - now = () => Date.now(), - }: EnsureActivityInfoReadyOptions = {}, -): Promise { - if (triggerRender) { - await triggerActivityRoleRenderImpl(page) - } - - const startedAt = now() - let lastInfo = await extractActivityInfoImpl(page) - - while (now() - startedAt < timeoutMs) { - if (lastInfo?.role?.ready) { - return lastInfo - } - - await page.waitForTimeout(400) - lastInfo = await extractActivityInfoImpl(page) - } - - return lastInfo -} - -export async function triggerActivityRoleRender(page: BrowserPageLike): Promise { - try { - await page.evaluate(() => { - const maybeRender = window.renderCdkey - - if (typeof maybeRender === 'function') { - maybeRender() - } - }) - } catch { - // ignore render trigger failures; polling will still inspect the page - } -} - -export async function extractActivityInfo(page: BrowserPageLike): Promise { - try { - return await page.evaluate(() => { - const asString = (value: unknown) => (typeof value === 'string' ? value.trim() : '') - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const pick = (selector: string) => document.querySelector(selector) - const pickBest = ( - selector: string, - predicate: ((element: Element) => boolean) | null = null, - ): Element | null => { - const matches = [...document.querySelectorAll(selector)] - const preferred = matches.find((element) => { - if (predicate && !predicate(element)) { - return false - } - - return isVisible(element) - }) - - if (preferred) { - return preferred - } - - if (predicate) { - return matches.find((element) => predicate(element)) || null - } - - return matches[0] || null - } - const textOf = (selector) => asString(pick(selector)?.textContent || '') - const inputOf = (selector) => { - const element = pickBest( - selector, - (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, - ) - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return '' - } - - return asString(element.value || '') - } - - const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} - const roleSelector = pick('[id^="milo_role_selector_"]') - const roleNameText = - textOf('#role_name') || - asString(roleSelector instanceof HTMLSelectElement ? roleSelector.selectedOptions?.[0]?.textContent || '' : '') - const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) - const popup = pick('#pop2') - const cdkeyInput = pickBest( - '[id^="milo_cdkeyInfo_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const verifyInput = pickBest( - '[id^="milo_verifyInput_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const submitButton = pickBest( - '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', - (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), - ) - - return { - nickname: textOf('#login_nickname_span'), - role: { - ready: Boolean(window.isRole || roleNameText), - roleName: roleNameText, - roleId: asString(roleData.sRoleId), - area: asString(roleData.sArea || '36'), - partition: asString(roleData.sPartition), - platId: asString(roleData.sPlatId), - md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), - checkparam: asString(roleData.sCheckparam), - }, - form: { - cdkeyInputId: cdkeyInput?.id || '', - cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), - verifyInputId: verifyInput?.id || '', - verifyValue: inputOf('[id^="milo_verifyInput_"]'), - verifyImgId: verifyImg?.id || '', - submitId: submitButton?.id || '', - }, - verify: { - visible: isVisible(verifyImg), - src: asString(verifyImg?.getAttribute('src')), - naturalWidth: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalWidth || 0) : 0, - naturalHeight: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalHeight || 0) : 0, - }, - popup: { - visible: isVisible(popup), - text: textOf('#PopText'), - detail: textOf('#PopText2'), - }, - } - }) - } catch { - return createEmptyActivityInfo() - } -} diff --git a/apps/backend/src/services/session/session-browser-config.ts b/apps/backend/src/services/session/session-browser-config.ts deleted file mode 100644 index 2504431c..00000000 --- a/apps/backend/src/services/session/session-browser-config.ts +++ /dev/null @@ -1,135 +0,0 @@ -import process from 'node:process' - -export type LoginType = 'qq' | 'wx' - -export const DEFAULT_LOGIN_TYPE: LoginType = 'qq' - -type BrowserConfigInput = { - headless?: boolean - devtools?: boolean - keepAlive?: boolean - prewarm?: boolean - slowMoMs?: number -} - -type ResolveBrowserLaunchOptionsInput = { - browserConfig?: BrowserConfigInput - chromePath?: string - nodeEnv?: string - ci?: string -} - -export type BrowserLaunchOptions = { - headless: boolean - devtools: boolean - usesBundledChromium: boolean - keepAlive: boolean - prewarm: boolean - slowMoMs: number -} - -export type ViewportSize = { - width: number - height: number -} - -export function resolveBrowserLaunchOptions(options: ResolveBrowserLaunchOptionsInput = {}): BrowserLaunchOptions { - const browserConfig = options.browserConfig || {} - const chromePath = options.chromePath || '' - const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV - const ci = options.ci ?? process.env.CI - const explicitHeadless = browserConfig.headless - const explicitDevtools = browserConfig.devtools - const explicitKeepAlive = browserConfig.keepAlive - const explicitPrewarm = browserConfig.prewarm - const explicitSlowMo = browserConfig.slowMoMs - const isProductionLike = nodeEnv === 'production' || ci === '1' - - const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike - - const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless - - const slowMoMs = Number.isFinite(explicitSlowMo) - ? Math.max(0, Math.min(explicitSlowMo, 2_000)) - : headless - ? 0 - : 150 - - const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike - const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive - - return { - headless, - devtools, - usesBundledChromium: !chromePath, - keepAlive, - prewarm, - slowMoMs, - } -} - -export function buildChromiumLaunchOptions( - launchOptions: BrowserLaunchOptions, - { chromePath = '' }: { chromePath?: string } = {}, -) { - const options = { - headless: launchOptions.headless, - devtools: launchOptions.devtools, - slowMo: launchOptions.slowMoMs, - } - - if (chromePath) { - return { - ...options, - executablePath: chromePath, - } - } - - return options -} - -export function resolveDefaultViewport(rawValue?: unknown): ViewportSize { - const fromVncResolution = parseViewportSpec(rawValue) - if (fromVncResolution) { - return fromVncResolution - } - - return { width: 1600, height: 900 } -} - -export function parseViewportSpec(rawValue: unknown): ViewportSize | null { - const text = String(rawValue || '').trim() - - if (!text) { - return null - } - - const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i) - if (!match) { - return null - } - - const width = Number(match[1]) - const height = Number(match[2]) - - if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) { - return null - } - - return { width, height } -} - -export function normalizeLoginType(value: unknown, fallback: LoginType = DEFAULT_LOGIN_TYPE): LoginType { - const normalized = String(value || fallback).trim().toLowerCase() - if (['qq', 'qc'].includes(normalized)) { - return 'qq' - } - if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) { - return 'wx' - } - return fallback -} - -export function getLoginTypeLabel(loginType: LoginType | string): string { - return loginType === 'wx' ? '微信' : 'QQ' -} diff --git a/apps/backend/src/services/session/session-cookie-map.ts b/apps/backend/src/services/session/session-cookie-map.ts deleted file mode 100644 index f39da4c8..00000000 --- a/apps/backend/src/services/session/session-cookie-map.ts +++ /dev/null @@ -1,49 +0,0 @@ -const TENCENT_COOKIE_URLS = [ - 'https://df.qq.com', - 'https://ssl.captcha.qq.com', - 'https://graph.qq.com', - 'https://milo.qq.com', -] - -type BrowserCookie = { - name?: string - value?: string -} - -type BrowserContextLike = { - cookies: (urls?: string[]) => Promise -} - -export type TencentCookieMap = Record - -export function normalizeTencentCookieMap(cookies: BrowserCookie[] = []): TencentCookieMap { - const cookieMap: TencentCookieMap = {} - - for (const cookie of cookies) { - if (!cookie?.name) { - continue - } - - cookieMap[String(cookie.name)] = String(cookie.value || '') - } - - if (!cookieMap.acctype) { - cookieMap.acctype = 'qc' - } - - if (!cookieMap.appid) { - cookieMap.appid = '101491592' - } - - if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) { - cookieMap.iegams_milo_proxylogin_qc = - `${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}` - } - - return cookieMap -} - -export async function loadCookieMapFromContext(browserContext: BrowserContextLike): Promise { - const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS) - return normalizeTencentCookieMap(cookies) -} diff --git a/apps/backend/src/services/session/session-login-shared.ts b/apps/backend/src/services/session/session-login-shared.ts deleted file mode 100644 index d59ca960..00000000 --- a/apps/backend/src/services/session/session-login-shared.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' -import { logDebug } from '../../utils/logger.js' - -const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) - -type FrameLike = unknown - -type PageWithFrames = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise -} - -type QrDownloadResponse = { - ok: () => boolean - status: () => number - body: () => Promise -} - -type PageWithRequestContext = { - context: () => { - request: { - get: (url: string, options?: { - failOnStatusCode?: boolean - headers?: Record - }) => Promise - } - } -} - -export function logBrowserSessionDebug(scope: string, detail?: unknown): void { - if (!SESSION_DEBUG_ENABLED) { - return - } - - logDebug('[browser/session]', scope, detail) -} - -export async function waitForFrame( - page: PageWithFrames, - predicate: (frame: TFrame) => boolean, - timeoutMs = 15_000, -): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < timeoutMs) { - const matched = page.frames().find((frame): frame is TFrame => { - try { - return predicate(frame as TFrame) - } catch { - return false - } - }) - - if (matched) { - return matched - } - - await page.waitForTimeout(300) - } - - throw new Error('未找到登录二维码 frame') -} - -export async function downloadRemoteQrImage( - page: PageWithRequestContext, - qrUrl: string, - qrImagePath: string, - referer = '', - loginType = 'unknown', -): Promise { - try { - const response = await page.context().request.get(qrUrl, { - failOnStatusCode: false, - headers: { - referer, - }, - }) - - if (!response.ok()) { - logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, { - qrUrl, - status: response.status(), - }) - return false - } - - const body = await response.body() - - if (!body?.length) { - return false - } - - return body - } catch (error) { - logBrowserSessionDebug(`${loginType}.capture.downloadError`, { - qrUrl, - message: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -export function isRetryableQrCaptureError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error || '') - - return ( - message.includes('Frame was detached') || - message.includes('Execution context was destroyed') || - message.includes('Target page, context or browser has been closed') || - message.includes('waiting for') || - message.includes('Timeout') - ) -} diff --git a/apps/backend/src/services/session/session-page.ts b/apps/backend/src/services/session/session-page.ts deleted file mode 100644 index bc423f5b..00000000 --- a/apps/backend/src/services/session/session-page.ts +++ /dev/null @@ -1,244 +0,0 @@ -/// - -import fs from 'node:fs/promises' -import path from 'node:path' - -import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js' -import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js' -import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js' - -type LoginType = 'qq' | 'wx' | string - -type LocatorLike = { - count: () => Promise - first: () => LocatorLike - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise -} - -type BrowserPageLike = { - frames: () => any[] - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise - reload: (options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - context: () => any -} - -type SessionForQrCapture = { - page: BrowserPageLike - loginType: LoginType - sessionDir: string - qrImagePath?: string - qrImageBase64?: string - qrUpdatedAt?: string - updatedAt?: string -} - -type CaptureSessionQrOptions = { - ensureLoginReady?: boolean - ensureLoginTab?: typeof ensureLoginTab - captureQrImageWithRetry?: typeof captureQrImageWithRetry - readFile?: (path: string, encoding: BufferEncoding) => Promise - now?: () => string -} - -type CaptureQrImageWithRetryOptions = { - captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - ensureLoginTab?: typeof ensureLoginTab - isRetryableQrCaptureError?: (error: unknown) => boolean - logBrowserSessionDebug?: (scope: string, detail?: unknown) => void -} - -type EnsureLoginTabOptions = { - ensureQqLoginReady?: (page: BrowserPageLike) => Promise - ensureWxLoginReady?: (page: BrowserPageLike) => Promise -} - -type ExtractQrStateOptions = { - extractQqQrState?: (page: BrowserPageLike) => Promise - extractWxQrState?: (page: BrowserPageLike) => Promise -} - -export async function captureSessionQr( - session: SessionForQrCapture, - { - ensureLoginReady = true, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry, - readFile = fs.readFile, - now = () => new Date().toISOString(), - }: CaptureSessionQrOptions = {}, -): Promise { - if (ensureLoginReady) { - await ensureLoginTabImpl(session.page, session.loginType) - } - - const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) - await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, { - ensureLoginTab: ensureLoginTabImpl, - }) - - session.qrImagePath = qrImagePath - session.qrImageBase64 = await readFile(qrImagePath, 'base64') - session.qrUpdatedAt = now() - session.updatedAt = session.qrUpdatedAt -} - -export async function captureQrImageWithRetry( - page: BrowserPageLike, - loginType: LoginType, - qrImagePath: string, - { - captureQqQrImage: captureQqQrImageImpl = captureQqQrImage, - captureWxQrImage: captureWxQrImageImpl = captureWxQrImage, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError, - logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug, - }: CaptureQrImageWithRetryOptions = {}, -): Promise { - const maxAttempts = loginType === 'qq' ? 4 : 3 - let lastError: unknown = null - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - logBrowserSessionDebugImpl('captureQrImageWithRetry.start', { - loginType, - attempt, - maxAttempts, - qrImagePath, - }) - - if (loginType === 'qq') { - await captureQqQrImageImpl(page, qrImagePath) - } else { - await captureWxQrImageImpl(page, qrImagePath) - } - - logBrowserSessionDebugImpl('captureQrImageWithRetry.success', { - loginType, - attempt, - qrImagePath, - }) - - return - } catch (error) { - lastError = error - logBrowserSessionDebugImpl('captureQrImageWithRetry.error', { - loginType, - attempt, - message: error instanceof Error ? error.message : String(error), - }) - - if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) { - throw error - } - - await page.waitForTimeout(500) - await ensureLoginTabImpl(page, loginType) - } - } - - if (lastError) { - throw lastError - } - - throw new Error('二维码截图失败') -} - -export async function ensureLoginTab( - page: BrowserPageLike, - loginType: LoginType, - { - ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady, - ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady, - }: EnsureLoginTabOptions = {}, -): Promise { - const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' - const loginButton = page.locator('#unlogin') - let interacted = false - - if (await loginButton.count()) { - try { - await loginButton.first().click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore when the login layer is already open - } - } - - const tab = page.locator(tabSelector).first() - - if (await tab.count()) { - try { - await tab.click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore tab click failures - } - } - - if (interacted) { - await page.waitForTimeout(250) - } - - if (loginType === 'qq') { - await ensureQqLoginReadyImpl(page) - return - } - - if (loginType === 'wx') { - await ensureWxLoginReadyImpl(page) - } -} - -export async function extractHostState(page: BrowserPageLike) { - return page.evaluate(() => { - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const unlogin = document.querySelector('#unlogin') - const logined = document.querySelector('#logined') - - return { - unloginVisible: isVisible(unlogin), - loginedVisible: isVisible(logined), - loginText: String(logined?.textContent || '').trim(), - } - }) -} - -export async function reloadActivityPageForPresentation(page: BrowserPageLike, activityUrl: string): Promise { - try { - await page.reload({ waitUntil: 'domcontentloaded' }) - } catch { - await page.goto(activityUrl, { waitUntil: 'domcontentloaded' }) - } - - await page.waitForTimeout(2_500) -} - -export async function extractQrState( - page: BrowserPageLike, - loginType: LoginType, - { - extractQqQrState: extractQqQrStateImpl = extractQqQrState, - extractWxQrState: extractWxQrStateImpl = extractWxQrState, - }: ExtractQrStateOptions = {}, -): Promise { - if (loginType === 'qq') { - return extractQqQrStateImpl(page) - } - - return extractWxQrStateImpl(page) -} diff --git a/apps/backend/src/services/session/session-payload.ts b/apps/backend/src/services/session/session-payload.ts deleted file mode 100644 index 595e09ad..00000000 --- a/apps/backend/src/services/session/session-payload.ts +++ /dev/null @@ -1,190 +0,0 @@ -export function buildSessionPayload( - session: TencentBrowserSessionPayloadSource, - { - activityUrl = '', - includeQrImage = true, - sessionDebugEnabled = false, - browserDebug = null, - }: BuildSessionPayloadOptions = {}, -): any { - const activityInfo = session.lastState?.activityInfo || null - const payload: Record = { - sessionId: session.sessionId, - loginType: session.loginType, - activityUrl, - status: session.status, - notice: session.notice, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - expiresAt: new Date(session.expiresAt).toISOString(), - qrUpdatedAt: session.qrUpdatedAt, - credentialReady: Boolean(session.lastState?.credentialReady), - activityInfo, - review: buildReviewPayload(session.lastReview), - redeem: buildRedeemPayload(session.lastRedeem), - artifacts: buildArtifactsPayload(session), - } - - if (includeQrImage) { - payload.qrImageBase64 = session.qrImageBase64 - } - - if (!sessionDebugEnabled) { - return payload - } - - return { - ...payload, - cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [], - state: session.lastState, - browserDebug, - qrImagePath: session.qrImagePath, - review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }), - redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), - artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), - } -} - -type BuildSessionPayloadOptions = { - activityUrl?: string - includeQrImage?: boolean - sessionDebugEnabled?: boolean - browserDebug?: unknown -} - -type InternalPathOptions = { - includeInternalPaths?: boolean -} - -type TencentBrowserSessionPayloadSource = { - sessionId: string - loginType?: string - status?: string - notice?: string - createdAt?: string - updatedAt?: string - expiresAt: string | number | Date - qrUpdatedAt?: string - qrImageBase64?: string - qrImagePath?: string - sessionDir?: string - lastState?: { - credentialReady?: boolean - cookieKeys?: string[] - activityInfo?: any - [key: string]: unknown - } | null - lastReview?: ReviewPayloadSource | null - lastRedeem?: RedeemPayloadSource | null -} - -type ReviewPayloadSource = { - capturedAt?: string - roleId?: string - roleName?: string - screenshotPath?: string - signature?: string -} - -type RedeemAttemptPayloadSource = { - attempt?: number - redeem?: unknown -} - -type RedeemPayloadSource = { - code?: string - area?: string - proofMode?: string - attempts?: RedeemAttemptPayloadSource[] - final?: RedeemAttemptPayloadSource | null - finishedAt?: string - screenshotPath?: string - htmlPath?: string - resultPath?: string -} - -export function buildReviewPayload(review: ReviewPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!review) { - return null - } - - const payload = { - capturedAt: review.capturedAt, - roleId: review.roleId || '', - roleName: review.roleName || '', - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: review.screenshotPath || '', - signature: review.signature || '', - } -} - -export function buildRedeemPayload(redeem: RedeemPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!redeem) { - return null - } - - const payload = { - code: redeem.code, - area: redeem.area, - proofMode: redeem.proofMode || 'full', - attempts: Array.isArray(redeem.attempts) - ? redeem.attempts.map((attempt) => ({ - attempt: attempt.attempt, - redeem: attempt.redeem || null, - })) - : [], - final: redeem.final - ? { - attempt: redeem.final.attempt, - redeem: redeem.final.redeem || null, - } - : null, - finishedAt: redeem.finishedAt, - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: redeem.screenshotPath || '', - htmlPath: redeem.htmlPath || '', - resultPath: redeem.resultPath || '', - } -} - -export function buildArtifactsPayload( - session: Pick< - TencentBrowserSessionPayloadSource, - 'sessionDir' | 'qrImagePath' | 'qrImageBase64' | 'lastReview' | 'lastRedeem' - >, - { includeInternalPaths = false }: InternalPathOptions = {}, -) { - const payload = { - hasQrImage: Boolean(session.qrImageBase64), - hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath), - hasScreenshot: Boolean(session.lastRedeem?.screenshotPath), - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - sessionDir: session.sessionDir, - qrImagePath: session.qrImagePath, - screenshotPath: session.lastRedeem?.screenshotPath || '', - htmlPath: session.lastRedeem?.htmlPath || '', - resultPath: session.lastRedeem?.resultPath || '', - reviewScreenshotPath: session.lastReview?.screenshotPath || '', - } -} diff --git a/apps/backend/src/services/session/session-proof-beijing-time.ts b/apps/backend/src/services/session/session-proof-beijing-time.ts deleted file mode 100644 index 4805a51b..00000000 --- a/apps/backend/src/services/session/session-proof-beijing-time.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// - -import { - BAIDU_BEIJING_TIME_URL, - BEIJING_TIME_PROOF_CLIP, - BEIJING_TIME_PROOF_VIEWPORT, -} from './session-proof-constants.js' - -type BrowserPageLike = { - setViewportSize: (viewport: { width: number; height: number }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: () => unknown) => Promise - screenshot: (options: { - path: string - clip: { x: number; y: number; width: number; height: number } - }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -export type BeijingTimeProof = { - screenshotPath: string - pageUrl: string -} - -export async function captureBeijingTimeProof( - browserContext: BrowserContextLike, - { screenshotPath }: { screenshotPath: string }, -): Promise { - const proofPage = await browserContext.newPage() - - try { - await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT) - await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' }) - await proofPage.waitForTimeout(3_000) - await proofPage.evaluate(() => { - window.scrollTo(0, 0) - }) - await proofPage.screenshot({ - path: screenshotPath, - clip: BEIJING_TIME_PROOF_CLIP, - }) - - return { - screenshotPath, - pageUrl: BAIDU_BEIJING_TIME_URL, - } - } finally { - await proofPage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-constants.ts b/apps/backend/src/services/session/session-proof-constants.ts deleted file mode 100644 index 0b80f9f0..00000000 --- a/apps/backend/src/services/session/session-proof-constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1' -export type RedeemProofMode = 'full' | 'basic' | 'off' - -export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 } -export const BEIJING_TIME_PROOF_CLIP = { - x: 0, - y: 0, - width: BEIJING_TIME_PROOF_VIEWPORT.width, - height: 620, -} -export const DEFAULT_REDEEM_PROOF_MODE: RedeemProofMode = 'full' diff --git a/apps/backend/src/services/session/session-proof-html.ts b/apps/backend/src/services/session/session-proof-html.ts deleted file mode 100644 index 31f5d32b..00000000 --- a/apps/backend/src/services/session/session-proof-html.ts +++ /dev/null @@ -1,180 +0,0 @@ -export type ProofHtmlInput = { - redeemImageBase64: string - timeImageBase64: string - redeemMessage?: string - timePageUrl: string -} - -export function buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, -}: ProofHtmlInput): string { - const capturedAt = new Date().toLocaleString('zh-CN', { - hour12: false, - timeZone: 'Asia/Shanghai', - }) - - return ` - - - - - 兑换与北京时间凭证 - - - -
-
-
-

Tencent Redeem Proof

-

兑换截图与北京时间截图

-

用于留存兑换结果与北京时间检索页的组合凭证。

-
-
- Captured At - ${escapeHtml(capturedAt)} -
-
-
-

兑换结果截图

-

${escapeHtml(redeemMessage || '兑换完成')}

-
- 兑换结果截图 -
-
-
-

百度检索“北京时间”截图

-

单独标签页打开百度搜索结果并截图。

-
${escapeHtml(timePageUrl)}
-
- 北京时间截图 -
-
-
- -` -} - -export function escapeHtml(value: unknown): string { - return String(value || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/apps/backend/src/services/session/session-proof-mode.ts b/apps/backend/src/services/session/session-proof-mode.ts deleted file mode 100644 index 790ef3a6..00000000 --- a/apps/backend/src/services/session/session-proof-mode.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -import { DEFAULT_REDEEM_PROOF_MODE, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultForProof = { - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - } -} | null | undefined - -export function resolveRedeemProofMode(rawMode: unknown = runtimeConfig.redeem.proofMode): RedeemProofMode { - const normalized = String(rawMode || '') - .trim() - .toLowerCase() - - if (normalized === 'basic' || normalized === 'off') { - return normalized - } - - return DEFAULT_REDEEM_PROOF_MODE -} - -export function shouldCaptureBeijingTimeProof(finalResult: RedeemResultForProof): boolean { - if (finalResult?.classification?.success === true) { - return true - } - - if (finalResult?.classification?.success === false) { - return false - } - - const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret) - return Number.isFinite(retCode) && retCode === 0 -} diff --git a/apps/backend/src/services/session/session-proof-paths.ts b/apps/backend/src/services/session/session-proof-paths.ts deleted file mode 100644 index e8202379..00000000 --- a/apps/backend/src/services/session/session-proof-paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path' - -export type RedeemArtifactPaths = { - redeemPageScreenshotPath: string - beijingTimeScreenshotPath: string - screenshotPath: string - htmlPath: string - resultPath: string -} - -export function buildArtifactPaths(outputDir: string): RedeemArtifactPaths { - return { - redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'), - beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'), - screenshotPath: path.join(outputDir, 'redeem-result.png'), - htmlPath: path.join(outputDir, 'page.html'), - resultPath: path.join(outputDir, 'result.json'), - } -} diff --git a/apps/backend/src/services/session/session-proof-renderer.ts b/apps/backend/src/services/session/session-proof-renderer.ts deleted file mode 100644 index 891d5735..00000000 --- a/apps/backend/src/services/session/session-proof-renderer.ts +++ /dev/null @@ -1,131 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { buildProofHtml } from './session-proof-html.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { path: string; fullPage?: boolean }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type ComposeProofScreenshotOptions = { - outputPath: string - redeemImagePath: string - timeImagePath: string - redeemMessage?: string - timePageUrl: string -} - -export async function showResultDialog(page: BrowserPageLike, message: string): Promise { - await page.evaluate((text) => { - document.querySelectorAll('iframe').forEach((element) => element.remove()) - document.querySelectorAll('.pop').forEach((element) => { - if (element instanceof HTMLElement) { - element.style.display = 'none' - } - }) - - let card = document.getElementById('codex-redeem-result') - - if (!card) { - card = document.createElement('div') - card.id = 'codex-redeem-result' - card.innerHTML = ` -
Tencent Browser Session
-
兑换结果
-
- ` - document.body.appendChild(card) - } - - const messageNode = card.querySelector('.codex-redeem-result__message') - - if (messageNode) { - messageNode.textContent = text - } - - Object.assign(card.style, { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: '99999', - width: 'min(560px, calc(100vw - 80px))', - padding: '32px 36px', - borderRadius: '24px', - background: 'rgba(10, 18, 28, 0.92)', - boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)', - border: '1px solid rgba(109, 241, 202, 0.25)', - color: '#f4fbff', - fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif', - }) - - const styleId = 'codex-redeem-result-style' - - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = ` - #codex-redeem-result .codex-redeem-result__eyebrow { - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: #6df1ca; - margin-bottom: 12px; - } - #codex-redeem-result .codex-redeem-result__title { - font-size: 34px; - font-weight: 700; - margin-bottom: 16px; - } - #codex-redeem-result .codex-redeem-result__message { - font-size: 24px; - line-height: 1.5; - color: #ffffff; - } - ` - document.head.appendChild(style) - } - - window.scrollTo(0, 0) - }, message) - - await page.waitForTimeout(600) -} - -export async function composeProofScreenshot( - browserContext: BrowserContextLike, - { outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }: ComposeProofScreenshotOptions, -): Promise { - const [redeemImageBase64, timeImageBase64] = await Promise.all([ - fs.readFile(redeemImagePath, 'base64'), - fs.readFile(timeImagePath, 'base64'), - ]) - - const composePage = await browserContext.newPage() - - try { - await composePage.setViewportSize({ width: 1520, height: 1900 }) - await composePage.setContent( - buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, - }), - { waitUntil: 'domcontentloaded' }, - ) - await composePage.screenshot({ path: outputPath, fullPage: true }) - } finally { - await composePage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-result-writer.ts b/apps/backend/src/services/session/session-proof-result-writer.ts deleted file mode 100644 index 2910933e..00000000 --- a/apps/backend/src/services/session/session-proof-result-writer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises' - -import { BAIDU_BEIJING_TIME_URL, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultFilePayload = { - proofMode: RedeemProofMode - sessionId?: string - activityUrl: string - code: string - finalResult?: { - role?: { - area?: string - } - [key: string]: unknown - } | null - attempts?: unknown[] - screenshotPath: string - htmlPath?: string - redeemPageScreenshotPath?: string - beijingTimeScreenshotPath?: string - beijingTimePageUrl?: string - beijingTimeError?: string -} - -export async function writeRedeemResultFile( - resultPath: string, - { - proofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath = '', - redeemPageScreenshotPath = '', - beijingTimeScreenshotPath = '', - beijingTimePageUrl = BAIDU_BEIJING_TIME_URL, - beijingTimeError = '', - }: RedeemResultFilePayload, -): Promise { - await fs.writeFile( - resultPath, - JSON.stringify( - { - proofMode, - sessionId, - activityUrl, - code, - area: finalResult?.role?.area || '', - final: finalResult, - attempts, - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - beijingTimePageUrl, - beijingTimeError, - screenshotPath, - htmlPath, - }, - null, - 2, - ), - 'utf8', - ) -} diff --git a/apps/backend/src/services/session/session-proof.test.ts b/apps/backend/src/services/session/session-proof.test.ts deleted file mode 100644 index ff4ef220..00000000 --- a/apps/backend/src/services/session/session-proof.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import path from 'node:path' - -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js' -import { buildProofHtml } from './session-proof-html.js' -import { buildArtifactPaths } from './session-proof-paths.js' - -test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => { - assert.equal(resolveRedeemProofMode('basic'), 'basic') - assert.equal(resolveRedeemProofMode(' OFF '), 'off') - assert.equal(resolveRedeemProofMode('full'), 'full') - assert.equal(resolveRedeemProofMode('unexpected'), 'full') - assert.equal(resolveRedeemProofMode(''), 'full') -}) - -test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => { - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: true, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: false, - }, - }), - false, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: 0, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: -183, - }, - }), - false, - ) - - assert.equal(shouldCaptureBeijingTimeProof(null), false) -}) - -test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => { - const artifactPaths = buildArtifactPaths('/tmp/redeem-proof') - - assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png')) - assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png')) - assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png')) - assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html')) - assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json')) -}) - -test('buildProofHtml escapes injected text while keeping proof sections stable', () => { - const html = buildProofHtml({ - redeemImageBase64: 'redeem-base64', - timeImageBase64: 'time-base64', - redeemMessage: '兑换成功 & 已到账', - timePageUrl: 'https://example.com/?q=<北京时间>', - }) - - assert.match(html, /Tencent Redeem Proof/) - assert.match(html, /兑换截图与北京时间截图/) - assert.match(html, /<b>兑换成功 & 已到账<\/b>/) - assert.match(html, /https:\/\/example\.com\/\?q=<北京时间>/) - assert.match(html, /data:image\/png;base64,redeem-base64/) - assert.match(html, /data:image\/png;base64,time-base64/) -}) diff --git a/apps/backend/src/services/session/session-proof.ts b/apps/backend/src/services/session/session-proof.ts deleted file mode 100644 index c132ce44..00000000 --- a/apps/backend/src/services/session/session-proof.ts +++ /dev/null @@ -1,205 +0,0 @@ -import fs from 'node:fs/promises' - -import { - BAIDU_BEIJING_TIME_URL, - DEFAULT_REDEEM_PROOF_MODE, - type RedeemProofMode, -} from './session-proof-constants.js' -import { captureBeijingTimeProof } from './session-proof-beijing-time.js' -import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js' -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js' -import { buildArtifactPaths } from './session-proof-paths.js' -import { writeRedeemResultFile } from './session-proof-result-writer.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { - path: string - fullPage?: boolean - clip?: { x: number; y: number; width: number; height: number } - }) => Promise - content: () => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type RedeemFinalResult = { - role?: { - area?: string - } - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - sMsg?: string - } - [key: string]: unknown -} - -type RedeemArtifactsInput = { - browserContext: BrowserContextLike - page: BrowserPageLike - sessionDir: string - sessionId?: string - activityUrl: string - code: string - finalResult: RedeemFinalResult - attempts?: unknown[] -} - -type WriteRedeemArtifactFilesInput = Omit & { - outputDir: string - proofMode?: RedeemProofMode | string -} - -type RedeemArtifactsResult = { - proofMode: RedeemProofMode - screenshotPath: string - htmlPath: string - resultPath: string -} - -export async function saveRedeemArtifacts({ - browserContext, - page, - sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, -}: RedeemArtifactsInput): Promise { - return writeRedeemArtifactFiles({ - browserContext, - page, - outputDir: sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, - proofMode: resolveRedeemProofMode(), - }) -} - -export async function writeRedeemArtifactFiles({ - browserContext, - page, - outputDir, - sessionId = '', - activityUrl, - code, - finalResult, - attempts, - proofMode = DEFAULT_REDEEM_PROOF_MODE, -}: WriteRedeemArtifactFilesInput): Promise { - const effectiveProofMode = resolveRedeemProofMode(proofMode) - const { - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - screenshotPath, - htmlPath, - resultPath, - } = buildArtifactPaths(outputDir) - - if (effectiveProofMode === 'off') { - return { - proofMode: effectiveProofMode, - screenshotPath: '', - htmlPath: '', - resultPath: '', - } - } - - await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成') - - if (effectiveProofMode === 'basic') { - await page.screenshot({ path: screenshotPath, fullPage: true }) - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath: '', - resultPath, - } - } - - await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true }) - - let beijingTimeProof: { - screenshotPath: string - pageUrl: string - error?: string - } = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: '', - } - - if (shouldCaptureBeijingTimeProof(finalResult)) { - try { - beijingTimeProof = await captureBeijingTimeProof(browserContext, { - screenshotPath: beijingTimeScreenshotPath, - }) - await composeProofScreenshot(browserContext, { - outputPath: screenshotPath, - redeemImagePath: redeemPageScreenshotPath, - timeImagePath: beijingTimeProof.screenshotPath, - redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'), - timePageUrl: beijingTimeProof.pageUrl, - }) - } catch (error) { - beijingTimeProof = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: error instanceof Error ? error.message : '北京时间截图生成失败', - } - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - } else { - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - - await fs.writeFile(htmlPath, await page.content(), 'utf8') - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath, - redeemPageScreenshotPath, - beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '', - beijingTimePageUrl: beijingTimeProof.pageUrl, - beijingTimeError: beijingTimeProof.error || '', - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath, - resultPath, - } -} - -export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } diff --git a/apps/backend/src/services/session/session-qq.ts b/apps/backend/src/services/session/session-qq.ts deleted file mode 100644 index bf96d042..00000000 --- a/apps/backend/src/services/session/session-qq.ts +++ /dev/null @@ -1,240 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -const QQ_QR_TARGET_SIZE = 144 - -type LocatorLike = { - count: () => Promise - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - first: () => LocatorLike -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => LocatorLike - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type QqQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureQqLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const switcher = frame.locator('#switcher_qlogin') - - if (!(await switcher.count())) { - return - } - - await switcher.click({ timeout: 3_000 }).catch(() => null) - await frame.waitForTimeout(250) - } catch { - // ignore qq inner tab switch failures; the screenshot retry path will try again - } -} - -export async function captureQqQrImage(page: PageLike, qrImagePath: string): Promise { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const qrUrl = await resolveQqQrImageUrl(frame) - const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl) - - logBrowserSessionDebug('qq.capture.qrUrl', { - qrUrl, - effectiveQrUrl, - frameUrl: frame.url(), - }) - - if (effectiveQrUrl) { - const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq') - logBrowserSessionDebug('qq.capture.downloadResult', { - qrUrl: effectiveQrUrl, - downloaded: Buffer.isBuffer(body) && body.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(body) && body.length > 0) { - await fs.writeFile(qrImagePath, body) - return - } - } - - logBrowserSessionDebug('qq.capture.fallbackScreenshot', { - qrImagePath, - }) - - const qrLocator = await findQqQrLocator(page, frame) - - try { - await qrLocator.screenshot({ path: qrImagePath }) - return - } catch { - const iframeLocator = page.locator('#milo-qcwx-frame-qc').first() - await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 }) - await iframeLocator.screenshot({ path: qrImagePath }) - } -} - -export async function findQqQrLocator(page: PageLike, frame: FrameLike | null = null): Promise { - const effectiveFrame = - frame || - await waitForFrame(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - const locator = effectiveFrame.locator('#qrlogin_img') - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('qq.findQrLocator.visibleReady', { - frameUrl: effectiveFrame.url(), - }) - return locator -} - -export async function extractQqQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const { qrVisible, bodyText } = await frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImage = document.querySelector('#qrlogin_img') - const bodyText = String(document.body?.textContent || '').replace(/\s+/g, ' ').trim() - - return { - qrVisible: - qrImage instanceof HTMLImageElement && - isVisible(qrImage) && - Number(qrImage.naturalWidth || 0) > 0 && - Number(qrImage.naturalHeight || 0) > 0, - bodyText, - } - }) - .catch(() => ({ - qrVisible: false, - bodyText: '', - })) - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function resolveQqQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const qrImage = document.querySelector('#qrlogin_img') - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} - -function upgradeQqQrImageUrl(qrUrl: string): string { - if (!qrUrl) { - return '' - } - - try { - const url = new URL(qrUrl) - - if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) { - return qrUrl - } - - const currentSize = Number(url.searchParams.get('d') || 0) - - if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) { - url.searchParams.set('d', String(QQ_QR_TARGET_SIZE)) - } - - return url.toString() - } catch { - return qrUrl - } -} diff --git a/apps/backend/src/services/session/session-redeem.test.ts b/apps/backend/src/services/session/session-redeem.test.ts deleted file mode 100644 index e22f4a89..00000000 --- a/apps/backend/src/services/session/session-redeem.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - classifyTencentRedeemResult, - isCaptchaRejectedResult, - resolveTencentRedeemMessage, -} from './session-redeem.js' - -test('resolveTencentRedeemMessage prefers popup detail over generic title', () => { - assert.equal( - resolveTencentRedeemMessage({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }), - '兑换码错误,请确认兑换码信息是否准确。', - ) -}) - -test('classifyTencentRedeemResult marks used codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }) - - assert.equal(result.outcome, 'code_used') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => { - const result = classifyTencentRedeemResult({ - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult recognizes successful gift popup', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。', - detail: '', - }, - }) - - assert.equal(result.outcome, 'success') - assert.equal(result.success, true) -}) - -test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => { - assert.equal( - isCaptchaRejectedResult({ - iRet: -100, - sMsg: '验证码错误', - }), - true, - ) - - assert.equal( - isCaptchaRejectedResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }), - false, - ) -}) diff --git a/apps/backend/src/services/session/session-redeem.ts b/apps/backend/src/services/session/session-redeem.ts deleted file mode 100644 index af6be4bc..00000000 --- a/apps/backend/src/services/session/session-redeem.ts +++ /dev/null @@ -1,718 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -type JsonObject = Record - -type BrowserResponseLike = { - url: () => string - request: () => { method: () => string } - text: () => Promise - status: () => number -} - -type BrowserLocatorLike = { - screenshot: (options: { path: string }) => Promise - getAttribute: (name: string) => Promise - click: (options?: JsonObject) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type BrowserPageLike = { - waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise - waitForResponse: ( - predicate: (response: BrowserResponseLike) => boolean, - options?: JsonObject, - ) => Promise - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => BrowserLocatorLike - evaluate: (callback: any, arg?: any) => Promise -} - -type ActivityInfo = { - role?: JsonObject & { - ready?: boolean - area?: string - } - form?: { - cdkeyInputId?: string - verifyInputId?: string - verifyImgId?: string - submitId?: string - } -} - -type TencentRedeemPopup = { - visible?: boolean - text?: string - detail?: string -} - -type TencentRedeemResult = JsonObject & { - iRet?: number | string - sMsg?: string - msg?: string - popup?: TencentRedeemPopup | null -} - -type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed' - -type TencentRedeemClassification = { - outcome: TencentRedeemOutcome - success: boolean - retryWithSameCode: boolean - retryWithReplacementCode: boolean - retCode: number | null - message: string -} - -type RedeemAttemptRecord = { - attempt: number - verifyCode: string - verifysession: string - ocrSample: unknown - redeem: TencentRedeemResult - classification: TencentRedeemClassification - role: ActivityInfo['role'] -} - -type TencentBrowserRedeemSession = JsonObject & { - status: string - notice: string - updatedAt: string - lastError?: string - lastRedeem?: JsonObject - page: BrowserPageLike - browserContext: unknown - sessionDir: string - sessionId: string -} - -type RunTencentBrowserRedeemOptions = { - session: TencentBrowserRedeemSession - code: string - maxAttempts: number - ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise - fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise - capturePageCaptchaForOcr: ( - page: BrowserPageLike, - options: { sessionDir: string; attempt: number }, - ) => Promise<{ - imageBuffer: Buffer - imagePath: string - imageExtension: string - contentType: string - verifyImgId: string - verifysession: string - }> - recognizeTencentCaptcha: (payload: JsonObject) => Promise - submitRedeemInBrowser: ( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - ) => Promise - isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean - refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise - persistSessionState: (session: TencentBrowserRedeemSession) => Promise - buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject - saveRedeemArtifacts: (payload: JsonObject) => Promise - activityUrl: string -} - -type ActivityInfoProvider = { - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise -} - -declare global { - interface Window { - Milo?: { - get?: (key: string) => unknown - } - closeDialog?: () => void - } -} - -export async function runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation, - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr, - recognizeTencentCaptcha, - submitRedeemInBrowser, - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl, -}: RunTencentBrowserRedeemOptions): Promise { - session.status = 'redeeming' - session.notice = '正在识别验证码并提交兑换' - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - const attempts: RedeemAttemptRecord[] = [] - let finalResult: RedeemAttemptRecord | null = null - - try { - await ensureLoggedInPresentation(session, { credentialReady: true }) - const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true }) - - if (!activityInfo?.role?.ready) { - throw new Error('浏览器会话尚未拿到角色信息,请稍后重试') - } - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - await fillRedeemCodeInBrowser(session.page, code) - - const captcha = await capturePageCaptchaForOcr(session.page, { - sessionDir: session.sessionDir, - attempt, - }) - const ocr = await recognizeTencentCaptcha({ - imageBase64: captcha.imageBuffer.toString('base64'), - imageExtension: captcha.imageExtension, - imageContentType: captcha.contentType, - finalUrl: captcha.imagePath, - saveSample: true, - tag: `browser-session-${session.sessionId}-attempt-${attempt}`, - }) - - if (ocr?.code !== 0) { - throw new Error(ocr?.msg || 'OCR 识别失败') - } - - const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim() - - if (!verifyCode) { - throw new Error('OCR 没有识别出验证码') - } - - const redeem = await submitRedeemInBrowser(session.page, { - code, - verifyCode, - }) - const classification = classifyTencentRedeemResult(redeem) - - const attemptRecord = { - attempt, - verifyCode, - verifysession: captcha.verifysession, - ocrSample: ocr?.data?.saved || null, - redeem, - classification, - role: activityInfo.role, - } - - attempts.push(attemptRecord) - finalResult = attemptRecord - - if (!isCaptchaRejectedResult(redeem)) { - break - } - - await dismissRedeemRetryPopup(session.page) - await refreshPageCaptcha(session.page, captcha.verifyImgId) - } - - if (!finalResult) { - throw new Error('浏览器会话兑换没有拿到结果') - } - - const artifacts = await saveRedeemArtifacts({ - browserContext: session.browserContext, - page: session.page, - sessionDir: session.sessionDir, - sessionId: session.sessionId, - activityUrl, - code, - finalResult, - attempts, - }) - - session.lastRedeem = { - code, - area: finalResult?.role?.area || '', - attempts, - final: finalResult, - proofMode: artifacts.proofMode, - screenshotPath: artifacts.screenshotPath, - htmlPath: artifacts.htmlPath, - resultPath: artifacts.resultPath, - finishedAt: new Date().toISOString(), - } - session.status = 'redeemed' - session.notice = resolveTencentRedeemMessage(finalResult?.redeem) - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function capturePageCaptchaForOcr( - page: BrowserPageLike, - { sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 }) - - if (!activityInfo?.form?.verifyImgId) { - throw new Error('页面里没有找到验证码图片节点') - } - - const verifySelector = `#${activityInfo.form.verifyImgId}` - - await page.waitForFunction( - (selector) => { - const element = document.querySelector(selector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.naturalWidth > 0 - ) - }, - verifySelector, - { timeout: 8_000 }, - ) - - const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png` - await page.locator(verifySelector).screenshot({ path: imagePath }) - - const verifysession = await page.evaluate(() => { - try { - if (window.Milo && typeof window.Milo.get === 'function') { - return String(window.Milo.get('verifysession') || '') - } - } catch { - // ignore Milo access failures - } - - return '' - }) - - return { - imageBuffer: await fs.readFile(imagePath), - imagePath, - imageExtension: '.png', - contentType: 'image/png', - verifyImgId: activityInfo.form.verifyImgId, - verifysession, - } -} - -export async function submitRedeemInBrowser( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - { ensureActivityInfoReady }: ActivityInfoProvider, -): Promise { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 }) - - if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) { - throw new Error('页面里没有找到兑换输入框或提交按钮') - } - - const verifySelector = `#${activityInfo.form.verifyInputId}` - const submitSelector = `#${activityInfo.form.submitId}` - - await setFormControlValue(page, verifySelector, payload.verifyCode) - await resetRedeemPopup(page) - await normalizeRedeemOverlay(page) - - const responsePromise = page - .waitForResponse( - (response) => - response.url().includes('dfm.ams.game.qq.com/ide/') && - response.request().method().toUpperCase() === 'POST', - { timeout: 10_000 }, - ) - .catch(() => null) - const popupPromise = waitForRedeemPopup(page).catch(() => null) - - await clickRedeemSubmit(page, submitSelector) - - const networkResponse = await responsePromise - const popup = await popupPromise - const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null - - if (parsedNetwork && typeof parsedNetwork === 'object') { - return { - httpStatus: networkResponse.status(), - popup: popup || null, - ...parsedNetwork, - } - } - - if (!popup) { - throw new Error('页面内兑换没有等到结果弹窗或接口响应') - } - - return { - httpStatus: networkResponse?.status?.() || 0, - iRet: inferRedeemCodeFromPopup(popup), - sMsg: popup.text || popup.detail || '兑换完成', - popup, - } -} - -export function fillRedeemCodeInBrowser( - page: BrowserPageLike, - code: string, - { activityInfo }: { activityInfo?: ActivityInfo } = {}, -) { - const cdkeySelector = activityInfo?.form?.cdkeyInputId - ? `#${activityInfo.form.cdkeyInputId}` - : '[id^="milo_cdkeyInfo_"]' - - const verifySelector = activityInfo?.form?.verifyInputId - ? `#${activityInfo.form.verifyInputId}` - : '[id^="milo_verifyInput_"]' - - return Promise.all([ - setFormControlValue(page, cdkeySelector, code), - setFormControlValue(page, verifySelector, ''), - ]) -} - -export async function prepareRedeemCodeFill( - page: BrowserPageLike, - code: string, - { ensureActivityInfoReady }: ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) - await fillRedeemCodeInBrowser(page, code, { activityInfo }) -} - -export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) { - if (!verifyImgId) { - return - } - - const selector = `#${verifyImgId}` - const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '') - await page.locator(selector).click({ timeout: 3_000 }).catch(() => null) - await page.waitForFunction( - ({ selector: targetSelector, previousSrc }) => { - const element = document.querySelector(targetSelector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '') - }, - { selector, previousSrc: currentSrc || '' }, - { timeout: 5_000 }, - ).catch(() => null) -} - -export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean { - if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') { - return true - } - - return false -} - -export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification { - const retCode = Number(result?.iRet) - const message = resolveTencentRedeemMessage(result) - const normalized = normalizeTencentRedeemText(message) - - if (Number.isFinite(retCode) && retCode === -100) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message) - } - - if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) { - return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用') - } - - if ( - retCode === -165 || - retCode === -183 || - /兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized) - ) { - return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确') - } - - if (/验证码|校验码/.test(normalized)) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试') - } - - if ( - retCode === 0 || - /成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized) - ) { - return buildTencentRedeemClassification('success', retCode, message || '兑换成功') - } - - return buildTencentRedeemClassification('failed', retCode, message || '兑换失败') -} - -function resetRedeemPopup(page: BrowserPageLike) { - return page.evaluate(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - const popupDetail = document.querySelector('#PopText2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - } - - if (popupText instanceof HTMLElement) { - popupText.textContent = '' - } - - if (popupDetail instanceof HTMLElement) { - popupDetail.classList.add('hide') - popupDetail.textContent = '' - } - }) -} - -async function dismissRedeemRetryPopup(page: BrowserPageLike) { - await page.evaluate(() => { - try { - if (typeof window.closeDialog === 'function') { - window.closeDialog() - } - } catch { - // ignore page close hook failures - } - - const closeButton = document.querySelector('#pop2 .pop_close') - - if (closeButton instanceof HTMLElement) { - closeButton.click() - } - - const popup = document.querySelector('#pop2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - popup.style.visibility = 'hidden' - } - }) - - await normalizeRedeemOverlay(page) - await page.waitForTimeout(150) -} - -async function normalizeRedeemOverlay(page: BrowserPageLike) { - await page.evaluate(() => { - const overlayIds = ['_overlay_', 'overlay_mask', 'overlay'] - - for (const id of overlayIds) { - const element = document.getElementById(id) - - if (!(element instanceof HTMLElement)) { - continue - } - - element.style.pointerEvents = 'none' - element.style.display = 'none' - element.style.opacity = '0' - } - }) -} - -async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) { - const locator = page.locator(submitSelector) - - try { - await locator.click({ timeout: 3_000 }) - return - } catch { - await normalizeRedeemOverlay(page) - } - - try { - await locator.click({ timeout: 3_000, force: true }) - return - } catch { - await locator.evaluate((element) => { - if (element instanceof HTMLElement) { - element.click() - } - }) - } -} - -async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) { - const nextValue = String(value ?? '') - - const appliedValue = await page.evaluate( - ({ targetSelector, targetValue }) => { - const isVisible = (element: Element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.getClientRects().length > 0 - ) - } - - const matches = [...document.querySelectorAll(targetSelector)] - const element = - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden' && - isVisible(node), - ) || - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) || - null - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return { - found: false, - value: '', - } - } - - const prototype = - element instanceof HTMLTextAreaElement - ? window.HTMLTextAreaElement.prototype - : window.HTMLInputElement.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') - - if (descriptor?.set) { - descriptor.set.call(element, targetValue) - } else { - element.value = targetValue - } - - element.setAttribute('value', targetValue) - element.focus() - element.dispatchEvent(new Event('input', { bubbles: true })) - element.dispatchEvent(new Event('change', { bubbles: true })) - element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' })) - element.blur() - - return { - found: true, - value: String(element.value || ''), - } - }, - { targetSelector: selector, targetValue: nextValue }, - ) - - if (!appliedValue?.found) { - throw new Error(`页面里没有找到可填写的表单节点: ${selector}`) - } - - if (String(appliedValue.value || '') !== nextValue) { - throw new Error(`页面表单写值失败: ${selector}`) - } -} - -async function waitForRedeemPopup(page: BrowserPageLike): Promise { - await page.waitForFunction(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - - if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(popup) - return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0 - }, { timeout: 10_000 }) - - return page.evaluate(() => ({ - visible: true, - text: String(document.querySelector('#PopText')?.textContent || '').trim(), - detail: String(document.querySelector('#PopText2')?.textContent || '').trim(), - })) -} - -function tryParseJson(text: string): any { - try { - return JSON.parse(text) - } catch { - return null - } -} - -function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) { - const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim() - - if (!text) { - return 1 - } - - if (/验证码|校验码/.test(text)) { - return -100 - } - - if (/成功|已兑换|领取成功/.test(text)) { - return 0 - } - - return 1 -} - -export function resolveTencentRedeemMessage(result: TencentRedeemResult): string { - const candidates = [ - result?.popup?.detail, - result?.popup?.text, - result?.sMsg, - result?.msg, - ] - .map((item) => String(item || '').trim()) - .filter(Boolean) - - return candidates[0] || '兑换完成' -} - -function buildTencentRedeemClassification( - outcome: TencentRedeemOutcome, - retCode: number, - message: string, -): TencentRedeemClassification { - return { - outcome, - success: outcome === 'success', - retryWithSameCode: outcome === 'captcha_rejected', - retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid', - retCode: Number.isFinite(retCode) ? retCode : null, - message: String(message || '').trim(), - } -} - -function normalizeTencentRedeemText(value: unknown): string { - return String(value || '') - .replace(/\s+/g, '') - .trim() - .toLowerCase() -} diff --git a/apps/backend/src/services/session/session-review.ts b/apps/backend/src/services/session/session-review.ts deleted file mode 100644 index bcef4bcf..00000000 --- a/apps/backend/src/services/session/session-review.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from 'node:path' - -import { logBrowserSessionDebug } from './session-login-shared.js' - -type BrowserPageLike = { - screenshot: (options: { path: string; fullPage?: boolean }) => Promise -} - -type ReviewSession = { - sessionId: string - loginType: string - sessionDir: string - page: BrowserPageLike - lastReview?: { - screenshotPath?: string - signature?: string - [key: string]: unknown - } | null -} - -type ActivityInfoForReview = { - role?: { - roleId?: string - roleName?: string - } -} - -export function resolveReviewSignature(session: Pick, activityInfo: ActivityInfoForReview) { - const roleId = String(activityInfo?.role?.roleId || '').trim() - const roleName = String(activityInfo?.role?.roleName || '').trim() - - return { - roleId, - roleName, - signature: `${session.loginType}|${roleId}|${roleName}`, - } -} - -export async function ensureReviewScreenshot(session: ReviewSession, activityInfo: ActivityInfoForReview): Promise { - const { roleId, roleName, signature } = resolveReviewSignature(session, activityInfo) - - if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) { - return false - } - - const screenshotPath = path.join(session.sessionDir, 'role-review.png') - - try { - await session.page.screenshot({ - path: screenshotPath, - fullPage: true, - }) - session.lastReview = { - screenshotPath, - capturedAt: new Date().toISOString(), - roleId, - roleName, - signature, - } - return true - } catch (error) { - logBrowserSessionDebug('reviewScreenshot.capture.error', { - sessionId: session.sessionId, - message: error instanceof Error ? error.message : String(error), - }) - return false - } -} diff --git a/apps/backend/src/services/session/session-service-config.ts b/apps/backend/src/services/session/session-service-config.ts deleted file mode 100644 index 2adb7d4b..00000000 --- a/apps/backend/src/services/session/session-service-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import path from 'node:path' - -import { runtimeConfig } from '../../config/runtime.js' -import { resolveDefaultViewport } from './session-browser-config.js' - -export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions') -export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' -export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() -export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 -export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution) -export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) diff --git a/apps/backend/src/services/session/session-service-runtime.ts b/apps/backend/src/services/session/session-service-runtime.ts deleted file mode 100644 index 22e6ab17..00000000 --- a/apps/backend/src/services/session/session-service-runtime.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { chromium } from 'playwright' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, - resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, -} from './session-browser-config.js' -import { CHROME_PATH } from './session-service-config.js' - -type BrowserLike = { - on: (event: 'disconnected', listener: () => void) => void - close: () => Promise - newContext: (options?: Record) => Promise -} - -type SessionServiceErrorOptions = { - statusCode?: number - errorCode?: string -} - -let browserPromise: Promise | null = null - -export class SessionServiceError extends Error { - statusCode: number - errorCode: string - - constructor(message: string, { statusCode = 500, errorCode = 'session_error' }: SessionServiceErrorOptions = {}) { - super(message) - this.name = 'SessionServiceError' - this.statusCode = statusCode - this.errorCode = errorCode - } -} - -export async function warmupTencentBrowser() { - const browserLaunch = resolveBrowserLaunchOptions() - - if (!browserLaunch.prewarm) { - return { - warmed: false, - reason: 'disabled', - browserLaunch, - } - } - - await ensureBrowser() - - return { - warmed: true, - reason: 'ready', - browserLaunch, - } -} - -export async function ensureBrowser(): Promise { - if (!browserPromise) { - const launchOptions = resolveBrowserLaunchOptions() - browserPromise = (chromium.launch(buildChromiumLaunchOptions(launchOptions)) as Promise) - .then((browser) => { - browser.on('disconnected', () => { - browserPromise = null - }) - return browser - }) - .catch((error) => { - browserPromise = null - throw error - }) - } - - return browserPromise -} - -export function resolveBrowserLaunchOptions() { - return resolveBrowserLaunchOptionsBase({ - browserConfig: runtimeConfig.browser, - chromePath: CHROME_PATH, - }) -} - -export async function closeBrowserIfIdle({ activeSessionCount = 0 }: { activeSessionCount?: number } = {}): Promise { - if (activeSessionCount > 0 || !browserPromise) { - return - } - - if (resolveBrowserLaunchOptions().keepAlive) { - return - } - - try { - const browser = await browserPromise - await browser.close() - } catch { - // ignore browser close failures - } finally { - browserPromise = null - } -} - -function buildChromiumLaunchOptions(launchOptions: ReturnType) { - return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) -} diff --git a/apps/backend/src/services/session/session-service-store.ts b/apps/backend/src/services/session/session-service-store.ts deleted file mode 100644 index 49e28e0c..00000000 --- a/apps/backend/src/services/session/session-service-store.ts +++ /dev/null @@ -1,205 +0,0 @@ -import crypto from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' - -import { - BROWSER_SESSION_DATA_ROOT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, -} from './session-service-config.js' -import { SessionServiceError } from './session-service-runtime.js' -import { - resolveInitialQrSessionState, - SESSION_TTL_MS, -} from './session-state.js' - -type BrowserContextLike = { - close?: () => Promise -} - -export type TencentBrowserSession = Record & { - sessionId: string - loginType: string - status: string - notice: string - createdAt: string - updatedAt: string - expiresAt: number - autoCloseAt: number - autoCloseTimer: ReturnType | null - sessionDir: string - browserContext: any - page: any - browserContextClosed?: boolean - presentationSyncAttempts: number -} - -type RegisterSessionInput = { - loginType: string - browserContext: BrowserContextLike - page: unknown -} - -type CloseSessionDependency = { - closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise -} - -type CloseSessionOptions = { - markClosed: boolean - persistSessionState: (session: TencentBrowserSession) => Promise - closeBrowserIfIdle: () => Promise -} - -type PersistSessionStateOptions = { - buildSessionPayload: (session: TencentBrowserSession) => unknown -} - -const sessions = new Map() - -export async function registerTencentBrowserSession({ - loginType, - browserContext, - page, -}: RegisterSessionInput): Promise { - const sessionId = createSessionId() - const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId) - await fs.mkdir(sessionDir, { recursive: true }) - - const now = new Date().toISOString() - const session = { - sessionId, - loginType, - status: 'created', - notice: '浏览器会话已创建', - createdAt: now, - updatedAt: now, - expiresAt: Date.now() + SESSION_TTL_MS, - autoCloseAt: 0, - autoCloseTimer: null, - sessionDir, - browserContext, - page, - qrImageBase64: '', - qrImagePath: '', - qrUpdatedAt: '', - lastState: null, - lastRedeem: null, - lastReview: null, - lastError: '', - presentationSyncAttempts: 0, - } - - sessions.set(sessionId, session) - return session -} - -export function listActiveTencentBrowserSessions(): TencentBrowserSession[] { - return [...sessions.values()] -} - -export async function getRequiredSession(sessionId: unknown): Promise { - const key = String(sessionId || '').trim() - - if (!key) { - throw new SessionServiceError('缺少 sessionId', { - statusCode: 400, - errorCode: 'missing_session_id', - }) - } - - const session = sessions.get(key) - - if (!session) { - throw new SessionServiceError('浏览器会话不存在或已过期', { - statusCode: 404, - errorCode: 'session_not_found', - }) - } - - return session -} - -export function applyInitialQrSessionState(session: TencentBrowserSession): void { - resetTencentSessionAutoClose(session) - Object.assign(session, resolveInitialQrSessionState(session.loginType)) -} - -export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void { - resetTencentSessionAutoClose(session) - - const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS - session.autoCloseAt = autoCloseAt - session.expiresAt = autoCloseAt - session.autoCloseTimer = setTimeout(() => { - void closeSession(session, { markClosed: true }).catch(() => { - // ignore delayed auto-close failures - }) - }, REDEEM_SUCCESS_AUTO_CLOSE_MS) -} - -export function resetTencentSessionAutoClose(session?: TencentBrowserSession | null): void { - if (session?.autoCloseTimer) { - clearTimeout(session.autoCloseTimer) - session.autoCloseTimer = null - } - - if (session) { - session.autoCloseAt = 0 - } -} - -export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise { - const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now()) - - await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) -} - -export async function closeSession( - session: TencentBrowserSession, - { - markClosed, - persistSessionState, - closeBrowserIfIdle, - }: CloseSessionOptions, -): Promise { - resetTencentSessionAutoClose(session) - sessions.delete(session.sessionId) - - try { - await session.browserContext?.close() - } catch { - // ignore browser context close failures - } - - session.browserContextClosed = true - session.updatedAt = new Date().toISOString() - - if (markClosed) { - session.status = 'closed' - session.notice = '浏览器会话已关闭' - await persistSessionState(session) - } - - await closeBrowserIfIdle() -} - -export async function persistSessionState( - session: TencentBrowserSession, - { buildSessionPayload }: PersistSessionStateOptions, -): Promise { - await fs.mkdir(session.sessionDir, { recursive: true }) - await fs.writeFile( - path.join(session.sessionDir, 'session.json'), - JSON.stringify(buildSessionPayload(session), null, 2), - 'utf8', - ) -} - -function createSessionId(): string { - let sessionId = '' - - do { - sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` - } while (sessions.has(sessionId)) - - return sessionId -} diff --git a/apps/backend/src/services/session/session-state.ts b/apps/backend/src/services/session/session-state.ts deleted file mode 100644 index 9fa29631..00000000 --- a/apps/backend/src/services/session/session-state.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { getLoginTypeLabel, type LoginType } from './session-browser-config.js' - -export const SESSION_TTL_MS = 30 * 60 * 1000 - -type InitialQrSessionStateOptions = { - updatedAt?: string - nowMs?: number - sessionTtlMs?: number - loginTypeLabel?: string -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -type QrState = { - expired?: boolean - scanned?: boolean -} - -type ActivityInfo = { - role?: { - ready?: boolean - roleName?: string - } -} - -type SessionStatusInput = { - hostState: HostState - qrState: QrState - credentialReady?: boolean - activityInfo?: ActivityInfo | null -} - -type SessionNoticeInput = SessionStatusInput & { - loginType: LoginType | string -} - -type CookieMap = { - openid?: string - access_token?: string - appid?: string -} - -export function resolveInitialQrSessionState( - loginType: LoginType | string, - { - updatedAt = new Date().toISOString(), - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, - loginTypeLabel = getLoginTypeLabel(loginType), - }: InitialQrSessionStateOptions = {}, -) { - return { - status: 'waiting_scan', - notice: `请使用${loginTypeLabel}扫码登录`, - updatedAt, - expiresAt: nowMs + sessionTtlMs, - } -} - -export function resolveTencentSessionExpiresAt( - session: { autoCloseAt?: number | string } | null | undefined, - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, -): number { - const autoCloseAt = Number(session?.autoCloseAt || 0) - if (autoCloseAt > 0) { - return autoCloseAt - } - - return nowMs + sessionTtlMs -} - -export function shouldRefreshLoggedInPresentation(hostState: HostState): boolean { - const loginText = String(hostState?.loginText || '').trim() - - return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText) -} - -export function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }: SessionStatusInput): string { - if (hostState.loginedVisible || credentialReady) { - if (credentialReady && activityInfo?.role?.ready) { - return 'ready_to_redeem' - } - - return 'logged_in' - } - - if (qrState.expired) { - return 'expired' - } - - if (qrState.scanned) { - return 'scanned' - } - - return 'waiting_scan' -} - -export function resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType, -}: SessionNoticeInput): string { - const loginTypeLabel = getLoginTypeLabel(loginType) - - if (credentialReady) { - if (activityInfo?.role?.ready && activityInfo.role.roleName) { - return `登录已完成,当前角色 ${activityInfo.role.roleName}` - } - - return '登录已完成,正在同步角色信息' - } - - if (hostState.loginedVisible) { - return '页面已识别登录态' - } - - if (qrState.expired) { - return '二维码已失效,准备刷新' - } - - if (qrState.scanned) { - return '二维码已扫描,请在手机上确认登录' - } - - return `请使用${loginTypeLabel}扫码登录` -} - -export function hasRedeemCredential(cookieMap: CookieMap): boolean { - return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid) -} diff --git a/apps/backend/src/services/session/session-wx.ts b/apps/backend/src/services/session/session-wx.ts deleted file mode 100644 index e66fcab7..00000000 --- a/apps/backend/src/services/session/session-wx.ts +++ /dev/null @@ -1,377 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -type LocatorLike = { - first: () => LocatorLike - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - isVisible: () => Promise - click: (options?: { timeout?: number }) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type WxLoginViewState = { - qrVisible: boolean - quickLoginVisible: boolean - switchToNormalVisible: boolean - qrCandidates: Array<{ - index: number - visible: boolean - src: string - naturalWidth: number - }> - bodyText: string -} - -type WxQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureWxLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - for (let attempt = 1; attempt <= 12; attempt += 1) { - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.ensureQrMode.viewState', { - attempt, - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - quickLoginVisible: viewState.quickLoginVisible, - switchToNormalVisible: viewState.switchToNormalVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - if (viewState.qrVisible) { - return - } - - if ( - viewState.quickLoginVisible || - viewState.switchToNormalVisible || - /使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText) - ) { - const switched = await switchWxQuickLoginToQr(frame) - logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', { - attempt, - switched, - }) - - if (switched) { - await frame.waitForTimeout(700) - continue - } - } - - await frame.waitForTimeout(400) - } - } catch { - // ignore wx quick-login switch failures; the screenshot retry path will try again - } -} - -export async function captureWxQrImage(page: PageLike, qrImagePath: string): Promise { - const qrLocator = await findWxQrLocator(page) - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - const qrUrl = await resolveWxQrImageUrl(frame) - logBrowserSessionDebug('wx.capture.qrUrl', { - qrUrl, - frameUrl: frame.url(), - }) - - if (qrUrl) { - const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx') - logBrowserSessionDebug('wx.capture.downloadResult', { - qrUrl, - downloaded: Buffer.isBuffer(downloaded) && downloaded.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(downloaded) && downloaded.length > 0) { - await fs.writeFile(qrImagePath, downloaded) - return - } - } - - logBrowserSessionDebug('wx.capture.fallbackScreenshot', { - qrImagePath, - }) - await qrLocator.screenshot({ path: qrImagePath }) -} - -export async function findWxQrLocator(page: PageLike): Promise { - await ensureWxLoginReady(page) - - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - ) - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.findQrLocator.beforeWait', { - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - const locator = frame.locator('.js_qrcode_img:visible').first() - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('wx.findQrLocator.visibleReady', { - frameUrl: frame.url(), - }) - return locator -} - -export async function extractWxQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const viewState = await inspectWxLoginView(frame) - const qrVisible = viewState.qrVisible - const bodyText = viewState.bodyText - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function inspectWxLoginView(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const quickLogin = document.querySelector('.js_quick_login') - const switchToNormal = document.querySelector('.js_switchToNormal') - const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => { - if (!(item instanceof HTMLImageElement)) { - return { - index, - visible: false, - src: '', - naturalWidth: 0, - } - } - - return { - index, - visible: isVisible(item), - src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(), - naturalWidth: Number(item.naturalWidth || 0), - } - }) - const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0) - - return { - qrVisible: Boolean(visibleQrCandidate), - quickLoginVisible: isVisible(quickLogin), - switchToNormalVisible: isVisible(switchToNormal), - qrCandidates, - bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(), - } - }) - .catch(() => ({ - qrVisible: false, - quickLoginVisible: false, - switchToNormalVisible: false, - qrCandidates: [], - bodyText: '', - })) -} - -async function switchWxQuickLoginToQr(frame: FrameLike): Promise { - const switcherCandidates = [ - frame.locator('.js_switchToNormal:visible').first(), - frame.locator('.js_switchToNormal').first(), - frame.locator('button:has-text("使用其他头像、昵称或账号")').first(), - frame.locator('text=使用其他头像、昵称或账号').first(), - ] - - for (const candidate of switcherCandidates) { - const visible = await candidate.isVisible().catch(() => false) - - if (!visible) { - continue - } - - logBrowserSessionDebug('wx.switchToNormal.candidateVisible', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - - const clicked = - (await candidate - .click({ timeout: 2_000 }) - .then(() => true) - .catch(() => false)) || - (await candidate - .evaluate((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - element.click() - return true - }) - .catch(() => false)) - - if (clicked) { - logBrowserSessionDebug('wx.switchToNormal.clicked', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - return true - } - } - - return frame - .evaluate(() => { - const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const text = String(element.textContent || '').replace(/\s+/g, ' ').trim() - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - - return ( - /使用其他头像、昵称或账号/.test(text) && - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - }) - - const target = candidates[0] - - if (!(target instanceof HTMLElement)) { - return false - } - - target.click() - return true - }) - .catch(() => false) -} - -async function resolveWxQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img')) - const qrImage = - qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) || - qrImages.find((item) => item instanceof HTMLImageElement) || - null - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} diff --git a/apps/backend/src/services/session/session.test.ts b/apps/backend/src/services/session/session.test.ts deleted file mode 100644 index c5143d8e..00000000 --- a/apps/backend/src/services/session/session.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - normalizeLoginType, - parseViewportSpec, -} from './session-browser-config.js' -import { normalizeTencentCookieMap } from './session-cookie-map.js' -import { - createEmptyActivityInfo, - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - captureQrImageWithRetry, - ensureLoginTab, -} from './session-page.js' -import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js' -import { ensureReviewScreenshot } from './session-review.js' -import { resolveInitialQrSessionState } from './session-state.js' -import { - REDEEM_SUCCESS_AUTO_CLOSE_MS, - resolveTencentSessionExpiresAt, -} from './session.js' - -test('resolveTencentSessionExpiresAt uses normal ttl when no auto-close is armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt: 0 }, nowMs), - nowMs + (30 * 60 * 1000), - ) -}) - -test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - const autoCloseAt = nowMs + REDEEM_SUCCESS_AUTO_CLOSE_MS - - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt }, nowMs + 60_000), - autoCloseAt, - ) -}) - -test('normalizeLoginType collapses aliases and falls back to qq', () => { - assert.equal(normalizeLoginType(' qc '), 'qq') - assert.equal(normalizeLoginType('wechat'), 'wx') - assert.equal(normalizeLoginType('unknown'), 'qq') - assert.equal(normalizeLoginType(''), 'qq') -}) - -test('parseViewportSpec accepts valid viewport strings and rejects invalid values', () => { - assert.deepEqual(parseViewportSpec('1920x1080'), { width: 1920, height: 1080 }) - assert.deepEqual(parseViewportSpec('1920x1080x24'), { width: 1920, height: 1080 }) - assert.equal(parseViewportSpec('799x600'), null) - assert.equal(parseViewportSpec('broken'), null) -}) - -test('buildRedeemPayload strips internal paths unless explicitly requested', () => { - const redeem = { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - } - - assert.deepEqual(buildRedeemPayload(redeem), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - }) - - assert.deepEqual(buildRedeemPayload(redeem, { includeInternalPaths: true }), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - }) -}) - -test('buildArtifactsPayload only exposes internal artifact paths in debug mode', () => { - const session = { - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - qrImageBase64: 'base64data', - lastReview: { - screenshotPath: '/tmp/session/review.png', - }, - lastRedeem: { - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - }, - } - - assert.deepEqual(buildArtifactsPayload(session), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - }) - - assert.deepEqual(buildArtifactsPayload(session, { includeInternalPaths: true }), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - reviewScreenshotPath: '/tmp/session/review.png', - }) -}) - -test('normalizeTencentCookieMap fills required defaults and derived proxy cookie', () => { - assert.deepEqual( - normalizeTencentCookieMap([ - { name: 'openid', value: 'openid-1' }, - { name: 'access_token', value: 'token-1' }, - { name: '', value: 'ignored' }, - ]), - { - openid: 'openid-1', - access_token: 'token-1', - acctype: 'qc', - appid: '101491592', - iegams_milo_proxylogin_qc: '101491592_$$_openid-1_$$_token-1', - }, - ) -}) - -test('resolveInitialQrSessionState creates waiting-scan snapshot with ttl', () => { - assert.deepEqual( - resolveInitialQrSessionState('wx', { - updatedAt: '2026-04-14T09:30:00.000Z', - nowMs: 1_700_000_000_000, - sessionTtlMs: 1_800_000, - }), - { - status: 'waiting_scan', - notice: '请使用微信扫码登录', - updatedAt: '2026-04-14T09:30:00.000Z', - expiresAt: 1_700_001_800_000, - }, - ) -}) - -test('ensureReviewScreenshot writes screenshot state and skips unchanged signature', async () => { - const screenshotCalls = [] - const session = { - sessionId: 'txbs-demo', - loginType: 'qq', - sessionDir: '/tmp/session-demo', - page: { - async screenshot(options) { - screenshotCalls.push(options) - }, - }, - lastReview: null, - } - - const activityInfo = { - role: { - roleId: '1001', - roleName: '测试角色', - }, - } - - assert.equal(await ensureReviewScreenshot(session, activityInfo), true) - assert.equal(screenshotCalls.length, 1) - assert.equal(session.lastReview.roleId, '1001') - assert.equal(session.lastReview.roleName, '测试角色') - assert.equal(session.lastReview.signature, 'qq|1001|测试角色') - assert.match(session.lastReview.screenshotPath, /role-review\.png$/) - - assert.equal(await ensureReviewScreenshot(session, activityInfo), false) - assert.equal(screenshotCalls.length, 1) -}) - -test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => { - const calls = [] - const makeLocator = (name) => ({ - async count() { - return 1 - }, - first() { - const firstLocator = { - async count() { - return 1 - }, - async click(options) { - calls.push({ type: 'click', name, options }) - }, - } - return firstLocator - }, - }) - - const page = { - locator(selector) { - if (selector === '#unlogin') { - return makeLocator('loginButton') - } - if (selector === '.qc-tab') { - return makeLocator('qqTab') - } - throw new Error(`unexpected selector: ${selector}`) - }, - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - await ensureLoginTab(page, 'qq', { - async ensureQqLoginReady() { - calls.push({ type: 'ensure', name: 'qq' }) - }, - }) - - assert.deepEqual(calls, [ - { type: 'click', name: 'loginButton', options: { timeout: 2_000 } }, - { type: 'click', name: 'qqTab', options: { timeout: 2_000 } }, - { type: 'wait', ms: 250 }, - { type: 'ensure', name: 'qq' }, - ]) -}) - -test('captureQrImageWithRetry retries once after retryable error', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - let attempt = 0 - await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', { - async captureWxQrImage() { - attempt += 1 - calls.push({ type: 'capture', attempt }) - if (attempt === 1) { - throw new Error('Timeout while waiting for qr') - } - }, - async ensureLoginTab(_page, loginType) { - calls.push({ type: 'ensureLoginTab', loginType }) - }, - isRetryableQrCaptureError(error) { - return String(error.message).includes('Timeout') - }, - logBrowserSessionDebug() {}, - }) - - assert.equal(attempt, 2) - assert.deepEqual(calls, [ - { type: 'capture', attempt: 1 }, - { type: 'wait', ms: 500 }, - { type: 'ensureLoginTab', loginType: 'wx' }, - { type: 'capture', attempt: 2 }, - ]) -}) - -test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => { - const session = { - page: { marker: 'page' }, - presentationSyncAttempts: 0, - } - const calls = [] - let hostCall = 0 - - const result = await ensureLoggedInPresentation(session, { - credentialReady: true, - activityUrl: 'https://example.com/activity', - async extractHostState() { - hostCall += 1 - return hostCall === 1 - ? { unloginVisible: true, loginedVisible: false, loginText: '' } - : { unloginVisible: false, loginedVisible: true, loginText: '已登录' } - }, - async reloadActivityPageForPresentation(_page, activityUrl) { - calls.push(activityUrl) - }, - }) - - assert.equal(session.presentationSyncAttempts, 1) - assert.deepEqual(calls, ['https://example.com/activity']) - assert.deepEqual(result, { - reloaded: true, - hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }, - }) -}) - -test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - const snapshots = [ - { role: { ready: false, roleName: '' } }, - { role: { ready: true, roleName: '测试角色' } }, - ] - let nowValue = 0 - - const result = await ensureActivityInfoReady(page, { - triggerRender: true, - timeoutMs: 1000, - async triggerActivityRoleRender() { - calls.push({ type: 'trigger' }) - }, - async extractActivityInfo() { - calls.push({ type: 'extract' }) - return snapshots.shift() - }, - now() { - nowValue += 300 - return nowValue - }, - }) - - assert.deepEqual(calls, [ - { type: 'trigger' }, - { type: 'extract' }, - { type: 'wait', ms: 400 }, - { type: 'extract' }, - ]) - assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } }) -}) - -test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => { - const page = { - async evaluate() { - throw new Error('page closed') - }, - } - - assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo()) -}) diff --git a/apps/backend/src/services/session/session.ts b/apps/backend/src/services/session/session.ts deleted file mode 100644 index 928495b0..00000000 --- a/apps/backend/src/services/session/session.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { - getLoginTypeLabel, - normalizeLoginType, -} from './session-browser-config.js' -import { logBrowserSessionDebug } from './session-login-shared.js' -import { - loadCookieMapFromContext, -} from './session-cookie-map.js' -import { - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - buildSessionPayload as buildSessionPayloadBase, -} from './session-payload.js' -import { - captureSessionQr, - ensureLoginTab, - extractHostState, - extractQrState, - reloadActivityPageForPresentation, -} from './session-page.js' -import { - saveRedeemArtifacts, -} from './session-proof.js' -import { - ensureReviewScreenshot, -} from './session-review.js' -import { - capturePageCaptchaForOcr, - isCaptchaRejectedResult, - prepareRedeemCodeFill, - refreshPageCaptcha, - runTencentBrowserRedeem, - submitRedeemInBrowser, -} from './session-redeem.js' -import { - ensureWxLoginReady, -} from './session-wx.js' -import { recognizeTencentCaptcha } from './ocr.js' -import { - hasRedeemCredential, - resolveInitialQrSessionState, - resolveSessionNotice, - resolveSessionStatus, - resolveTencentSessionExpiresAt, -} from './session-state.js' -import { - ACTIVITY_URL, - DEFAULT_VIEWPORT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, - SESSION_DEBUG_ENABLED, -} from './session-service-config.js' -import { - closeBrowserIfIdle, - ensureBrowser, - resolveBrowserLaunchOptions, - SessionServiceError, - warmupTencentBrowser, -} from './session-service-runtime.js' -import { - applyInitialQrSessionState, - armRedeemedSessionAutoClose, - cleanupExpiredTencentBrowserSessions, - closeSession as closeStoredSession, - getRequiredSession, - listActiveTencentBrowserSessions, - persistSessionState as persistStoredSessionState, - registerTencentBrowserSession, - resetTencentSessionAutoClose, -} from './session-service-store.js' -import type { TencentBrowserSession } from './session-service-store.js' - -type JsonObject = Record - -type IncludeQrImageOptions = { - includeQrImage?: boolean -} - -type CloseAllSessionsOptions = { - markClosed?: boolean -} - -type CloseManagedSessionOptions = { - markClosed: boolean -} - -export { resolveTencentSessionExpiresAt } -export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser } - -export async function createTencentBrowserSession(payload: JsonObject = {}): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const loginType = normalizeLoginType(payload.loginType || payload.qrType) - - const browser = await ensureBrowser() - const browserContext = await browser.newContext({ - viewport: DEFAULT_VIEWPORT, - deviceScaleFactor: 1, - }) - - const page = await browserContext.newPage() - page.setDefaultTimeout(20_000) - - const session = await registerTencentBrowserSession({ - loginType, - browserContext, - page, - }) - const sessionId = session.sessionId - - try { - await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) - await ensureLoginTab(page, loginType) - await captureSessionQr(session, { ensureLoginReady: false }) - applyInitialQrSessionState(session) - await persistSessionState(session) - void refreshTencentBrowserSession(sessionId).catch((error) => { - logBrowserSessionDebug('createSession.backgroundRefresh.error', { - sessionId, - message: error instanceof Error ? error.message : String(error), - }) - }) - return buildSessionPayload(session) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `${getLoginTypeLabel(loginType)} 浏览器会话初始化失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function getTencentBrowserSession( - sessionId: unknown, - options: IncludeQrImageOptions = {}, -): Promise { - return refreshTencentBrowserSession(sessionId, options) -} - -export async function getTencentBrowserSessionSummary(sessionId: unknown): Promise { - return refreshTencentBrowserSession(sessionId, { includeQrImage: false }) -} - -export async function reloadTencentBrowserSession(sessionId: unknown): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (!credentialReady) { - session.qrImageBase64 = '' - session.qrImagePath = '' - session.qrUpdatedAt = '' - await ensureLoginTab(session.page, session.loginType) - await captureSessionQr(session) - } - - session.notice = credentialReady - ? '后端页面已刷新,正在同步最新状态' - : `已刷新${getLoginTypeLabel(session.loginType)}登录页,准备重新抓取二维码` - session.updatedAt = new Date().toISOString() - - return await refreshTencentBrowserSession(sessionId) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '刷新后端页面失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function refreshTencentBrowserSession( - sessionId: unknown, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - let hostState = await extractHostState(session.page) - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (credentialReady) { - const presentation = await ensureLoggedInPresentation(session, { - hostState, - credentialReady, - activityUrl: ACTIVITY_URL, - }) - hostState = presentation.hostState - } else { - session.presentationSyncAttempts = 0 - } - - if (!credentialReady && session.loginType === 'wx') { - await ensureWxLoginReady(session.page) - } - - const qrState = await extractQrState(session.page, session.loginType) - const activityInfo = credentialReady - ? await ensureActivityInfoReady(session.page, { triggerRender: true }) - : await extractActivityInfo(session.page) - - session.lastState = { - hostState, - qrState, - credentialReady, - activityInfo, - cookieKeys: Object.keys(cookieMap), - } - session.status = resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) - session.notice = resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType: session.loginType, - }) - - if (credentialReady && activityInfo?.role?.ready) { - await ensureReviewScreenshot(session, activityInfo) - } - - session.updatedAt = new Date().toISOString() - session.expiresAt = resolveTencentSessionExpiresAt(session) - - if (!session.qrImageBase64 || session.status === 'expired') { - if (session.status === 'expired') { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - await ensureLoginTab(session.page, session.loginType) - } - - await captureSessionQr(session) - if (session.status === 'expired') { - session.notice = '二维码已刷新,请重新扫码' - session.status = 'waiting_scan' - } - } - - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `查询 ${getLoginTypeLabel(session.loginType)} 浏览器会话状态失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } -} - -export async function redeemTencentBrowserSession(sessionId: unknown, payload: JsonObject = {}): Promise { - const session = await getRequiredSession(sessionId) - const code = String(payload.code || payload.sCdKey || '').trim() - const maxAttempts = Math.max(1, Math.min(Number(payload.maxAttempts || 6), 12)) - - if (!code) { - throw new SessionServiceError('缺少兑换码', { - statusCode: 400, - errorCode: 'missing_redeem_code', - }) - } - - const refreshed = await refreshTencentBrowserSession(sessionId) - - if (!['logged_in', 'ready_to_redeem', 'redeemed'].includes(refreshed.status)) { - throw new SessionServiceError(`当前会话还不能兑换,状态为 ${refreshed.status}`, { - statusCode: 409, - errorCode: 'session_not_ready', - }) - } - - resetTencentSessionAutoClose(session) - - await runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation: (redeemSession, options) => - ensureLoggedInPresentation(redeemSession as any, options as any), - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr: (page, options) => - capturePageCaptchaForOcr(page, { ...options, ensureActivityInfoReady }), - recognizeTencentCaptcha, - submitRedeemInBrowser: (page, redeemPayload) => - submitRedeemInBrowser(page, redeemPayload, { ensureActivityInfoReady }), - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl: ACTIVITY_URL, - }) - - armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession }) - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } -} - -export async function closeTencentBrowserSession(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - await closeManagedSession(session, { markClosed: true }) - return { - sessionId, - closed: true, - } -} - -export async function closeAllTencentBrowserSessions( - { markClosed = true }: CloseAllSessionsOptions = {}, -): Promise { - const activeSessions = listActiveTencentBrowserSessions() - - await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed }))) - - return { - closedCount: activeSessions.length, - } -} - -export async function getTencentBrowserSessionScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastRedeem?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有兑换截图', { - statusCode: 404, - errorCode: 'screenshot_not_ready', - }) - } - - return screenshotPath -} - -export async function getTencentBrowserSessionReviewScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastReview?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有客服复核截图', { - statusCode: 404, - errorCode: 'review_screenshot_not_ready', - }) - } - - return screenshotPath -} - -async function persistSessionState(session: TencentBrowserSession): Promise { - await persistStoredSessionState(session, { buildSessionPayload }) -} - -function buildSessionPayload( - session: TencentBrowserSession, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): JsonObject { - return buildSessionPayloadBase(session, { - activityUrl: ACTIVITY_URL, - includeQrImage, - sessionDebugEnabled: SESSION_DEBUG_ENABLED, - browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null, - }) -} - -async function fillRedeemCodeInBrowser(page: any, code: string): Promise { - await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) -} - -async function closeManagedSession( - session: TencentBrowserSession, - { markClosed }: CloseManagedSessionOptions, -): Promise { - await closeStoredSession(session, { - markClosed, - persistSessionState, - closeBrowserIfIdle: () => closeBrowserIfIdle({ - activeSessionCount: listActiveTencentBrowserSessions().length, - }), - }) -} diff --git a/apps/backend/src/startup/bootstrap-kuaishou.ts b/apps/backend/src/startup/bootstrap-kuaishou.ts deleted file mode 100644 index f14d45a0..00000000 --- a/apps/backend/src/startup/bootstrap-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { runDatabaseMigrations } from "../db/migrate.js"; -import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; -import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; -import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { logError, logInfo } from "../utils/logger.js"; -import { formatStartupError, type StartupState } from "./state.js"; - -const CORE_BOOT_RETRY_DELAY_MS = 5_000; - -export async function bootstrapKuaishouCoreServices( - startupState: StartupState, - isShutdownStarted: () => boolean -) { - if (startupState.core.running || isShutdownStarted()) { - return; - } - - startupState.core.running = true; - startupState.browser.status = "skipped"; - startupState.browser.message = "kuaishou lite mode"; - startupState.ocr.status = "skipped"; - startupState.ocr.message = "kuaishou lite mode"; - - while (!isShutdownStarted() && !startupState.core.ready) { - startupState.phase = - startupState.core.attemptCount === 0 ? "starting" : "retrying"; - startupState.core.attemptCount += 1; - startupState.core.lastAttemptAt = new Date().toISOString(); - - try { - logInfo("[startup:kuaishou]", "开始执行快手轻量核心启动步骤", { - attempt: startupState.core.attemptCount, - }); - - await runDatabaseMigrations(); - await ensureFulfillmentCatalogBootstrapped(); - await ensureAdminUsersBootstrapped(); - - startupState.core.ready = true; - startupState.core.lastError = ""; - startupState.phase = "ready"; - startupState.core.readyAt = new Date().toISOString(); - - logInfo("[startup:kuaishou]", "快手轻量核心启动步骤完成,服务已就绪", { - attempt: startupState.core.attemptCount, - }); - - startScheduledJobs(); - break; - } catch (error) { - const message = formatStartupError(error); - startupState.phase = "retrying"; - startupState.core.lastError = message; - logError("[startup:kuaishou]", "快手轻量核心启动步骤失败,将自动重试", { - attempt: startupState.core.attemptCount, - retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, - error, - }); - - await sleep(CORE_BOOT_RETRY_DELAY_MS); - } - } - - startupState.core.running = false; -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index 4d831656..2dc932b9 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -1,13 +1,8 @@ -import process from "node:process"; - -import { runtimeConfig } from "../config/runtime.js"; import { runDatabaseMigrations } from "../db/migrate.js"; import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { warmupOcrService } from "../services/session/ocr.js"; -import { warmupTencentBrowser } from "../services/session/session.js"; -import { logError, logInfo, logWarn } from "../utils/logger.js"; +import { logError, logInfo } from "../utils/logger.js"; import { formatStartupError, type StartupState } from "./state.js"; const CORE_BOOT_RETRY_DELAY_MS = 5_000; @@ -21,14 +16,19 @@ export async function bootstrapCoreServices( } startupState.core.running = true; + startupState.browser.status = "skipped"; + startupState.browser.message = "kuaishou lite mode"; + startupState.ocr.status = "skipped"; + startupState.ocr.message = "kuaishou lite mode"; while (!isShutdownStarted() && !startupState.core.ready) { - startupState.phase = startupState.core.attemptCount === 0 ? "starting" : "retrying"; + startupState.phase = + startupState.core.attemptCount === 0 ? "starting" : "retrying"; startupState.core.attemptCount += 1; startupState.core.lastAttemptAt = new Date().toISOString(); try { - logInfo("[startup]", "开始执行核心启动步骤", { + logInfo("[startup]", "开始执行快手核心启动步骤", { attempt: startupState.core.attemptCount, }); @@ -41,19 +41,17 @@ export async function bootstrapCoreServices( startupState.phase = "ready"; startupState.core.readyAt = new Date().toISOString(); - logInfo("[startup]", "核心启动步骤完成,服务已就绪", { + logInfo("[startup]", "快手核心启动步骤完成,服务已就绪", { attempt: startupState.core.attemptCount, }); startScheduledJobs(); - void bootstrapBrowser(startupState); - void bootstrapOcr(startupState); break; } catch (error) { const message = formatStartupError(error); startupState.phase = "retrying"; startupState.core.lastError = message; - logError("[startup]", "核心启动步骤失败,将自动重试", { + logError("[startup]", "快手核心启动步骤失败,将自动重试", { attempt: startupState.core.attemptCount, retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, error, @@ -66,87 +64,6 @@ export async function bootstrapCoreServices( startupState.core.running = false; } -async function bootstrapBrowser(startupState: StartupState) { - startupState.browser.status = "starting"; - startupState.browser.lastAttemptAt = new Date().toISOString(); - - try { - const result = await warmupTencentBrowser(); - - if (!result.warmed) { - startupState.browser.status = "skipped"; - startupState.browser.message = "browser prewarm skipped"; - logInfo("[startup]", "browser prewarm skipped"); - return; - } - - startupState.browser.status = "ready"; - startupState.browser.message = "browser prewarm ready"; - logInfo("[startup]", "browser prewarm ready"); - } catch (error) { - if (isMissingPlaywrightBrowserError(error)) { - const installCommand = resolveBrowserInstallCommand(); - startupState.browser.status = "degraded"; - startupState.browser.message = formatErrorMessage(error); - logWarn("[startup]", `browser prewarm skipped: ${formatErrorMessage(error)}`); - logWarn("[startup]", `请先安装浏览器依赖: ${installCommand}`); - - if (String(runtimeConfig.browser.chromePath || "").trim()) { - logWarn("[startup]", "当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在"); - } - - return; - } - - startupState.browser.status = "degraded"; - startupState.browser.message = formatStartupError(error); - logError("[startup]", "browser prewarm failed", error); - } -} - -async function bootstrapOcr(startupState: StartupState) { - startupState.ocr.status = "starting"; - startupState.ocr.lastAttemptAt = new Date().toISOString(); - - try { - await warmupOcrService(); - startupState.ocr.status = "ready"; - startupState.ocr.message = "OCR service ready"; - logInfo("[startup]", "OCR service ready"); - } catch (error) { - startupState.ocr.status = "degraded"; - startupState.ocr.message = formatStartupError(error); - logWarn("[startup]", `OCR service skipped: ${formatStartupError(error)}`); - } -} - -function isMissingPlaywrightBrowserError(error: unknown) { - const message = formatErrorMessage(error).toLowerCase(); - - return ( - message.includes("executable doesn't exist") || - message.includes("please run the following command to download new browsers") || - (message.includes("browserType.launch".toLowerCase()) && - message.includes("please run")) - ); -} - -function formatErrorMessage(error: unknown) { - if (error instanceof Error && error.message) { - return error.message.split("\n")[0].trim(); - } - - return String(error || "未知错误").trim(); -} - -function resolveBrowserInstallCommand() { - if (process.platform === "linux") { - return "npm run browser:install:linux"; - } - - return "npm run browser:install"; -} - function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/apps/backend/src/startup/shutdown-kuaishou.ts b/apps/backend/src/startup/shutdown-kuaishou.ts deleted file mode 100644 index dd3f60e1..00000000 --- a/apps/backend/src/startup/shutdown-kuaishou.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Server } from "node:http"; - -import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { logError, logInfo } from "../utils/logger.js"; -import type { StartupState } from "./state.js"; - -export function createKuaishouShutdownController( - server: Server, - startupState: StartupState -) { - let shutdownStarted = false; - - async function shutdown(signal: string) { - if (shutdownStarted) { - return; - } - - shutdownStarted = true; - startupState.phase = "shutting_down"; - logInfo("[shutdown:kuaishou]", `received ${signal}, closing HTTP server`); - - stopScheduledJobs(); - - if (!server.listening) { - return; - } - - await new Promise((resolve) => { - server.close((error) => { - if (error) { - logError("[shutdown:kuaishou]", "failed to close HTTP server", error); - process.exitCode = 1; - } - - resolve(); - }); - }); - } - - return { - isShutdownStarted: () => shutdownStarted, - shutdown, - }; -} diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts index e87c36c4..2ee3134a 100644 --- a/apps/backend/src/startup/shutdown.ts +++ b/apps/backend/src/startup/shutdown.ts @@ -1,12 +1,13 @@ import type { Server } from "node:http"; import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { closeOcrService } from "../services/session/ocr.js"; -import { closeAllTencentBrowserSessions } from "../services/session/session.js"; import { logError, logInfo } from "../utils/logger.js"; import type { StartupState } from "./state.js"; -export function createShutdownController(server: Server, startupState: StartupState) { +export function createShutdownController( + server: Server, + startupState: StartupState +) { let shutdownStarted = false; async function shutdown(signal: string) { @@ -16,22 +17,10 @@ export function createShutdownController(server: Server, startupState: StartupSt shutdownStarted = true; startupState.phase = "shutting_down"; - logInfo("[shutdown]", `received ${signal}, closing browser sessions and HTTP server`); + logInfo("[shutdown]", `received ${signal}, closing HTTP server`); stopScheduledJobs(); - try { - await closeAllTencentBrowserSessions({ markClosed: true }); - } catch (error) { - logError("[shutdown]", "failed to close browser sessions", error); - } - - try { - await closeOcrService(); - } catch (error) { - logError("[shutdown]", "failed to close OCR service", error); - } - if (!server.listening) { return; } diff --git a/apps/backend/src/types/admin-read-models.ts b/apps/backend/src/types/admin-read-models.ts index 582597f1..71e1aab2 100644 --- a/apps/backend/src/types/admin-read-models.ts +++ b/apps/backend/src/types/admin-read-models.ts @@ -6,17 +6,6 @@ export type AdminTaskBindingSummary = { roleKeys: string[] } -export type AdminAgisoAutoDeliveryStatus = { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - export type AdminOrderListItem = { orderId: number provider: string @@ -78,7 +67,6 @@ export type AdminTaskListItem = { inventoryCredentialType: string claimToken: string screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export type AdminInventoryItemListItem = { diff --git a/apps/backend/src/types/admin-route-inputs.ts b/apps/backend/src/types/admin-route-inputs.ts index 328ee9be..114b55e0 100644 --- a/apps/backend/src/types/admin-route-inputs.ts +++ b/apps/backend/src/types/admin-route-inputs.ts @@ -8,7 +8,6 @@ import type { AdminWebhookEventListQueryInput, } from './admin-read-inputs.js' import type { - AdminAgisoShopConfigSaveInput, AdminCloudtentaclesCatalogQueryInput, AdminCloudtentaclesFullFlowInput, AdminCloudtentaclesSendSmsCodeInput, @@ -18,8 +17,6 @@ import type { AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesValidateSessionInput, AdminCloudtentaclesVirtualNumberInput, - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, AdminInventoryCreateInput, AdminInventoryImportInput, AdminInventoryInvalidateInput, @@ -28,7 +25,6 @@ import type { AdminKuaishouEticketDetailQueryInput, AdminKuaishouEticketShopInfoInput, AdminKuaishouEticketSourceConfigInput, - AdminManualRedeemCreateInput, AdminNotificationConfigInput, AdminNotificationTestInput, AdminScheduledJobsConfigInput, @@ -49,7 +45,6 @@ export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSour export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput -export type AdminAgisoShopConfigRouteBody = AdminAgisoShopConfigSaveInput export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetailQueryInput export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput @@ -63,20 +58,16 @@ export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput -export type AdminFulfillmentBindingConfigRouteBody = AdminFulfillmentBindingConfigSaveInput -export type AdminFulfillmentBindingLookupRouteBody = AdminFulfillmentBindingLookupInput export type AdminInventoryCreateRouteBody = AdminInventoryCreateInput export type AdminInventoryImportRouteBody = AdminInventoryImportInput export type AdminInventoryInvalidateRouteBody = AdminInventoryInvalidateInput export type AdminUserInventoryGroupsRouteBody = AdminUserInventoryGroupsInput export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput -export type AdminManualRedeemCreateRouteBody = AdminManualRedeemCreateInput export type AdminInventoryRouteParams = { inventoryItemId?: string } export type AdminOrderRouteParams = { orderId?: string } export type AdminTaskRouteParams = { taskId?: string, bindingId?: string } -export type AdminManualRedeemRouteParams = { taskId?: string } export type AdminWebhookEventRouteParams = { eventId?: string } export type AdminEntityRouteParams = { id?: string } export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string } diff --git a/apps/backend/src/types/admin-write-inputs.ts b/apps/backend/src/types/admin-write-inputs.ts index 2abaca02..3fb27fc5 100644 --- a/apps/backend/src/types/admin-write-inputs.ts +++ b/apps/backend/src/types/admin-write-inputs.ts @@ -27,28 +27,6 @@ export type AdminUserInventoryGroupsInput = { inventoryGroupCodes?: string[] } -export type AdminAgisoMessagingDefaultsInput = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AdminAgisoShopConfigWriteItemInput = { - shopId?: string - shopName?: string - accessToken?: string - enabled?: boolean | null - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string -} - -export type AdminAgisoShopConfigSaveInput = { - defaults?: AdminAgisoMessagingDefaultsInput - shops?: AdminAgisoShopConfigWriteItemInput[] -} - export type AdminKuaishouCloudFulfillmentItemInput = { id?: string enabled?: boolean @@ -271,36 +249,6 @@ export type AdminCloudtentaclesFullFlowInput = { vnKey?: string } -export type AdminFulfillmentBindingLookupInput = { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string -} - -export type AdminFulfillmentBindingConfigWriteItemInput = { - provider?: string - platform?: string - shopId?: string - shopName?: string - skuCode?: string - skuName?: string - profileKey?: string - enabled?: boolean - priority?: number | string - config?: Record - match?: { - externalSkuCode?: string - externalItemId?: string - externalSkuName?: string - config?: Record - } -} - -export type AdminFulfillmentBindingConfigSaveInput = { - bindings?: AdminFulfillmentBindingConfigWriteItemInput[] -} - export type AdminInventoryInvalidateInput = { reason?: string } @@ -315,10 +263,3 @@ export type AdminTaskManualDispatchInput = { export type AdminTaskKuaishouCloudDispatchInput = { ticketCode?: string } - -export type AdminManualRedeemCreateInput = { - proofValue?: string - skuCode?: string - skuName?: string - remark?: string -} diff --git a/apps/backend/src/types/admin-write-models.ts b/apps/backend/src/types/admin-write-models.ts index 674d86ad..150f1f04 100644 --- a/apps/backend/src/types/admin-write-models.ts +++ b/apps/backend/src/types/admin-write-models.ts @@ -19,30 +19,6 @@ export type AdminInventoryGroupListResponse = { items: AdminInventorySkuSuggestion[] } -export type AdminAgisoMessagingDefaults = { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export type AdminAgisoShopConfigItem = { - shopId: string - shopName: string - accessToken: string - accessTokenMasked: string - enabled: boolean | null - messageTemplate: string - autoDeliveryMessageTemplate: string - appSecretConfigured: boolean - apiVersion: string - sendMessageEndpoint: string -} - -export type AdminAgisoShopConfigSaveResponse = { - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] -} - export type AdminWebhookReplayResponse = { eventId: number replayed: boolean diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index d14a32c4..cb953d73 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -5,41 +5,10 @@ export type AdminDefaultUser = { status?: string; }; -export type AgisoMessagingShopConfig = { - enabled?: boolean; - shopName?: string; - accessToken?: string; - messageTemplate?: string; - autoDeliveryMessageTemplate?: string; - appSecret?: string; - apiVersion?: string; - sendMessageEndpoint?: string; -}; - -export type AgisoMessagingShopsConfig = Record< - string, - AgisoMessagingShopConfig ->; - export type RuntimeConfig = { server: { port: number; }; - browser: { - chromePath: string; - headless: boolean | null; - devtools: boolean | null; - keepAlive: boolean | null; - prewarm: boolean | null; - slowMoMs: number | null; - vncResolution: string; - }; - session: { - debug: boolean; - }; - ocr: { - baseUrl: string; - }; data: { root: string; }; @@ -61,27 +30,6 @@ export type RuntimeConfig = { defaultUsers: AdminDefaultUser[]; }; platforms: { - agiso: { - appSecret: string; - tradeDetail: { - endpoint: string; - apiVersion: string; - timeoutMs: number; - }; - autoDelivery: { - enabled: boolean; - endpoint: string; - apiVersion: string; - }; - messaging: { - enabled: boolean; - apiVersion: string; - sendMessageEndpoint: string; - messageTemplate: string; - autoDeliveryMessageTemplate: string; - shops: AgisoMessagingShopsConfig; - }; - }; ninetyone: { userId: string; secret: string; @@ -132,7 +80,4 @@ export type RuntimeConfig = { cors: { allowedOrigins: string[]; }; - redeem: { - proofMode: string; - }; }; diff --git a/apps/backend/tsconfig.kuaishou.build.json b/apps/backend/tsconfig.kuaishou.build.json deleted file mode 100644 index ed08e0e5..00000000 --- a/apps/backend/tsconfig.kuaishou.build.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.kuaishou.json", - "compilerOptions": { - "noEmit": false, - "outDir": "dist", - "rootDir": "src", - "declaration": false, - "sourceMap": true - } -} diff --git a/apps/backend/tsconfig.kuaishou.json b/apps/backend/tsconfig.kuaishou.json deleted file mode 100644 index 95aff275..00000000 --- a/apps/backend/tsconfig.kuaishou.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.json", - "files": [ - "src/index-kuaishou.ts" - ], - "include": [], - "exclude": [ - "node_modules", - "data", - "dist", - "src/**/*.test.js", - "src/**/*.test.ts" - ] -} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index c354ca19..31952edb 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -16,7 +16,6 @@ export type { AdminDashboardSummary } from './dashboard' // Orders types export type { AdminOrderBindingSummary, - AdminOrderAgisoAutoDeliverySummary, AdminOrderListItem, AdminOrderDetail, } from './orders' @@ -24,7 +23,6 @@ export type { // Tasks types export type { AdminTaskBindingSummary, - AdminAgisoAutoDeliveryStatus, AdminTaskListItem, AdminTaskOperations, AdminTaskActionResponse, diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 8f16c568..cf6e7a4e 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -1,5 +1,4 @@ import type { AdminTaskListItem } from './tasks' -import type { AdminAgisoAutoDeliveryStatus } from './tasks' export interface AdminOrderBindingSummary { totalTaskCount: number @@ -13,13 +12,6 @@ export interface AdminOrderBindingSummary { userBindingStatus: string } -export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus { - totalTaskCount: number - deliveredTaskCount: number - sourceTaskId: number | null - sourceTaskNo: string -} - export interface AdminOrderListItem { orderId: number provider: string @@ -74,7 +66,6 @@ export interface AdminOrderDetail { itemSummary: string rawPayload: Record bindingSummary: AdminOrderBindingSummary - agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null } items: Array<{ orderItemId: number diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 8e0c76dc..a5cb0d56 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -6,17 +6,6 @@ export interface AdminTaskBindingSummary { roleKeys: string[] } -export interface AdminAgisoAutoDeliveryStatus { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - export interface AdminTaskListItem { taskId: number taskNo: string @@ -46,7 +35,6 @@ export interface AdminTaskListItem { inventoryCredentialType: string claimToken: string screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export interface AdminTaskOperations { diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 9dead2ba..70e9b9e4 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -61,9 +61,6 @@ export function formatTaskEventType(eventType: string) { claim_redeem_code_invalid: "兑换码错误", claim_redeem_inventory_replaced: "已切换新兑换码", claim_redeem_completed: "兑换链路完成", - agiso_auto_delivery_success: "咸鱼自动发货成功", - agiso_auto_delivery_failed: "咸鱼自动发货失败", - agiso_auto_delivery_skipped: "咸鱼自动发货已跳过", }; return formatStatusWithRaw(eventType, labelMap); @@ -96,48 +93,6 @@ export function formatRedeemResolutionStatus(value: string) { : value || "-"; } -export function formatAutoDeliveryTrigger(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "claim_redeemed": - return "用户兑换完成后触发"; - case "manual_dispatch_completed": - return "人工履约完成后触发"; - case "task_delivered": - return "任务交付完成后触发"; - default: - return normalized || "-"; - } -} - -export function formatAutoDeliveryReason(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "waiting_other_tasks": - return "订单下仍有任务未完成交付"; - case "already_sent": - return "该订单已经执行过自动发货"; - case "auto_delivery_disabled": - return "自动发货开关已关闭"; - case "missing_config": - return "缺少 Agiso 自动发货配置"; - case "request_failed": - return "Agiso 接口返回失败"; - case "request_error": - return "请求 Agiso 接口时发生异常"; - case "delivery_not_confirmed": - return "接口返回成功,但订单状态未确认进入已发货"; - case "task_not_delivered": - return "当前任务尚未进入已交付状态"; - case "not_supported": - return "当前订单不支持 Agiso 咸鱼自动发货"; - default: - return normalized || "-"; - } -} - export function formatKuaishouRoleInfoLabel(value: string) { const normalized = String(value || "").trim(); @@ -177,9 +132,7 @@ export function formatTaskEventPayload(payload: Record) { : "", payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "", payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "", - payload.trigger - ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ""))}` - : "", + payload.trigger ? `触发 ${payload.trigger}` : "", payload.reason ? `原因 ${payload.reason}` : "", payload.purchaseTriggered === true ? "已触发购买" : "", payload.usedKnapsack === true ? "使用背包库存" : "", @@ -211,24 +164,3 @@ export function formatBindingCountSummary(summary: { }) { return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`; } - -export function resolveTaskAutoDeliveryStatus(task: { - agisoAutoDelivery?: { status?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.status) return task.agisoAutoDelivery.status; - return task.deliveryStatus === "delivered" ? "pending" : "waiting"; -} - -export function formatTaskAutoDeliveryReason(task: { - agisoAutoDelivery?: { errorMessage?: string; reason?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.errorMessage) - return task.agisoAutoDelivery.errorMessage; - if (task.agisoAutoDelivery?.reason) - return formatAutoDeliveryReason(task.agisoAutoDelivery.reason); - return task.deliveryStatus === "delivered" - ? "当前任务已交付,等待订单统一自动发货" - : "当前任务尚未完成交付"; -} diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 0225b131..4da0f0a9 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -34,7 +34,7 @@ export const adminInventoryStatusOptions = [ export const adminInventoryCredentialTypeOptions = [ { label: '全部凭据类型', value: '' }, - { label: '腾讯兑换码(自动领取)', value: 'tencent_code' }, + { label: '兑换码(领取链路)', value: 'tencent_code' }, { label: '卡号卡密', value: 'card_password' }, { label: '账号密码', value: 'account_password' }, { label: '激活链接', value: 'activation_link' }, diff --git a/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue b/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue index 37780fbe..a53f1242 100644 --- a/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue +++ b/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue @@ -6,11 +6,8 @@ import AdminStatusTag from '@/components/admin/AdminStatusTag.vue' import { fetchAdminOrderDetail } from '@/services/admin' import type { AdminOrderDetail } from '@/types/admin' import { - formatAutoDeliveryReason, formatBindingRoleSummary, formatBindingCountSummary, - resolveTaskAutoDeliveryStatus, - formatTaskAutoDeliveryReason, } from '@/utils/admin-display' import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminWebhookEventType } from '@/utils/admin-webhook' @@ -116,26 +113,6 @@ function formatSpecSummary(spec: Record) { {{ detail.order.bindingSummary.releasedBindingCount }} - - 自动发货 -
- -
- - {{ - detail.order.agisoAutoDelivery.errorMessage || - formatAutoDeliveryReason(detail.order.agisoAutoDelivery.reason) || - '全部任务交付后会自动触发' - }} - - - 已交付 {{ detail.order.agisoAutoDelivery.deliveredTaskCount }}/{{ - detail.order.agisoAutoDelivery.totalTaskCount - }} - · 最近执行 - {{ formatAdminDateTime(detail.order.agisoAutoDelivery.updatedAt) }} - -
@@ -199,16 +176,6 @@ function formatSpecSummary(spec: Record) { - - - - - - @@ -193,43 +171,6 @@ function formatSpecSummary(spec: Record) { - -

Webhook 摘要

- - - - - - - - - - - - - - - - - - - - - -
-

原始载荷

diff --git a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue index ac5d65b6..3bffec92 100644 --- a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue @@ -5,7 +5,6 @@ import { closeAdminTask, markAdminTaskManualReview, regenerateAdminTaskClaimLink, - releaseAdminTaskInventoryBinding, retryAdminTask, } from '@/services/admin' @@ -14,7 +13,6 @@ import AdminTaskActionBar from './components/AdminTaskActionBar.vue' import AdminTaskEventsSection from './components/AdminTaskEventsSection.vue' import AdminTaskHeroSection from './components/AdminTaskHeroSection.vue' import AdminTaskInfoSection from './components/AdminTaskInfoSection.vue' -import AdminTaskInventoryBindingsSection from './components/AdminTaskInventoryBindingsSection.vue' import AdminTaskManualDispatchSection from './components/AdminTaskManualDispatchSection.vue' import AdminTaskRedeemResolutionSection from './components/AdminTaskRedeemResolutionSection.vue' import AdminTaskScreenshotSection from './components/AdminTaskScreenshotSection.vue' @@ -135,20 +133,6 @@ onMounted(loadDetail) - - {
- + @@ -367,11 +337,7 @@ onMounted(() => { v-if="canManageTaskLifecycle" :disabled=" row.executorKey === 'manual_dispatch' || - ![ - 'retry_pending', - 'manual_review', - 'waiting_inventory', - ].includes(row.status) + !['retry_pending', 'manual_review'].includes(row.status) " :loading="actionLoadingId === row.taskId" size="small" diff --git a/apps/frontend/src/views/admin/tasks/components/AdminTaskInfoSection.vue b/apps/frontend/src/views/admin/tasks/components/AdminTaskInfoSection.vue index c0dc5b9a..2ebe7a00 100644 --- a/apps/frontend/src/views/admin/tasks/components/AdminTaskInfoSection.vue +++ b/apps/frontend/src/views/admin/tasks/components/AdminTaskInfoSection.vue @@ -36,21 +36,12 @@ defineProps() {{ detail.task.resultMessage || '-' }} - - 客服账号不可查看库存凭据与原始领取 Token + + 客服账号不可查看原始领取 Token diff --git a/apps/frontend/src/views/admin/tasks/components/AdminTaskInventoryBindingsSection.vue b/apps/frontend/src/views/admin/tasks/components/AdminTaskInventoryBindingsSection.vue deleted file mode 100644 index 5d97d2c9..00000000 --- a/apps/frontend/src/views/admin/tasks/components/AdminTaskInventoryBindingsSection.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/tasks/components/AdminTaskRedeemResolutionSection.vue b/apps/frontend/src/views/admin/tasks/components/AdminTaskRedeemResolutionSection.vue index d760e401..26f7fb8e 100644 --- a/apps/frontend/src/views/admin/tasks/components/AdminTaskRedeemResolutionSection.vue +++ b/apps/frontend/src/views/admin/tasks/components/AdminTaskRedeemResolutionSection.vue @@ -40,7 +40,7 @@ defineProps()
@@ -48,10 +48,6 @@ defineProps() {{ formatRedeemOutcomeLabel(attempt.outcome) }}
-
- 库存项 - {{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }} -
凭据 {{ attempt.codeMasked || '-' }} diff --git a/apps/frontend/src/views/admin/users/AdminUsersView.vue b/apps/frontend/src/views/admin/users/AdminUsersView.vue index d55676ad..9518122a 100644 --- a/apps/frontend/src/views/admin/users/AdminUsersView.vue +++ b/apps/frontend/src/views/admin/users/AdminUsersView.vue @@ -26,11 +26,9 @@ const { updateRole, toggleStatus, promptResetPassword, - promptUpdateInventoryGroups, // formatters isCurrentUser, formatRoleLabel, - formatInventoryGroups, // utils hasAdminRole, formatAdminDateTime, @@ -78,9 +76,7 @@ const { :update-role="updateRole" :toggle-status="toggleStatus" :prompt-reset-password="promptResetPassword" - :prompt-update-inventory-groups="promptUpdateInventoryGroups" :format-role-label="formatRoleLabel" - :format-inventory-groups="formatInventoryGroups" :format-admin-date-time="formatAdminDateTime" /> diff --git a/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue b/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue index ccfd079a..47862a68 100644 --- a/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue +++ b/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue @@ -13,9 +13,7 @@ defineProps<{ updateRole: (item: AdminUserListItem, nextRole: AdminRole) => Promise toggleStatus: (item: AdminUserListItem) => Promise promptResetPassword: (item: AdminUserListItem) => Promise - promptUpdateInventoryGroups: (item: AdminUserListItem) => Promise formatRoleLabel: (role: AdminRole) => string - formatInventoryGroups: (item: AdminUserListItem) => string[] formatAdminDateTime: (value: string) => string }>() @@ -39,19 +37,6 @@ defineProps<{ - - - @@ -101,13 +86,6 @@ defineProps<{ @click="toggleStatus(row)" >{{ row.status === 'active' ? '停用' : '启用' }} - 配库存组 td.el-table__cell) { background: var(--el-table-row-hover-bg-color); } -.inventory-tags { - display: flex; - gap: 4px; - align-items: center; - white-space: nowrap; -} -.inventory-tags :deep(.el-tag) { - flex: 0 0 auto; -} .date-cell { display: inline-block; white-space: nowrap; diff --git a/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts b/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts index c08944e1..82de9cf1 100644 --- a/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts +++ b/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts @@ -11,7 +11,6 @@ import { createAdminUser, fetchAdminUsers, resetAdminUserPassword, - updateAdminUserInventoryGroups, updateAdminUserRole, updateAdminUserStatus, } from '@/services/admin' @@ -44,12 +43,6 @@ export function useAdminUsers() { return '普通运营' } - function formatInventoryGroups(item: AdminUserListItem) { - if (item.role !== 'support') return ['不限库存组'] - if (item.inventoryGroupCodes.length === 0) return ['未绑定'] - return item.inventoryGroupCodes - } - async function loadUsers(page = pagination.value.page) { if (!hasAdminRole('admin')) { loading.value = false @@ -170,39 +163,6 @@ export function useAdminUsers() { } } - async function promptUpdateInventoryGroups(item: AdminUserListItem) { - if (item.role !== 'support') { - showError('仅客服账号需要绑定库存组') - return - } - try { - const result = await showPrompt( - `请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`, - '配置库存组', - { - inputType: 'text', - inputValue: item.inventoryGroupCodes.join(','), - inputPlaceholder: '例如 A组,B组,售后组', - confirmButtonText: '保存', - cancelButtonText: '取消', - }, - ) - const inventoryGroupCodes = String(result.value || '') - .split(',') - .map((v) => v.trim()) - .filter(Boolean) - actionLoadingId.value = item.userId - await updateAdminUserInventoryGroups(item.userId, { inventoryGroupCodes }) - showSuccess('用户库存组已更新') - await loadUsers() - } catch (error) { - if (isFeedbackDismissed(error)) return - showError(error instanceof Error ? error.message : '更新库存组失败') - } finally { - actionLoadingId.value = null - } - } - onMounted(loadUsers) return { @@ -227,11 +187,9 @@ export function useAdminUsers() { updateRole, toggleStatus, promptResetPassword, - promptUpdateInventoryGroups, // formatters isCurrentUser, formatRoleLabel, - formatInventoryGroups, // utils hasAdminRole, formatAdminDateTime, diff --git a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventDetailView.vue b/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventDetailView.vue deleted file mode 100644 index 96ffeab6..00000000 --- a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventDetailView.vue +++ /dev/null @@ -1,354 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue b/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue deleted file mode 100644 index 69bc5084..00000000 --- a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue +++ /dev/null @@ -1,415 +0,0 @@ - - - - - diff --git a/docs/91卡券/6.开发设计.md b/docs/91卡券/6.开发设计.md index b4ba9967..ae572be3 100644 --- a/docs/91卡券/6.开发设计.md +++ b/docs/91卡券/6.开发设计.md @@ -50,11 +50,11 @@ - `POST /api/v1/open/91/orders/create` - `POST /api/v1/open/91/orders/query` -不放进现有 `/api/v1/webhooks`,因为: +接口使用独立公开路由组,原因: -- `91` 不是 webhook 来源; - `91` 需要自己的响应结构:`code/message/data`; -- 与现有项目 `buildSuccessPayload(code=0)` 不兼容。 +- 与后台管理接口的统一响应结构隔离; +- 便于按 91 的签名、时间戳和错误码规范单独演进。 ### 4.2 返回格式 From 851d1d1efc68d722895d6575e088bb307be65e7c Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 23:17:01 +0800 Subject: [PATCH 15/73] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=BD=BB=E9=87=8F?= =?UTF-8?q?=E7=89=88=E6=AE=8B=E7=95=99=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- apps/backend/README.md | 2 +- apps/backend/scripts/cleanup-dev-data.ts | 3 --- package-lock.json | 6 ------ 4 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 package-lock.json diff --git a/README.md b/README.md index a1a5b014..d33bb416 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 快手自动发货与履约管理系统。 -项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。 +项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。 ## 技术栈 @@ -148,7 +148,7 @@ docker compose up -d --build ## 主要业务模块 - `apps/backend/src/routes`:API 路由 -- `apps/backend/src/services/order`:订单、库存和履约匹配 +- `apps/backend/src/services/order`:订单、商品匹配和履约任务编排 - `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排 - `apps/backend/src/services/admin`:后台读写、权限、配置管理 - `apps/backend/src/repositories`:数据库访问 diff --git a/apps/backend/README.md b/apps/backend/README.md index ab710e58..2b296c41 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -64,7 +64,7 @@ npm test - `src/index.ts`:服务入口 - `src/app.ts`:Express 应用组装 - `src/routes`:API 路由 -- `src/services/order`:订单、库存和履约匹配 +- `src/services/order`:订单、商品匹配和履约任务编排 - `src/services/fulfillment`:快手 Cloud 履约编排 - `src/services/admin`:后台读写、权限、配置管理 - `src/repositories`:数据库访问 diff --git a/apps/backend/scripts/cleanup-dev-data.ts b/apps/backend/scripts/cleanup-dev-data.ts index 559e2d48..871157bf 100644 --- a/apps/backend/scripts/cleanup-dev-data.ts +++ b/apps/backend/scripts/cleanup-dev-data.ts @@ -9,12 +9,9 @@ const RESET_TABLES = [ 'task_runtime_contexts', 'task_events', 'claim_tokens', - 'task_inventory_bindings', 'fulfillment_tasks', 'order_items', 'orders', - 'webhook_events', - 'inventory_items', ] const options = parseArgs(process.argv.slice(2)) diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ff955191..00000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "core-infra", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From dea3023825de4ed6db09da7a7469fe111d1a1cc6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 08:19:55 +0800 Subject: [PATCH 16/73] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E4=B8=8E=E6=97=A5=E5=BF=97=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/db/migrations/001_init.sql | 1 + .../migrations/002_admin_session_version.sql | 2 + .../src/repositories/admin-user-repo.ts | 13 +- .../repositories/fulfillment-profile-repo.ts | 2 +- .../src/services/admin/admin-auth-service.ts | 24 +++ .../admin/admin-read-shared-helpers.ts | 2 +- .../platform-config/notification-service.ts | 6 +- .../admin/write/kuaishou-cloud-actions.ts | 6 +- .../fulfillment-bootstrap-service.ts | 20 +- .../claim/kuaishou-cloud-claim-context.ts | 19 +- .../claim/kuaishou-cloud-claim-service.ts | 9 +- .../claim/kuaishou-cloud-sync-service.ts | 5 +- .../kuaishou-cloud-task-service.ts | 25 +-- .../kuaishou-cloud/binding-resources.ts | 36 ++-- .../kuaishou-cloud/cloudtentacles-context.ts | 2 +- .../fulfillment/kuaishou-cloud/domain.ts | 23 ++- .../kuaishou-cloud/task-context.ts | 22 +- .../kuaishou-cloud/task-finalization.ts | 5 +- .../services/notification/config-service.ts | 23 ++- .../notification/notification-service.ts | 7 +- .../services/open-91/order-query-service.ts | 9 +- apps/backend/src/services/open-91/shared.ts | 14 +- ...aishou-cloud-fulfillment-config-service.ts | 22 +- .../cloudtentacles/debug-flow-service.ts | 2 +- .../cloudtentacles/session-service.ts | 2 +- .../cloudtentacles/session-state-service.ts | 10 +- .../cloudtentacles/virtual-number-service.ts | 6 +- .../platforms/ninetyone/order-service.ts | 19 +- .../scheduler/cloudtentacles-health-job.ts | 2 +- apps/backend/src/startup/state.ts | 24 ++- apps/backend/src/utils/http.ts | 9 +- apps/backend/src/utils/logger.test.ts | 24 +++ apps/backend/src/utils/logger.ts | 190 +++++++++++++----- apps/backend/tsconfig.json | 1 + apps/frontend/src/lib/http.ts | 1 + 35 files changed, 402 insertions(+), 185 deletions(-) create mode 100644 apps/backend/src/db/migrations/002_admin_session_version.sql diff --git a/apps/backend/src/db/migrations/001_init.sql b/apps/backend/src/db/migrations/001_init.sql index 7f6bc9f5..c53ee619 100644 --- a/apps/backend/src/db/migrations/001_init.sql +++ b/apps/backend/src/db/migrations/001_init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS admin_users ( password_hash TEXT NOT NULL, role TEXT NOT NULL, status TEXT NOT NULL, + session_version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); diff --git a/apps/backend/src/db/migrations/002_admin_session_version.sql b/apps/backend/src/db/migrations/002_admin_session_version.sql new file mode 100644 index 00000000..7b2e0d62 --- /dev/null +++ b/apps/backend/src/db/migrations/002_admin_session_version.sql @@ -0,0 +1,2 @@ +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS session_version INTEGER NOT NULL DEFAULT 1; diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index 22044d99..b8f0f99d 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -6,6 +6,7 @@ type AdminUserRow = { password_hash: string role: string status: string + session_version: number created_at: string updated_at: string } @@ -21,7 +22,7 @@ type AdminUserCreateInput = { type AdminUserPatch = Partial> type AdminUserListInput = { @@ -66,9 +67,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise 0 ? parsed : 1 +} + +function nextAdminSessionVersion(user: AdminUserRow): number { + return normalizeAdminSessionVersion(user.session_version) + 1 +} + async function getRequiredAdminUser(userId: number | string): Promise { const user = await getAdminUserById(Number(userId)) diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index f111760c..f7a5c27b 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -86,7 +86,7 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', cloudSourceKey: String(binding.cloudSourceKey || '').trim(), cloudSourceKeyFallbacks: Array.isArray(binding.cloudSourceKeyFallbacks) - ? binding.cloudSourceKeyFallbacks.map((value) => String(value || '').trim()).filter(Boolean) + ? binding.cloudSourceKeyFallbacks.map((value: unknown) => String(value || '').trim()).filter(Boolean) : [], resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), skuId: Number(binding.skuId || 0) || 0, diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index b90e51e8..1b419136 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -95,7 +95,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { bark: { enabled: bark.enabled !== false, serverUrl: String(bark.serverUrl || 'https://api.day.app').trim(), - recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item) => ({ + recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), deviceKey: String(item.deviceKey || '').trim(), @@ -105,7 +105,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { }, wpush: { enabled: wpush.enabled !== false, - recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item) => ({ + recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), apiKey: String(item.apiKey || item.apikey || '').trim(), @@ -146,7 +146,7 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) { function listAdminCloudtentaclesMonitorAccounts() { const sourcesConfig = listCloudtentaclesSources() const sessionsConfig = getAllCloudtentaclesSessionStates() - const sessions = sessionsConfig.sessions || {} + const sessions: Record = sessionsConfig.sessions || {} return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => { const sourceKey = String(source.key || '').trim() diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index a3818bbb..cd31ff02 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -171,7 +171,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', roleName: '', @@ -181,9 +181,9 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( status: 'pending', name: '', rid: '', - refreshedAt: null, + refreshedAt: null as null, errorMessage: '', - rawInfo: null, + rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 3ea42216..8d5c0328 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -4,6 +4,7 @@ import { replaceFulfillmentProfileRequirements, upsertFulfillmentProfile, upsertSkuFulfillmentBinding, + type FulfillmentProfileRequirementInput, } from '../../repositories/fulfillment-profile-repo.js' import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' import { normalizeProductName } from '../order/product-match-service.js' @@ -14,7 +15,18 @@ import { } from '../order/kuaishou-cloud-fulfillment-config-service.js' import { nowIso } from '../../utils/time.js' -const CORE_PROFILES = [ +type JsonObject = Record +type CoreProfile = { + profileKey: string + name: string + executorKey: string + requiresClaim: boolean + autoDispatch: boolean + inventoryStrategy: string + requirements: FulfillmentProfileRequirementInput[] +} + +const CORE_PROFILES: CoreProfile[] = [ { profileKey: 'manual_review', name: '人工发货', @@ -35,8 +47,6 @@ const CORE_PROFILES = [ }, ] -type JsonObject = Record - export async function ensureFulfillmentCatalogBootstrapped() { const profileMap = await ensureCoreProfiles() await syncConfiguredFulfillmentBindings(profileMap) @@ -44,7 +54,7 @@ export async function ensureFulfillmentCatalogBootstrapped() { async function ensureCoreProfiles() { const timestamp = nowIso() - const profileMap = {} + const profileMap: JsonObject = {} for (const profile of CORE_PROFILES) { const saved = await upsertFulfillmentProfile({ @@ -148,6 +158,6 @@ function resolveBindingRuntimeConfig(binding: JsonObject = {}) { return baseConfig } -function isPlainObject(value) { +function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 8f40357a..f0126b70 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -7,10 +7,19 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import { buildClaimUrl } from './claim-service.js' +import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' +type JsonObject = Record +type ClaimContext = { + claimToken: ClaimTokenRow + task: TaskRow + order: OrderRow + orderItem: OrderItemRow +} + export async function getClaimContext(token: unknown) { const normalized = String(token || '').trim() @@ -76,7 +85,7 @@ export async function getClaimContext(token: unknown) { } } -export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) { +export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { @@ -113,7 +122,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) skuName: orderItem.sku_name, quantity: orderItem.quantity, }, - session: null, + session: null as null, kuaishouCloudFulfillment, result: task.redeemed_at ? { @@ -126,7 +135,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) } } -export function mapClaimKuaishouCloudFulfillment(task, order) { +export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) { if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { return null } @@ -213,11 +222,11 @@ export function mapClaimKuaishouCloudFulfillment(task, order) { } } -function parseTaskContext(task) { +function parseTaskContext(task: TaskRow): JsonObject { return parseTaskContextValue(task) } -async function expireClaimContext(claimToken, task) { +async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { status: 'expired', diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 8209cacc..397e40dc 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -20,6 +20,7 @@ import { } from '../fulfillment/kuaishou-cloud-task-service.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' +import type { TaskRow } from '../../types/repository-rows.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) @@ -136,7 +137,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js return getKuaishouCloudClaimDetail(token) } -export async function getKuaishouCloudClaimGuideAssetPath(filename) { +export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) { const normalized = String(filename || '').trim() if (!ALLOWED_GUIDE_FILES.has(normalized)) { throw createHttpError('指引图片不存在', { @@ -172,7 +173,7 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { }) } -function parseTaskContext(task) { +function parseTaskContext(task: Partial | null | undefined): JsonObject { const rawValue = task?.context_json if (!rawValue) { return {} @@ -189,7 +190,7 @@ function parseTaskContext(task) { } } -export async function confirmKuaishouCloudClaimRole(token) { +export async function confirmKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) const now = nowIso() @@ -245,7 +246,7 @@ export async function confirmKuaishouCloudClaimRole(token) { return getKuaishouCloudClaimDetail(token) } -export async function redeemKuaishouCloudClaim(token) { +export async function redeemKuaishouCloudClaim(token: unknown) { const context = await getClaimContext(token) const now = nowIso() diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index f9290902..ad8fcce4 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -4,8 +4,9 @@ import { probeKuaishouCloudTaskBindUrl, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud-task-service.js' +import type { TaskRow } from '../../types/repository-rows.js' -export async function syncKuaishouCloudRoleInfo(task) { +export async function syncKuaishouCloudRoleInfo(task: TaskRow) { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) { @@ -42,7 +43,7 @@ export async function syncKuaishouCloudRoleInfo(task) { } } -function parseTaskContext(task) { +function parseTaskContext(task: Partial | null | undefined): Record { const rawValue = task?.context_json if (!rawValue) { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts index 51aca310..40e3d8cb 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts @@ -43,6 +43,7 @@ import { normalizeActor, parseTaskContext, } from "./kuaishou-cloud/task-context.js"; +import type { TaskRow } from "../../types/repository-rows.js"; export { isKuaishouCloudBindUrlFresh, @@ -58,7 +59,7 @@ export { resolvePersistedCloudtentaclesContextWithFallback, } from "./kuaishou-cloud/cloudtentacles-context.js"; -export async function ensureTaskClaimLink(task) { +export async function ensureTaskClaimLink(task: TaskRow) { const tokenStatus = String(task?.primary_claim_token_status || "").trim(); const token = String( task?.primary_claim_token || task?.claim_token || "" @@ -82,7 +83,7 @@ export async function ensureTaskClaimLink(task) { } export async function prepareKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -246,7 +247,7 @@ export async function prepareKuaishouCloudFulfillmentTask( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: "pending", bindProbeMessage: "", roleName: "", @@ -256,9 +257,9 @@ export async function prepareKuaishouCloudFulfillmentTask( status: "pending", name: "", rid: "", - refreshedAt: null, + refreshedAt: null as null, errorMessage: "", - rawInfo: null, + rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, @@ -312,7 +313,7 @@ export async function prepareKuaishouCloudFulfillmentTask( } export async function refreshKuaishouCloudTaskBindUrl( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -413,7 +414,7 @@ export async function refreshKuaishouCloudTaskBindUrl( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: "pending", bindProbeMessage: "", roleName: @@ -485,7 +486,7 @@ export async function refreshKuaishouCloudTaskBindUrl( } export async function probeKuaishouCloudTaskBindUrl( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -503,7 +504,7 @@ export async function probeKuaishouCloudTaskBindUrl( return { task, flow, - probe: null, + probe: null as null, }; } @@ -518,7 +519,7 @@ export async function probeKuaishouCloudTaskBindUrl( return { task, flow, - probe: null, + probe: null as null, }; } @@ -582,7 +583,7 @@ export async function probeKuaishouCloudTaskBindUrl( * @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input] */ async function markKuaishouCloudBindUrlRefreshFailed( - task, + task: TaskRow, { taskContext, flow, now, actor, error }: JsonObject = {} ) { const errorMessage = @@ -648,7 +649,7 @@ async function markKuaishouCloudBindUrlRefreshFailed( } export async function refreshKuaishouCloudTaskRoleInfo( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts index 69b22b04..92cb3120 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -11,8 +11,8 @@ import { import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js"; export function resolveKuaishouCloudBindingResources( - flow, - { skuItems = [], knapsackItems = [] } = {} + flow: JsonObject, + { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {} ) { const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) @@ -70,7 +70,7 @@ export function resolveKuaishouCloudBindingResources( }; } -export function resolveKuaishouCloudVnKeyCandidates(_input = {}) { +export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) { return [KUAISHOU_CLOUD_FIXED_VN_KEY]; } @@ -159,7 +159,7 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb ); } -function collectKuaishouCloudNameCandidates(flow) { +function collectKuaishouCloudNameCandidates(flow: JsonObject) { return Array.from( new Set( [ @@ -171,10 +171,10 @@ function collectKuaishouCloudNameCandidates(flow) { ); } -function findCloudItemByNames(items, nameCandidates, preferredId = 0) { +function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) { const normalizedItems = Array.isArray(items) ? items : []; const normalizedNames = nameCandidates - .map((item) => ({ + .map((item: string) => ({ raw: String(item || "").trim(), normalized: normalizeProductName(item), })) @@ -182,34 +182,34 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { if (normalizedNames.length === 0 || normalizedItems.length === 0) { return preferredId > 0 - ? normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + ? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null : null; } if (preferredId > 0) { const preferred = - normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null; if (preferred) { return preferred; } } - const exactMatches = normalizedItems.filter((item) => { + const exactMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => candidate.normalized === itemName + (candidate: { normalized: string }) => candidate.normalized === itemName ); }); if (exactMatches.length > 0) { return exactMatches[0]; } - const partialMatches = normalizedItems.filter((item) => { + const partialMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => + (candidate: { normalized: string }) => itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName) ); @@ -224,13 +224,15 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { return null; } -function isCloudSkuLikeItem(item) { - return Boolean(item) && typeof item === "object" && Number(item.id || 0) > 0; +function isCloudSkuLikeItem(item: unknown): item is JsonObject { + const current = item && typeof item === "object" ? item as JsonObject : {}; + return Number(current.id || 0) > 0; } -function isRecoverableKuaishouCloudVnKeyError(error) { - const errorCode = String(error?.errorCode || error?.code || "").trim(); - const errorMessage = String(error?.message || "").trim(); +function isRecoverableKuaishouCloudVnKeyError(error: unknown) { + const current = error && typeof error === "object" ? error as JsonObject : {}; + const errorCode = String(current.errorCode || current.code || "").trim(); + const errorMessage = String(current.message || "").trim(); return ( errorCode === "cloudtentacles_vn_bind_url_failed" && errorMessage.includes("不支持的游戏类型") diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts index d6977a6c..6ed8118e 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts @@ -45,7 +45,7 @@ export function resolvePersistedCloudtentaclesContext(sourceKey = "default") { */ export function resolvePersistedCloudtentaclesContextWithFallback( primarySourceKey = "default", - fallbacks = [] + fallbacks: unknown[] = [] ) { const candidates = [ String(primarySourceKey || "default").trim() || "default", diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index 57668f5e..676ce7d1 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -8,12 +8,13 @@ export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; export type JsonObject = Record; -export function isKuaishouCloudTask(task) { - return String(task?.executor_key || "").trim() === "kuaishou_ct_assisted"; +export function isKuaishouCloudTask(task: unknown) { + const source = task && typeof task === "object" ? task as JsonObject : {}; + return String(source.executor_key || "").trim() === "kuaishou_ct_assisted"; } -export function normalizeKuaishouCloudFlow(value) { - const source = value && typeof value === "object" ? value : {}; +export function normalizeKuaishouCloudFlow(value: unknown) { + const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; const binding = source.binding && typeof source.binding === "object" ? source.binding : {}; const role = @@ -129,8 +130,8 @@ export function normalizeKuaishouCloudFlow(value) { }; } -export function normalizeKuaishouCloudRoleInfo(value) { - const rawInfo = value && typeof value === "object" ? value : null; +export function normalizeKuaishouCloudRoleInfo(value: unknown) { + const rawInfo: JsonObject | null = value && typeof value === "object" ? value as JsonObject : null; const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object" ? rawInfo.sBindInfo @@ -157,15 +158,15 @@ export function normalizeKuaishouCloudRoleInfo(value) { }; } -export function maskPhone(value) { +export function maskPhone(value: unknown) { return maskPhoneValue(value); } -export function maskCode(value) { +export function maskCode(value: unknown) { return maskCodeValue(value); } -export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) { +export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { const preparedTime = Date.parse(String(preparedAt || "")); if (!Number.isFinite(preparedTime)) { return null; @@ -176,7 +177,7 @@ export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) { return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString(); } -export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) { +export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { const normalizedFlow = normalizeKuaishouCloudFlow(flow); if (!normalizedFlow.binding.bindUrl) { return false; @@ -195,7 +196,7 @@ export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) { return expiresTime > now.getTime(); } -export function normalizeStringArray(value) { +export function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { return value.map((v) => String(v || "").trim()).filter(Boolean); } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts index dc28bada..6b259ee2 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts @@ -1,14 +1,18 @@ import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js"; +import type { TaskRow } from "../../../types/repository-rows.js"; -export function normalizeActor(actor) { +type JsonObject = Record; + +export function normalizeActor(actor: unknown) { if (!actor || typeof actor !== "object") { return null; } - const source = String(actor.source || "").trim(); - const userId = Number(actor.userId || 0) || 0; - const username = String(actor.username || "").trim(); - const role = String(actor.role || "").trim(); + const current = actor as JsonObject; + const source = String(current.source || "").trim(); + const userId = Number(current.userId || 0) || 0; + const username = String(current.username || "").trim(); + const role = String(current.role || "").trim(); if (!source && !userId && !username && !role) { return null; @@ -22,19 +26,19 @@ export function normalizeActor(actor) { }; } -export function parseTaskContext(task) { +export function parseTaskContext(task: Partial | null | undefined) { return parseTaskContextValue(task); } -export function getTaskClaimExpiresAt(task) { +export function getTaskClaimExpiresAt(task: Partial | null | undefined) { return task?.claim_expires_at || task?.primary_claim_expires_at || null; } -export function isClaimExpired(expiredAt) { +export function isClaimExpired(expiredAt: unknown) { if (!expiredAt) { return false; } - const timestamp = new Date(expiredAt).getTime(); + const timestamp = new Date(expiredAt instanceof Date ? expiredAt : String(expiredAt || "")).getTime(); return Number.isFinite(timestamp) && timestamp <= Date.now(); } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 90414207..d82576f4 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -20,9 +20,10 @@ import { } from "./domain.js"; import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js"; import { normalizeActor, parseTaskContext } from "./task-context.js"; +import type { TaskRow } from "../../../types/repository-rows.js"; export async function dispatchKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -157,7 +158,7 @@ export async function dispatchKuaishouCloudFulfillmentTask( } export async function returnKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { diff --git a/apps/backend/src/services/notification/config-service.ts b/apps/backend/src/services/notification/config-service.ts index cdd03559..56472ee8 100644 --- a/apps/backend/src/services/notification/config-service.ts +++ b/apps/backend/src/services/notification/config-service.ts @@ -7,6 +7,13 @@ const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notificat const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' type JsonObject = Record +type NotificationRecipient = { + id: string + name: string + deviceKey?: string + apiKey?: string + enabled: boolean +} export function getNotificationConfigFilePath() { return NOTIFICATION_CONFIG_FILE_PATH @@ -26,7 +33,7 @@ export function listEnabledBarkRecipients(config: JsonObject = getNotificationCo } return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : []) - .filter((item) => item.enabled !== false && String(item.deviceKey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.deviceKey || '').trim()) } export function listEnabledWpushRecipients(config: JsonObject = getNotificationConfig()) { @@ -35,7 +42,7 @@ export function listEnabledWpushRecipients(config: JsonObject = getNotificationC } return (Array.isArray(config.channels?.wpush?.recipients) ? config.channels.wpush.recipients : []) - .filter((item) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) } function loadNotificationConfigFromFile() { @@ -58,20 +65,20 @@ export function normalizeNotificationConfig(rawValue: unknown) { enabled: typeof bark.enabled === 'boolean' ? bark.enabled : true, serverUrl: normalizeBarkServerUrl(bark.serverUrl), recipients: (Array.isArray(bark.recipients) ? bark.recipients : []) - .map((item) => normalizeBarkRecipient(item)) + .map((item: unknown) => normalizeBarkRecipient(item)) .filter(Boolean), }, wpush: { enabled: typeof wpush.enabled === 'boolean' ? wpush.enabled : true, recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []) - .map((item) => normalizeWpushRecipient(item)) + .map((item: unknown) => normalizeWpushRecipient(item)) .filter(Boolean), }, }, } } -function normalizeBarkRecipient(rawValue: unknown) { +function normalizeBarkRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -97,7 +104,7 @@ function normalizeBarkServerUrl(value: unknown) { return normalized.replace(/\/+$/, '') } -function normalizeWpushRecipient(rawValue: unknown) { +function normalizeWpushRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -125,11 +132,11 @@ export function createDefaultNotificationConfig() { bark: { enabled: true, serverUrl: DEFAULT_BARK_SERVER_URL, - recipients: [], + recipients: [] as NotificationRecipient[], }, wpush: { enabled: true, - recipients: [], + recipients: [] as NotificationRecipient[], }, }, } diff --git a/apps/backend/src/services/notification/notification-service.ts b/apps/backend/src/services/notification/notification-service.ts index 07646fba..090d5421 100644 --- a/apps/backend/src/services/notification/notification-service.ts +++ b/apps/backend/src/services/notification/notification-service.ts @@ -8,6 +8,7 @@ import { sendBarkNotification } from './bark-service.js' import { sendWpushNotification } from './wpush-service.js' type JsonObject = Record +type NotificationResult = ReturnType type NotificationInput = { title?: string body?: string @@ -31,12 +32,12 @@ export async function sendInternalNotification(input: NotificationInput = {}) { successCount: 0, failedCount: 0, skippedCount: barkRecipients.length + wpushRecipients.length, - results: [], + results: [] as NotificationResult[], } } const results = [ - ...(await Promise.all(barkRecipients.map(async (recipient) => { + ...(await Promise.all(barkRecipients.map(async (recipient: JsonObject) => { try { const result = await sendBarkNotification({ serverUrl: bark.serverUrl, @@ -64,7 +65,7 @@ export async function sendInternalNotification(input: NotificationInput = {}) { ) } }))), - ...(await Promise.all(wpushRecipients.map(async (recipient) => { + ...(await Promise.all(wpushRecipients.map(async (recipient: JsonObject) => { try { const result = await sendWpushNotification({ recipient, diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 0b39f832..7a5b3b73 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -23,8 +23,11 @@ import { OPEN_91_PROVIDER, resolveOpen91QueryState, } from './shared.js' +import type { OrderRow } from '../../types/repository-rows.js' -export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { +type JsonObject = Record + +export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '' } = {}) { const config = assertOpen91Config() const normalized = normalizeOpen91QueryPayload(payload) @@ -168,12 +171,12 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { return buildOpen91SuccessResponse(responseData) } -function resolveOpen91OrderFailReason(order) { +function resolveOpen91OrderFailReason(order: OrderRow) { const payload = parseJsonObject(order?.raw_payload_json) return String(payload.manualFailedReason || '').trim() } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/open-91/shared.ts b/apps/backend/src/services/open-91/shared.ts index 096b28ca..41c47303 100644 --- a/apps/backend/src/services/open-91/shared.ts +++ b/apps/backend/src/services/open-91/shared.ts @@ -45,7 +45,7 @@ export function assertOpen91Config() { return config } -export function normalizeOpen91String(value) { +export function normalizeOpen91String(value: unknown) { return String(value || '').trim() } @@ -187,7 +187,7 @@ export function assertOpen91Signature(params: JsonObject = {}, config = assertOp } } -export function encryptOpen91Cards(cards = [], secret = assertOpen91Config().secret) { +export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { const normalizedSecret = normalizeOpen91String(secret) if (normalizedSecret.length !== 32) { throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { @@ -227,7 +227,7 @@ export function buildOpen91ErrorResponse(message: unknown, code = 500) { return { code, message: normalizeOpen91String(message) || '系统错误', - data: null, + data: null as null, } } @@ -323,7 +323,7 @@ export function resolveOpen91QueryState( } } -export function stringifyOpen91SignValue(value) { +export function stringifyOpen91SignValue(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { return String(value) } @@ -339,12 +339,12 @@ export function stringifyOpen91SignValue(value) { return String(value) } -function normalizeOpen91BuyNum(value) { +function normalizeOpen91BuyNum(value: unknown) { const parsed = Number(value) return Number.isInteger(parsed) ? parsed : 0 } -function normalizeOpen91OptionalAmount(value) { +function normalizeOpen91OptionalAmount(value: unknown) { const normalized = normalizeOpen91String(value) if (!normalized) { return '' @@ -360,7 +360,7 @@ function normalizeOpen91OptionalAmount(value) { return normalized } -function normalizeOpen91Timestamp(value) { +function normalizeOpen91Timestamp(value: unknown) { const normalized = normalizeOpen91String(value) if (!/^\d{10}$/.test(normalized)) { return 0 diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts index dea4e65d..44f02040 100644 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts @@ -20,7 +20,7 @@ export function getKuaishouCloudFulfillmentConfig() { return loadKuaishouCloudFulfillmentConfigFromFile(); } -export function saveKuaishouCloudFulfillmentConfig(rawValue) { +export function saveKuaishouCloudFulfillmentConfig(rawValue: unknown) { return writeJsonFile( KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, rawValue, @@ -94,19 +94,19 @@ function loadKuaishouCloudFulfillmentConfigFromFile() { ); } -function normalizeKuaishouCloudFulfillmentConfig(rawValue) { +function normalizeKuaishouCloudFulfillmentConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {}; return { enabled: source.enabled !== false, items: Array.isArray(source.items) ? source.items - .map((item) => normalizeKuaishouCloudFulfillmentItem(item)) + .map((item: unknown) => normalizeKuaishouCloudFulfillmentItem(item)) .filter(Boolean) : [], }; } -function normalizeKuaishouCloudFulfillmentItem(rawValue) { +function normalizeKuaishouCloudFulfillmentItem(rawValue: unknown) { if (!isPlainObject(rawValue)) { return null; } @@ -178,17 +178,17 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue) { }; } -function normalizePositiveInteger(value) { +function normalizePositiveInteger(value: unknown) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; } -function normalizeNonNegativeInteger(value, fallback) { +function normalizeNonNegativeInteger(value: unknown, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } -function normalizePriority(value) { +function normalizePriority(value: unknown) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return 100; @@ -196,19 +196,19 @@ function normalizePriority(value) { return Math.max(1, Math.round(parsed)); } -function isPlainObject(value) { +function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === "[object Object]"; } -function normalizeStringArray(value) { +function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { - return value.map((v) => String(v || "").trim()).filter(Boolean); + return value.map((v: unknown) => String(v || "").trim()).filter(Boolean); } // 兼容旧格式:字符串用逗号分隔 if (typeof value === "string") { return value .split(",") - .map((v) => v.trim()) + .map((v: string) => v.trim()) .filter(Boolean); } return []; diff --git a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts index 0c22d88f..affde4f3 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts @@ -37,7 +37,7 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) { const beforeAsset = await runFlowStep('查询购买前余额', () => getCloudtentaclesAsset(payload)) await sleep(350) const skuList = await runFlowStep('查询 SKU 列表', () => listCloudtentaclesSku(payload)) - const targetSku = skuList.items.find((item) => Number(item.id || 0) === skuId) || null + const targetSku = skuList.items.find((item: JsonObject) => Number(item.id || 0) === skuId) || null if (!targetSku) { throw createHttpError(`cloudtentacles 未找到 SKU ${skuId}`, { diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts index dc47a801..9afe2c75 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts @@ -176,7 +176,7 @@ export async function validateCloudtentaclesSession(payload: JsonObject = {}) { userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {}, asset: Number(assetResult.payload?.data || 0), permissions: Array.isArray(permissionResult.payload?.data) - ? permissionResult.payload.data.map((item) => String(item || '').trim()).filter(Boolean) + ? permissionResult.payload.data.map((item: unknown) => String(item || '').trim()).filter(Boolean) : [], } } diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts index c55b7df3..412bb06d 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts @@ -6,6 +6,10 @@ import { PROJECT_ROOT } from '../../../config/runtime.js' const CLOUDTENTACLES_SESSION_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-session.json') type JsonObject = Record +type CloudtentaclesSessionState = ReturnType +type CloudtentaclesSessionStatesFile = { + sessions: Record +} export function getCloudtentaclesSessionFilePath() { return CLOUDTENTACLES_SESSION_FILE_PATH @@ -127,7 +131,7 @@ export function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { // Internal helpers // --------------------------------------------------------------------------- -function loadCloudtentaclesSessionStatesFromFile() { +function loadCloudtentaclesSessionStatesFromFile(): CloudtentaclesSessionStatesFile { if (!fs.existsSync(CLOUDTENTACLES_SESSION_FILE_PATH)) { return createDefaultCloudtentaclesSessionStates() } @@ -162,7 +166,7 @@ function normalizeCloudtentaclesSessionState(rawValue: unknown) { * Handles old format (single object without sessions key) by auto-wrapping * into { sessions: { 'default': ... } }. */ -function normalizeSessionStatesFile(rawValue: unknown) { +function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { // Old format: { token: 'xxx', ... } (single object, no sessions key) if (isPlainObject(rawValue) && !rawValue.sessions) { return { @@ -194,7 +198,7 @@ function createDefaultCloudtentaclesSessionState() { } } -function createDefaultCloudtentaclesSessionStates() { +function createDefaultCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { return { sessions: { 'default': createDefaultCloudtentaclesSessionState(), diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index 9d2c3263..ab9380be 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -200,7 +200,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: 'missing_signature_params', roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, } } @@ -247,7 +247,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: error instanceof Error ? error.message : String(error || 'probe_failed'), roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, durationMs: Date.now() - startedAt, } } @@ -269,7 +269,7 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) { }) const items = Array.isArray(result.payload?.data) ? result.payload.data : [] - const matchedItem = items.find((item) => Number(item?.id || 0) === id) || items[0] || {} + const matchedItem = items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {} const bindInfo = parseBindInfo(matchedItem?.bind_info) return { diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 797fa4d8..6e6f6af5 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -11,6 +11,7 @@ import { } from '../../open-91/shared.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' +import type { OrderItemRow, OrderRow } from '../../../types/repository-rows.js' export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config' export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed' @@ -68,7 +69,7 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj } } -export async function upsertOpen91PendingOrder(payload = {}, config = {}) { +export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: JsonObject = {}) { const event = buildOpen91SourceEvent(payload, config) const now = nowIso() const existing = await findOrderByPlatformOrderId({ @@ -127,11 +128,11 @@ export async function upsertOpen91PendingOrder(payload = {}, config = {}) { return { order, orderItems, - tasks: [], + tasks: [] as JsonObject[], } } -export async function retryOpen91Order(orderId) { +export async function retryOpen91Order(orderId: string | number) { const order = await getRequiredOpen91Order(orderId) const items = await listOrderItemsByOrderId(order.id) const event = buildOpen91SourceEventFromOrder(order, items) @@ -154,7 +155,7 @@ export async function retryOpen91Order(orderId) { } } -export async function failOpen91Order(orderId, reason = '') { +export async function failOpen91Order(orderId: string | number, reason = '') { const order = await getRequiredOpen91Order(orderId) const now = nowIso() const rawPayload = parseJsonObject(order.raw_payload_json) @@ -245,7 +246,7 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend } } -export async function getRequiredOpen91Order(orderId) { +export async function getRequiredOpen91Order(orderId: string | number) { const order = await getOrderById(Number(orderId)) if (!order || order.provider !== OPEN_91_PROVIDER || order.platform !== OPEN_91_PLATFORM) { @@ -258,11 +259,11 @@ export async function getRequiredOpen91Order(orderId) { return order } -function buildOpen91SourceEventFromOrder(order, items = []) { +function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[] = []) { const rawPayload = parseJsonObject(order.raw_payload_json) const body = parseJsonObject(rawPayload.body) const normalizedItems = Array.isArray(items) ? items : [] - const eventItems = normalizedItems.map((item) => { + const eventItems = normalizedItems.map((item: OrderItemRow) => { const snapshot = parseJsonObject(item.item_snapshot_json) const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim() @@ -301,7 +302,7 @@ function buildOpen91SourceEventFromOrder(order, items = []) { } } -function mapOpen91AdminOrderRow(row) { +function mapOpen91AdminOrderRow(row: JsonObject) { const rawPayload = parseJsonObject(row.raw_payload_json) const items = Array.isArray(row.items_json) ? row.items_json : [] const firstItem = items[0] || {} @@ -327,7 +328,7 @@ function mapOpen91AdminOrderRow(row) { } } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 57083d3e..a759e859 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -65,7 +65,7 @@ async function runCloudtentaclesHealthAccount( ): Promise { const sourceKey = String(account.sourceKey || '').trim() || 'default' const source = getCloudtentaclesSourceByKey(sourceKey) - const session = getCloudtentaclesSessionStateByKey(sourceKey) || {} + const session: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {} const label = String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500) const checkedAt = new Date().toISOString() diff --git a/apps/backend/src/startup/state.ts b/apps/backend/src/startup/state.ts index 2f0de5f9..53f36847 100644 --- a/apps/backend/src/startup/state.ts +++ b/apps/backend/src/startup/state.ts @@ -1,6 +1,26 @@ -export type StartupState = ReturnType; +type StartupProcessError = { + time: string; + message: string; +}; -export function createStartupState() { +export type StartupState = { + phase: string; + startedAt: string; + core: { + ready: boolean; + running: boolean; + attemptCount: number; + readyAt: string; + lastAttemptAt: string; + lastError: string; + }; + process: { + lastUnhandledRejection: StartupProcessError | null; + lastUncaughtException: StartupProcessError | null; + }; +}; + +export function createStartupState(): StartupState { return { phase: "starting", startedAt: new Date().toISOString(), diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index 7a83dcf5..15c9f918 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -29,13 +29,16 @@ export function buildSuccessPayload(data: unknown, msg: string = "ok") { export function buildErrorPayload(error: unknown, fallbackMessage: string) { const classification = classifyRouteError(error); + const message = classification.statusCode >= 500 + ? fallbackMessage || "服务内部错误" + : error instanceof Error ? error.message : fallbackMessage; return { code: 1, - msg: error instanceof Error ? error.message : fallbackMessage, + msg: message, errorCode: classification.errorCode, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } @@ -62,7 +65,7 @@ export function buildNotFoundPayload(req: Request) { code: 1, msg: `未实现接口: ${req.method} ${req.originalUrl}`, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } diff --git a/apps/backend/src/utils/logger.test.ts b/apps/backend/src/utils/logger.test.ts index b9d53227..dfe5efe2 100644 --- a/apps/backend/src/utils/logger.test.ts +++ b/apps/backend/src/utils/logger.test.ts @@ -102,3 +102,27 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod assert.match(text, /未登录或登录已失效/) assert.doesNotMatch(text, /\u001B\[/) }) + +test('formatLogEntry masks sensitive fields in inline and nested details', () => { + const text = formatLogEntry({ + time: '2026-04-14T10:36:24.974Z', + level: 'info', + scope: '[security]', + message: 'masked', + pid: 4671, + detail: { + token: 'abcdef1234567890', + authorization: 'Bearer abcdef1234567890', + nested: { + cookie: 'sessionid=abcdef1234567890', + note: 'password=super-secret-value', + }, + }, + }, { color: false }) + + assert.doesNotMatch(text, /abcdef1234567890/) + assert.doesNotMatch(text, /super-secret-value/) + assert.match(text, /abcdef\*\*\*\*567890/) + assert.match(text, /Bearer\*\*\*\*567890/) + assert.match(text, /password=super-\*\*\*\*-value/) +}) diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index 5f3680fe..ee0f9a8a 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -5,15 +5,29 @@ import util from 'node:util' import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' import type { HttpErrorLike } from './http.js' +import { maskSecret } from './masking.js' -const LOG_LEVEL_PRIORITY = { +type LogLevel = 'debug' | 'info' | 'warn' | 'error' +type LogChannel = 'app' | 'integration' +type LogDetail = unknown +type InlineFields = Record +type LogEntry = { + time: string + level: LogLevel + scope: string + message: string + pid: number + detail?: unknown +} + +const LOG_LEVEL_PRIORITY: Record = { debug: 10, info: 20, warn: 30, error: 40, } -const LEVEL_LABELS = { +const LEVEL_LABELS: Record = { debug: 'DEBUG', info: 'INFO ', warn: 'WARN ', @@ -36,35 +50,48 @@ const LOG_DIR = path.join(DATA_ROOT, 'logs') const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) const LOG_RETENTION_DAYS = 7 const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) +const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i +const MAX_SANITIZE_DEPTH = 8 let writeQueue = Promise.resolve() let lastCleanupDateKey = '' -export function createRequestId(prefix = 'req') { +export function createRequestId(prefix = 'req'): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } -export function logDebug(scope, message, detail = undefined) { +export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('debug', scope, message, detail) } -export function logInfo(scope, message, detail = undefined) { +export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('info', scope, message, detail) } -export function logWarn(scope, message, detail = undefined) { +export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('warn', scope, message, detail) } -export function logError(scope, message, detail = undefined) { +export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('error', scope, message, detail) } -export function logIntegration(scope, message, detail = undefined, { level = 'info' } = {}) { +export function logIntegration( + scope: unknown, + message: unknown, + detail: LogDetail = undefined, + { level = 'info' }: { level?: LogLevel } = {}, +) { return writeLog(level, scope, message, detail, { channel: 'integration' }) } -function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { +function writeLog( + level: LogLevel, + scope: unknown, + message: unknown, + detail: LogDetail, + { channel = 'app' }: { channel?: LogChannel } = {}, +): LogEntry | null { if (!shouldWriteLog(level, ACTIVE_LOG_LEVEL)) { return null } @@ -83,7 +110,7 @@ function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { return entry } -function enqueueFileWrite(entry, filePath) { +function enqueueFileWrite(entry: LogEntry, filePath: string): void { const line = formatLogEntry(entry, { color: false }) const dateKey = extractLogDateKey(entry.time) @@ -99,12 +126,12 @@ function enqueueFileWrite(entry, filePath) { }) } -function writeConsole(entry) { +function writeConsole(entry: LogEntry): void { const logger = resolveConsoleMethod(entry.level) logger(formatLogEntry(entry, { color: supportsAnsiColor() })) } -function resolveConsoleMethod(level) { +function resolveConsoleMethod(level: unknown) { const normalized = normalizeLogLevel(level) if (normalized === 'error') { @@ -118,26 +145,26 @@ function resolveConsoleMethod(level) { return console.log } -export function normalizeLogLevel(level) { +export function normalizeLogLevel(level: unknown): LogLevel { const normalized = String(level || '').trim().toLowerCase() - return LOG_LEVEL_PRIORITY[normalized] != null ? normalized : 'info' + return isLogLevel(normalized) ? normalized : 'info' } -export function shouldWriteLog(level, configuredLevel = ACTIVE_LOG_LEVEL) { +export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean { const normalizedLevel = normalizeLogLevel(level) const normalizedConfigured = normalizeLogLevel(configuredLevel) return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] } -export function formatLogEntry(entry, { color = false } = {}) { +export function formatLogEntry(entry: Partial, { color = false }: { color?: boolean } = {}): string { const normalizedEntry = { time: String(entry?.time || new Date().toISOString()), level: normalizeLogLevel(entry?.level), scope: String(entry?.scope || 'app').trim() || 'app', message: String(entry?.message || '').trim() || '-', pid: Number(entry?.pid || process.pid), - detail: entry?.detail, + detail: sanitizeLogValue(entry?.detail), } const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color) @@ -166,16 +193,16 @@ export function formatLogEntry(entry, { color = false } = {}) { return `${firstLine}\n${indentBlock(inspected, ' ')}` } -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) { +export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app' return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) } export function resolveExpiredLogFilenames( - fileNames = [], + fileNames: string[] = [], referenceTime = new Date().toISOString(), retentionDays = LOG_RETENTION_DAYS, -) { +): string[] { const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS) const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) @@ -183,8 +210,8 @@ export function resolveExpiredLogFilenames( .filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey)) } -export function formatLogTimestamp(value) { - const date = new Date(value) +export function formatLogTimestamp(value: unknown): string { + const date = new Date(value instanceof Date ? value : String(value || '')) if (Number.isNaN(date.getTime())) { return String(value || '').trim() || new Date().toISOString() @@ -193,27 +220,28 @@ export function formatLogTimestamp(value) { return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` } -function resolveDataRoot() { +function resolveDataRoot(): string { const configured = String(runtimeConfig.data?.root || '').trim() return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') } -function normalizeLogValue(value) { +function normalizeLogValue(value: unknown): unknown { if (typeof value === 'undefined') { return undefined } try { - return JSON.parse( + return sanitizeLogValue(JSON.parse( JSON.stringify(value, (_key, current) => { if (current instanceof Error) { const currentError = current as Error & HttpErrorLike return { name: current.name, - message: current.message, - stack: current.stack, + message: sanitizeLogString(current.message), + stack: sanitizeLogString(current.stack), statusCode: currentError.statusCode, errorCode: currentError.errorCode, + context: sanitizeLogValue(currentError.context), } } @@ -223,13 +251,71 @@ function normalizeLogValue(value) { return current }), - ) + )) } catch { - return String(value) + return sanitizeLogString(String(value)) } } -function splitDetailPayload(detail) { +function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { + if (typeof value === 'undefined') { + return undefined + } + + if (value === null) { + return null + } + + if (isSensitiveLogKey(key)) { + return maskSecret(value) + } + + if (typeof value === 'string') { + return sanitizeLogString(value) + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return String(value) + } + + if (depth >= MAX_SANITIZE_DEPTH) { + return '[MaxDepth]' + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeLogValue(item, key, depth + 1)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([currentKey, currentValue]) => [ + currentKey, + sanitizeLogValue(currentValue, currentKey, depth + 1), + ]), + ) + } + + return sanitizeLogString(String(value)) +} + +function sanitizeLogString(value: unknown): string { + return String(value || '') + .replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`) + .replace( + /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, + (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, + ) +} + +function isSensitiveLogKey(key: unknown): boolean { + return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) +} + +function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } { if (typeof detail === 'undefined') { return { inline: {}, @@ -252,8 +338,8 @@ function splitDetailPayload(detail) { } if (detail && typeof detail === 'object') { - const inline = {} - const block = {} + const inline: InlineFields = {} + const block: Record = {} for (const [key, value] of Object.entries(detail)) { if (isScalarLogValue(value)) { @@ -276,14 +362,14 @@ function splitDetailPayload(detail) { } } -function formatInlineFields(fields) { +function formatInlineFields(fields: InlineFields): string { return Object.entries(fields) .filter(([, value]) => typeof value !== 'undefined' && value !== '') .map(([key, value]) => `${key}=${formatInlineValue(value)}`) .join(' ') } -function formatInlineValue(value) { +function formatInlineValue(value: unknown): string { if (value === null) { return 'null' } @@ -296,22 +382,22 @@ function formatInlineValue(value) { return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) } -function isScalarLogValue(value) { +function isScalarLogValue(value: unknown): value is string | number | boolean | null { return value === null || ['string', 'number', 'boolean'].includes(typeof value) } -function indentBlock(text, indent) { +function indentBlock(text: unknown, indent: string): string { return String(text || '') .split('\n') .map((line) => `${indent}${line}`) .join('\n') } -function supportsAnsiColor() { +function supportsAnsiColor(): boolean { return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) } -function resolveLevelColor(level) { +function resolveLevelColor(level: unknown): string { switch (normalizeLogLevel(level)) { case 'debug': return ANSI.blue @@ -324,7 +410,7 @@ function resolveLevelColor(level) { } } -function colorize(text, ansiCode, enabled) { +function colorize(text: string, ansiCode: string, enabled: boolean): string { if (!enabled || !ansiCode) { return text } @@ -332,15 +418,15 @@ function colorize(text, ansiCode, enabled) { return `${ansiCode}${text}${ANSI.reset}` } -function formatLocalDate(date) { +function formatLocalDate(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } -function formatLocalTime(date) { +function formatLocalTime(date: Date): string { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` } -function formatTimezoneOffset(date) { +function formatTimezoneOffset(date: Date): string { const totalMinutes = -date.getTimezoneOffset() const sign = totalMinutes >= 0 ? '+' : '-' const absoluteMinutes = Math.abs(totalMinutes) @@ -349,7 +435,7 @@ function formatTimezoneOffset(date) { return `${sign}${hours}:${minutes}` } -async function cleanupExpiredLogsIfNeeded(dateKey) { +async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { if (!dateKey || dateKey === lastCleanupDateKey) { return } @@ -366,13 +452,13 @@ async function cleanupExpiredLogsIfNeeded(dateKey) { } } -function extractLogDateKey(time) { +function extractLogDateKey(time: unknown): string { const normalized = String(time || '').trim() return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized) } -function toDateKey(value) { - const date = new Date(value) +function toDateKey(value: unknown): string { + const date = new Date(value instanceof Date ? value : String(value || '')) if (Number.isNaN(date.getTime())) { return new Date().toISOString().slice(0, 10) @@ -381,13 +467,13 @@ function toDateKey(value) { return date.toISOString().slice(0, 10) } -function offsetDate(value, offsetDays) { - const date = new Date(value) +function offsetDate(value: unknown, offsetDays: unknown): Date { + const date = new Date(value instanceof Date ? value : String(value || '')) date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0)) return date } -function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { +function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { return true } @@ -398,5 +484,9 @@ function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { return false } - return matched[2] < cutoffDateKey + return String(matched[2] || '') < cutoffDateKey +} + +function isLogLevel(value: string): value is LogLevel { + return ['debug', 'info', 'warn', 'error'].includes(value) } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index a0fdbe5b..cb7c5ef6 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": true, "noEmit": true, "strict": false, + "noImplicitAny": true, "skipLibCheck": true, "types": ["node"], "lib": ["ES2022"] diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index 13e55903..dfb08252 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -106,6 +106,7 @@ function shouldClearAdminSession(errorCode: string) { 'admin_auth_invalid', 'admin_auth_expired', 'admin_auth_user_invalid', + 'admin_auth_stale', ].includes(errorCode) } From 49ac82698ff972d38c93259954f4ffb9339a8dc1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 08:32:15 +0800 Subject: [PATCH 17/73] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B9=82=E7=AD=89=E4=B8=8E=E9=99=90=E6=B5=81?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/middleware/rate-limit.test.ts | 116 +++++++++++++++++ apps/backend/src/middleware/rate-limit.ts | 122 ++++++++++++++++++ apps/backend/src/repositories/task-repo.ts | 49 +++++++ apps/backend/src/routes/admin/auth.ts | 8 +- apps/backend/src/routes/claims.ts | 17 +++ apps/backend/src/routes/open-91.ts | 13 +- .../claim/kuaishou-cloud-claim-service.ts | 54 +++++++- 7 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/middleware/rate-limit.test.ts create mode 100644 apps/backend/src/middleware/rate-limit.ts diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts new file mode 100644 index 00000000..9da2755f --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { NextFunction, Request, Response } from "express"; + +import { + createRateLimitMiddleware, + resetRateLimitBucketsForTest, +} from "./rate-limit.js"; + +test("createRateLimitMiddleware allows requests within the window", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:allow", + windowMs: 60_000, + max: 2, + }); + const req = createMockRequest(); + const res = createMockResponse(); + let nextCount = 0; + const next: NextFunction = () => { + nextCount += 1; + }; + + limiter(req, res, next); + limiter(req, res, next); + + assert.equal(nextCount, 2); + assert.equal(res.statusCode, 200); +}); + +test("createRateLimitMiddleware blocks requests over the limit", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:block", + windowMs: 60_000, + max: 1, + }); + const req = createMockRequest(); + const res = createMockResponse(); + let nextCount = 0; + const next: NextFunction = () => { + nextCount += 1; + }; + + limiter(req, res, next); + limiter(req, res, next); + + assert.equal(nextCount, 1); + assert.equal(res.statusCode, 429); + assert.equal(res.headers["Retry-After"], "60"); + assert.equal(res.body.errorCode, "rate_limited"); +}); + +test("createRateLimitMiddleware supports custom limit responses", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:custom", + windowMs: 60_000, + max: 1, + onLimit: (_req, res) => { + res.status(200).json({ code: 429, message: "limited" }); + }, + }); + const req = createMockRequest(); + const res = createMockResponse(); + + limiter(req, res, () => {}); + limiter(req, res, () => {}); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { code: 429, message: "limited" }); +}); + +function createMockRequest(): Request { + return { + ip: "127.0.0.1", + originalUrl: "/test", + headers: {}, + socket: { + remoteAddress: "127.0.0.1", + }, + } as Request; +} + +function createMockResponse(): Response & { + statusCode: number; + headers: Record; + body: any; +} { + const res = { + statusCode: 200, + headers: {} as Record, + body: null as any, + setHeader(name: string, value: string) { + this.headers[name] = value; + return this; + }, + status(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + json(body: any) { + this.body = body; + return this; + }, + }; + + return res as Response & { + statusCode: number; + headers: Record; + body: any; + }; +} diff --git a/apps/backend/src/middleware/rate-limit.ts b/apps/backend/src/middleware/rate-limit.ts new file mode 100644 index 00000000..2b9a2d41 --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.ts @@ -0,0 +1,122 @@ +import type { NextFunction, Request, Response } from "express"; + +import { buildErrorPayload, createHttpError } from "../utils/http.js"; +import { logWarn } from "../utils/logger.js"; + +type RateLimitKeyResolver = (req: Request) => string; + +type RateLimitExceededContext = { + scope: string; + retryAfterSeconds: number; +}; + +type RateLimitOptions = { + scope: string; + windowMs: number; + max: number; + key?: RateLimitKeyResolver; + onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void; +}; + +type RateLimitBucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); +let lastCleanupAt = 0; + +export function createRateLimitMiddleware({ + scope, + windowMs, + max, + key = defaultRateLimitKey, + onLimit, +}: RateLimitOptions) { + const normalizedScope = String(scope || "default").trim() || "default"; + const normalizedWindowMs = Math.max(1000, Number(windowMs || 0)); + const normalizedMax = Math.max(1, Number(max || 0)); + + return (req: Request, res: Response, next: NextFunction): void => { + const now = Date.now(); + cleanupExpiredBuckets(now); + + const bucketKey = `${normalizedScope}:${key(req)}`; + const current = buckets.get(bucketKey); + const bucket = current && current.resetAt > now + ? current + : { count: 0, resetAt: now + normalizedWindowMs }; + + bucket.count += 1; + buckets.set(bucketKey, bucket); + + if (bucket.count <= normalizedMax) { + next(); + return; + } + + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + res.setHeader("Retry-After", String(retryAfterSeconds)); + + logWarn("[rate-limit]", "请求触发限流", { + scope: normalizedScope, + ip: req.ip, + originalUrl: req.originalUrl, + retryAfterSeconds, + }); + + if (onLimit) { + onLimit(req, res, { + scope: normalizedScope, + retryAfterSeconds, + }); + return; + } + + const error = createHttpError("请求过于频繁,请稍后再试", { + statusCode: 429, + errorCode: "rate_limited", + }); + res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试")); + }; +} + +export function resetRateLimitBucketsForTest(): void { + buckets.clear(); + lastCleanupAt = 0; +} + +export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver { + return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":"); +} + +export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver { + return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":"); +} + +function defaultRateLimitKey(req: Request): string { + return normalizeKeyPart( + req.ip || + String(req.headers["x-forwarded-for"] || "").split(",")[0] || + req.socket.remoteAddress || + "unknown", + ); +} + +function normalizeKeyPart(value: unknown): string { + const normalized = String(value || "").trim().toLowerCase(); + return normalized || "unknown"; +} + +function cleanupExpiredBuckets(now: number): void { + if (now - lastCleanupAt < 60_000) { + return; + } + + lastCleanupAt = now; + for (const [key, bucket] of buckets.entries()) { + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } +} diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 2dab2c69..00008f90 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -213,6 +213,55 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch }) } +export async function updateTaskStatusIfCurrent( + taskId: number | string, + currentStatus: string, + patch: TaskUpdatePatch, +): Promise { + const now = String(patch.updated_at || '').trim() + const result = await query<{ id: number }>( + ` + UPDATE fulfillment_tasks + SET + task_status = $1, + delivery_status = COALESCE($2, delivery_status), + result_code = COALESCE($3, result_code), + result_message = COALESCE($4, result_message), + user_action_status = COALESCE($5, user_action_status), + last_error = COALESCE($6, last_error), + context_json = COALESCE($7::jsonb, context_json), + redeemed_at = COALESCE($8, redeemed_at), + updated_at = COALESCE($9, updated_at) + WHERE id = $10 + AND task_status = $11 + RETURNING id + `, + [ + patch.task_status, + patch.delivery_status ?? null, + patch.result_code ?? null, + patch.result_message ?? null, + patch.user_action_status ?? null, + patch.last_error ?? null, + typeof patch.context_json === 'undefined' + ? null + : typeof patch.context_json === 'string' + ? patch.context_json + : JSON.stringify(patch.context_json || {}), + patch.redeemed_at ?? null, + now || null, + Number(taskId), + currentStatus, + ], + ) + + if (!result.rows[0]) { + return null + } + + return getTaskById(taskId) +} + export async function getTaskById(taskId: number | string): Promise { return getTaskByIdWithExecutor(query, taskId) } diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index ed210f73..2bea2c5e 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -1,11 +1,17 @@ import { Router } from 'express' +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js' import { createJsonHandler, extractBearerToken } from './shared.js' const router = Router() -router.post('/auth/login', createJsonHandler( +router.post('/auth/login', createRateLimitMiddleware({ + scope: 'admin:login', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), +}), createJsonHandler( (req) => loginAdmin(req.body?.username, req.body?.password), { successMessage: '登录成功', diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index 659b9573..f8eb6df8 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -1,5 +1,6 @@ import { Router } from "express"; +import { createRateLimitMiddleware, getParamRateLimitKey } from "../middleware/rate-limit.js"; import { confirmKuaishouCloudClaimRole, getKuaishouCloudClaimDetail, @@ -14,9 +15,22 @@ import { } from "../utils/http.js"; const router = Router(); +const claimReadRateLimit = createRateLimitMiddleware({ + scope: "claim:read", + windowMs: 60_000, + max: 120, + key: getParamRateLimitKey("token"), +}); +const claimWriteRateLimit = createRateLimitMiddleware({ + scope: "claim:write", + windowMs: 60_000, + max: 30, + key: getParamRateLimitKey("token"), +}); router.get( "/:token", + claimReadRateLimit, createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { errorMessage: "查询快手领取详情失败", scope: "[claims/:token]", @@ -25,6 +39,7 @@ router.get( router.post( "/:token/kuaishou-cloud/verify-ticket", + claimWriteRateLimit, createRouteHandler( (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), { @@ -37,6 +52,7 @@ router.post( router.post( "/:token/kuaishou-cloud/confirm-role", + claimWriteRateLimit, createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { successMessage: "角色已确认", errorMessage: "确认角色失败", @@ -46,6 +62,7 @@ router.post( router.post( "/:token/kuaishou-cloud/redeem", + claimWriteRateLimit, createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { successMessage: "兑换请求已提交", errorMessage: "兑换失败", diff --git a/apps/backend/src/routes/open-91.ts b/apps/backend/src/routes/open-91.ts index 879a281a..af0a1d34 100644 --- a/apps/backend/src/routes/open-91.ts +++ b/apps/backend/src/routes/open-91.ts @@ -1,13 +1,22 @@ import { Router } from 'express' +import { createRateLimitMiddleware } from '../middleware/rate-limit.js' import { createOpen91Order } from '../services/open-91/order-create-service.js' import { queryOpen91Order } from '../services/open-91/order-query-service.js' import { buildOpen91ErrorResponse } from '../services/open-91/shared.js' import { createRequestId, logIntegration } from '../utils/logger.js' const router = Router() +const open91RateLimit = createRateLimitMiddleware({ + scope: 'open91', + windowMs: 60_000, + max: 120, + onLimit: (_req, res) => { + res.status(200).json(buildOpen91ErrorResponse('请求过于频繁,请稍后再试', 429)) + }, +}) -router.post('/orders/create', async (req, res) => { +router.post('/orders/create', open91RateLimit, async (req, res) => { const requestId = createRequestId('91') const startedAt = Date.now() @@ -39,7 +48,7 @@ router.post('/orders/create', async (req, res) => { } }) -router.post('/orders/query', async (req, res) => { +router.post('/orders/query', open91RateLimit, async (req, res) => { const requestId = createRequestId('91') const startedAt = Date.now() diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 397e40dc..15bc3847 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { PROJECT_ROOT } from '../../config/runtime.js' import { createTaskEvent } from '../../repositories/task-event-repo.js' -import { updateTask } from '../../repositories/task-repo.js' +import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { @@ -201,7 +201,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { }) } - if (['role_confirmed', 'dispatched_pending_return', 'completed'].includes(String(context.task.task_status || '').trim())) { + if (['role_confirmed', 'redeeming', 'dispatched_pending_return', 'completed', 'manual_review'].includes(String(context.task.task_status || '').trim())) { return getKuaishouCloudClaimDetail(token) } @@ -257,18 +257,58 @@ export async function redeemKuaishouCloudClaim(token: unknown) { }) } - if (context.task.task_status !== 'role_confirmed') { + const currentStatus = String(context.task.task_status || '').trim() + if (['redeeming', 'dispatched_pending_return', 'completed', 'manual_review'].includes(currentStatus)) { + return getKuaishouCloudClaimDetail(token) + } + + if (currentStatus !== 'role_confirmed') { throw createHttpError('请先确认角色信息', { statusCode: 409, errorCode: 'claim_kuaishou_cloud_role_not_confirmed', }) } - await dispatchKuaishouCloudFulfillmentTask(context.task, { - source: 'claim_page_redeem', - actor: { source: 'claim_page' }, - autoFinalize: true, + const lockedTask = await updateTaskStatusIfCurrent(context.task.id, 'role_confirmed', { + task_status: 'redeeming', + user_action_status: 'not_required', + last_error: '', + updated_at: now, }) + if (!lockedTask) { + return getKuaishouCloudClaimDetail(token) + } + + try { + await dispatchKuaishouCloudFulfillmentTask(lockedTask, { + source: 'claim_page_redeem', + actor: { source: 'claim_page' }, + autoFinalize: true, + }) + } catch (error) { + const latestTask = await getTaskById(lockedTask.id) + if (latestTask && String(latestTask.task_status || '').trim() !== 'redeeming') { + return getKuaishouCloudClaimDetail(token) + } + + const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理' + await updateTask(lockedTask.id, { + task_status: 'manual_review', + user_action_status: 'not_required', + last_error: message, + result_code: 'kuaishou_cloud_redeem_failed', + result_message: message, + updated_at: nowIso(), + }) + + await createTaskEvent(lockedTask.id, 'kuaishou_cloud_redeem_failed', { + source: 'claim_page_redeem', + errorMessage: message, + }, nowIso()) + + throw error + } + return getKuaishouCloudClaimDetail(token) } From 7aef1a6d50d573ce9154211909485da3677ddef7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 08:45:06 +0800 Subject: [PATCH 18/73] =?UTF-8?q?=E5=BC=80=E5=90=AF=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E7=A9=BA=E5=80=BC=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/admin/orders.ts | 2 +- .../routes/admin/platform-config/ninetyone.ts | 4 +-- apps/backend/src/routes/admin/shared.ts | 11 +++++++ apps/backend/src/routes/admin/tasks.ts | 4 +-- apps/backend/src/routes/admin/users.ts | 8 ++--- .../src/services/admin/admin-auth-service.ts | 30 +++++++++++++++++-- .../src/services/admin/admin-read-service.ts | 3 +- .../admin/admin-read-shared-helpers.ts | 2 +- .../platform-config/cloudtentacles-service.ts | 4 +-- .../claim/kuaishou-cloud-claim-context.ts | 2 +- .../claim/kuaishou-cloud-claim-service.ts | 2 +- .../claim/kuaishou-cloud-sync-service.ts | 4 +-- .../kuaishou-cloud-task-service.ts | 4 +-- .../kuaishou-cloud/cloudtentacles-context.ts | 10 ++++++- .../kuaishou-cloud/task-finalization.ts | 6 ++++ .../services/order/delivery-task-service.ts | 26 ++++++++++------ .../cloudtentacles/debug-flow-service.ts | 6 ++-- .../kuaishou-eticket/source-config-service.ts | 2 +- .../src/services/scheduler/config-service.ts | 4 ++- apps/backend/src/utils/http.ts | 4 +-- apps/backend/tsconfig.json | 1 + 21 files changed, 101 insertions(+), 38 deletions(-) diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index bbdbc471..eb4f9d6d 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -19,7 +19,7 @@ router.get('/orders', createJsonHandler( )) router.get('/orders/:orderId', createJsonHandler( - (req) => getAdminOrderDetail((req.params as AdminOrderRouteParams).orderId), + (req) => getAdminOrderDetail(String((req.params as AdminOrderRouteParams).orderId || '')), { successMessage: 'ok', errorMessage: '读取订单详情失败', diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts index 1da89d08..767112fe 100644 --- a/apps/backend/src/routes/admin/platform-config/ninetyone.ts +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -23,7 +23,7 @@ router.get( router.post( "/ninetyone/orders/:id/retry", createJsonHandler( - (req) => retryAdminNinetyoneOrder((req.params as AdminEntityRouteParams).id), + (req) => retryAdminNinetyoneOrder(String((req.params as AdminEntityRouteParams).id || "")), { successMessage: "91卡券订单已重试", errorMessage: "重试 91卡券订单失败", @@ -49,7 +49,7 @@ router.post( createJsonHandler( (req) => failAdminNinetyoneOrder( - (req.params as AdminEntityRouteParams).id, + String((req.params as AdminEntityRouteParams).id || ""), req.body as { reason?: string } ), { diff --git a/apps/backend/src/routes/admin/shared.ts b/apps/backend/src/routes/admin/shared.ts index a00e302f..5bd5d5a3 100644 --- a/apps/backend/src/routes/admin/shared.ts +++ b/apps/backend/src/routes/admin/shared.ts @@ -73,6 +73,17 @@ export function requireAdminRoles(allowedRoles: string[]) { } } +export function getRequiredAdminSession(req: Request): AdminSession { + if (req.adminSession) { + return req.adminSession + } + + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) +} + async function recordAdminAudit( session: AdminSession | null | undefined, req: Request, diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 038d89af..68ccc81d 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -24,8 +24,8 @@ import type { AdminTaskActionResponse } from "../../types/admin-write-models.js" const router = Router(); -function getTaskId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).taskId; +function getTaskId(req: Request): string { + return String((req.params as AdminTaskRouteParams).taskId || ""); } router.get( diff --git a/apps/backend/src/routes/admin/users.ts b/apps/backend/src/routes/admin/users.ts index 45f532b5..66f16973 100644 --- a/apps/backend/src/routes/admin/users.ts +++ b/apps/backend/src/routes/admin/users.ts @@ -7,7 +7,7 @@ import { updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './shared.js' type AdminUserMutationResult = { user: { @@ -54,7 +54,7 @@ router.post('/users', createJsonHandler( )) router.post('/users/:userId/role', createJsonHandler( - (req) => updateManagedAdminUserRole(String(req.params.userId), req.body, req.adminSession), + (req) => updateManagedAdminUserRole(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)), { successMessage: '用户角色已更新', errorMessage: '更新用户角色失败', @@ -75,7 +75,7 @@ router.post('/users/:userId/role', createJsonHandler( )) router.post('/users/:userId/status', createJsonHandler( - (req) => updateManagedAdminUserStatus(String(req.params.userId), req.body, req.adminSession), + (req) => updateManagedAdminUserStatus(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)), { successMessage: '用户状态已更新', errorMessage: '更新用户状态失败', @@ -96,7 +96,7 @@ router.post('/users/:userId/status', createJsonHandler( )) router.post('/users/:userId/reset-password', createJsonHandler( - (req) => resetManagedAdminUserPassword(String(req.params.userId), req.body), + (req) => resetManagedAdminUserPassword(String(req.params.userId || ''), req.body), { successMessage: '用户密码已重置', errorMessage: '重置用户密码失败', diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index 13d2294a..4fb558ba 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -171,8 +171,8 @@ export async function getAdminSessionSummary(token: unknown): Promise> { +): Promise { return syncDeliveryTasksForOrderWithDeps(order, orderItems) } @@ -77,7 +77,7 @@ export async function syncDeliveryTasksForOrderWithDeps( order: OrderRow, orderItems: OrderItemRow[], deps: DeliveryTaskDeps = {}, -): Promise> { +): Promise { const { createTask: createDeliveryTask = createTask, listTasksByOrderId: listTasks = listTasksByOrderId, @@ -104,11 +104,12 @@ export async function syncDeliveryTasksForOrderWithDeps( } const itemMap = new Map(orderItems.map((item) => [item.id, item])) - return Promise.all(existingTasks.map((task) => preparePaidTask({ + const preparedTasks = await 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 || '', }, runtimeDeps))) + return preparedTasks.filter(isTaskRow) } const tasks: DeliveryTaskRow[] = [] @@ -243,11 +244,13 @@ export async function syncDeliveryTasksForOrderWithDeps( updatedAt: createdAt, }) - tasks.push({ - ...task, - skuCode: item.sku_code, - skuName: item.sku_name, - }) + if (task) { + tasks.push({ + ...task, + skuCode: item.sku_code, + skuName: item.sku_name, + }) + } } } @@ -255,7 +258,8 @@ export async function syncDeliveryTasksForOrderWithDeps( return tasks } - return Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) + const preparedTasks = await Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) + return preparedTasks.filter(isTaskRow) } async function preparePaidTask( @@ -380,6 +384,10 @@ function parseJsonObject(value: unknown): JsonObject { } } +function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is TaskRow { + return Boolean(task && Number(task.id || 0) > 0) +} + function isKuaishouCloudExecutor(value: unknown): boolean { return String(value || '').trim() === 'kuaishou_ct_assisted' } diff --git a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts index affde4f3..4f933f65 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts @@ -181,8 +181,10 @@ async function runFlowStep( runner: () => Promise, options: FlowStepOptions = {}, ) { - const retries = Number.isInteger(options.retries) && options.retries > 0 ? options.retries : 0 - const retryDelayMs = Number.isFinite(options.retryDelayMs) && options.retryDelayMs > 0 ? options.retryDelayMs : 1000 + const rawRetries = Number(options.retries) + const rawRetryDelayMs = Number(options.retryDelayMs) + const retries = Number.isInteger(rawRetries) && rawRetries > 0 ? rawRetries : 0 + const retryDelayMs = Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000 for (let attempt = 0; attempt <= retries; attempt += 1) { try { diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts index 2e2a2c08..716ce122 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts @@ -100,7 +100,7 @@ function normalizeKuaishouEticketSourceConfig(rawValue: unknown): KuaishouEticke baseUrl: String(source.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL, shops: shops .map((item) => normalizeKuaishouEticketShopConfig(item)) - .filter(Boolean), + .filter((item): item is KuaishouEticketShopConfig => Boolean(item)), } } diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 3aa053da..21a11a05 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -37,7 +37,9 @@ function loadScheduledJobsConfigFromFile() { export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] - const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean) + const jobs = rawJobs + .map((item) => normalizeScheduledJob(item)) + .filter((item): item is ReturnType => Boolean(item)) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) if (!hasCloudtentaclesHealth) { diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index 15c9f918..7e1c0569 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -45,8 +45,8 @@ export function buildErrorPayload(error: unknown, fallbackMessage: string) { export function sendRouteError( res: Response, error: unknown, - fallbackMessage: string, - scope: string + fallbackMessage = "服务内部错误", + scope = "[route]" ): void { const classification = classifyRouteError(error); const logger = classification.statusCode >= 500 ? logError : logWarn; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index cb7c5ef6..da0b9aed 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -8,6 +8,7 @@ "noEmit": true, "strict": false, "noImplicitAny": true, + "strictNullChecks": true, "skipLibCheck": true, "types": ["node"], "lib": ["ES2022"] From 36dfc46884cc77b12182dbed6ad2cfea69fb97e9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 08:57:19 +0800 Subject: [PATCH 19/73] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=91=BD=E5=90=8D=E4=B8=8E=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=BD=92=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/admin.ts | 2 +- apps/backend/src/routes/admin/audit-logs.ts | 2 +- apps/backend/src/routes/admin/auth.ts | 2 +- apps/backend/src/routes/admin/dashboard.ts | 2 +- apps/backend/src/routes/admin/orders.ts | 2 +- .../src/routes/admin/platform-config.ts | 2 +- .../admin/platform-config/cloudtentacles.ts | 4 +-- .../kuaishou-cloud-fulfillment.ts | 4 +-- .../admin/platform-config/kuaishou-eticket.ts | 4 +-- .../routes/admin/platform-config/ninetyone.ts | 4 +-- .../admin/platform-config/notifications.ts | 4 +-- .../routes/admin/{shared.ts => session.ts} | 0 apps/backend/src/routes/admin/tasks.ts | 2 +- apps/backend/src/routes/admin/users.ts | 2 +- .../{write/shared.ts => write-helpers.ts} | 6 ++-- .../admin/write/kuaishou-cloud-actions.ts | 2 +- .../claim/kuaishou-cloud-claim-service.ts | 2 +- .../claim/kuaishou-cloud-sync-service.ts | 2 +- .../fulfillment/kuaishou-cloud/domain.ts | 2 +- .../index.ts} | 36 +++++++++---------- .../services/open-91/order-query-service.ts | 2 +- .../cloudtentacles/catalog-service.ts | 2 +- .../cloudtentacles/crypto-service.ts | 2 +- .../cloudtentacles/{shared.ts => helpers.ts} | 0 .../platforms/cloudtentacles/http-client.ts | 2 +- .../cloudtentacles/knapsack-service.ts | 2 +- .../cloudtentacles/session-service.ts | 2 +- .../cloudtentacles/virtual-number-service.ts | 2 +- .../kuaishou-eticket/consume-service.ts | 2 +- .../{shared.ts => helpers.ts} | 0 .../platforms/kuaishou-eticket/http-client.ts | 2 +- .../kuaishou-eticket/info-service.ts | 2 +- .../shared.ts => types/json.ts} | 0 apps/frontend/src/services/admin.ts | 1 - 34 files changed, 53 insertions(+), 54 deletions(-) rename apps/backend/src/routes/admin/{shared.ts => session.ts} (100%) rename apps/backend/src/services/admin/{write/shared.ts => write-helpers.ts} (91%) rename apps/backend/src/services/fulfillment/{kuaishou-cloud-task-service.ts => kuaishou-cloud/index.ts} (95%) rename apps/backend/src/services/platforms/cloudtentacles/{shared.ts => helpers.ts} (100%) rename apps/backend/src/services/platforms/kuaishou-eticket/{shared.ts => helpers.ts} (100%) rename apps/backend/src/{routes/admin/platform-config/shared.ts => types/json.ts} (100%) delete mode 100644 apps/frontend/src/services/admin.ts diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 2c56d851..108acb37 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -5,7 +5,7 @@ import auditLogsRouter from "./admin/audit-logs.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 { requireAdminSession } from "./admin/session.js"; import tasksRouter from "./admin/tasks.js"; import usersRouter from "./admin/users.js"; import { buildNotFoundPayload } from "../utils/http.js"; diff --git a/apps/backend/src/routes/admin/audit-logs.ts b/apps/backend/src/routes/admin/audit-logs.ts index 1e9b7076..bbb6d7fb 100644 --- a/apps/backend/src/routes/admin/audit-logs.ts +++ b/apps/backend/src/routes/admin/audit-logs.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminAuditLogs } from '../../services/admin/admin-audit-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +import { createJsonHandler, requireAdminRoles } from './session.js' type AdminAuditLogRouteQuery = Record diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index 2bea2c5e..dd039764 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js' -import { createJsonHandler, extractBearerToken } from './shared.js' +import { createJsonHandler, extractBearerToken } from './session.js' const router = Router() diff --git a/apps/backend/src/routes/admin/dashboard.ts b/apps/backend/src/routes/admin/dashboard.ts index 5924be92..5ffc5d69 100644 --- a/apps/backend/src/routes/admin/dashboard.ts +++ b/apps/backend/src/routes/admin/dashboard.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js' -import { createJsonHandler } from './shared.js' +import { createJsonHandler } from './session.js' const router = Router() diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index eb4f9d6d..13b4fdb1 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js' -import { createJsonHandler } from './shared.js' +import { createJsonHandler } from './session.js' import type { AdminOrderRouteParams, AdminOrderRouteQuery, diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index f5cb0345..dd531225 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { requireAdminRoles } from "./shared.js"; +import { requireAdminRoles } from "./session.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts index 854b1878..4213e747 100644 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -33,8 +33,8 @@ import type { AdminCloudtentaclesValidateSessionRouteBody, AdminCloudtentaclesVirtualNumberRouteBody, } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts index 170dce6c..b0ef002d 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -5,8 +5,8 @@ import { updateAdminKuaishouCloudFulfillmentConfig, } from "../../../services/admin/platform-config/kuaishou-cloud-fulfillment-service.js"; import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts index 6fce9fa8..be8aab9a 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts @@ -13,8 +13,8 @@ import type { AdminKuaishouEticketShopInfoRouteBody, AdminKuaishouEticketSourceConfigRouteBody, } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts index 767112fe..4e84f2b7 100644 --- a/apps/backend/src/routes/admin/platform-config/ninetyone.ts +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -6,8 +6,8 @@ import { retryAdminNinetyoneOrder, } from "../../../services/admin/platform-config/ninetyone-service.js"; import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/platform-config/notifications.ts b/apps/backend/src/routes/admin/platform-config/notifications.ts index 4fcf800a..56bb44d4 100644 --- a/apps/backend/src/routes/admin/platform-config/notifications.ts +++ b/apps/backend/src/routes/admin/platform-config/notifications.ts @@ -14,8 +14,8 @@ import type { AdminNotificationTestRouteBody, AdminScheduledJobsConfigRouteBody, } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/shared.ts b/apps/backend/src/routes/admin/session.ts similarity index 100% rename from apps/backend/src/routes/admin/shared.ts rename to apps/backend/src/routes/admin/session.ts diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 68ccc81d..728df2c4 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -14,7 +14,7 @@ import { import { createJsonHandler, requireAdminRoles, -} from "./shared.js"; +} from "./session.js"; import type { AdminTaskKuaishouCloudDispatchRouteBody, AdminTaskRouteParams, diff --git a/apps/backend/src/routes/admin/users.ts b/apps/backend/src/routes/admin/users.ts index 66f16973..6f3c9b2f 100644 --- a/apps/backend/src/routes/admin/users.ts +++ b/apps/backend/src/routes/admin/users.ts @@ -7,7 +7,7 @@ import { updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' -import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './shared.js' +import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js' type AdminUserMutationResult = { user: { diff --git a/apps/backend/src/services/admin/write/shared.ts b/apps/backend/src/services/admin/write-helpers.ts similarity index 91% rename from apps/backend/src/services/admin/write/shared.ts rename to apps/backend/src/services/admin/write-helpers.ts index 7cb37a44..5f4a3a3c 100644 --- a/apps/backend/src/services/admin/write/shared.ts +++ b/apps/backend/src/services/admin/write-helpers.ts @@ -1,10 +1,10 @@ -import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' +import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' import { maskCode as maskCodeValue, maskPhone as maskPhoneValue, -} from '../../../utils/masking.js' +} from '../../utils/masking.js' -import type { TaskRow } from '../../../types/repository-rows.js' +import type { TaskRow } from '../../types/repository-rows.js' type TaskClaimLink = { token: string diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index cd31ff02..443ed55a 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -32,7 +32,7 @@ import { getTaskClaimExpiresAt, maskCode, maskPhone, -} from './shared.js' +} from '../write-helpers.js' import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 317f69ae..8b112514 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -17,7 +17,7 @@ import { normalizeKuaishouCloudFlow, prepareKuaishouCloudFulfillmentTask, refreshKuaishouCloudTaskRoleInfo, -} from '../fulfillment/kuaishou-cloud-task-service.js' +} from '../fulfillment/kuaishou-cloud/index.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import type { TaskRow } from '../../types/repository-rows.js' diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index 2e3b3f5b..c055c1b6 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -3,7 +3,7 @@ import { prepareKuaishouCloudFulfillmentTask, probeKuaishouCloudTaskBindUrl, refreshKuaishouCloudTaskRoleInfo, -} from '../fulfillment/kuaishou-cloud-task-service.js' +} from '../fulfillment/kuaishou-cloud/index.js' import type { TaskRow } from '../../types/repository-rows.js' export async function syncKuaishouCloudRoleInfo(task: TaskRow) { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index 676ce7d1..deecbc22 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -1,4 +1,4 @@ -import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js"; +import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js"; import { maskCode as maskCodeValue, maskPhone as maskPhoneValue, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts similarity index 95% rename from apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts rename to apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index 0b15819a..86b8e9cf 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -1,24 +1,24 @@ -import { createTaskEvent } from "../../repositories/task-event-repo.js"; -import { updateTask } from "../../repositories/task-repo.js"; -import { buildClaimUrl, createTaskClaimToken } from "../claim/claim-service.js"; +import { createTaskEvent } from "../../../repositories/task-event-repo.js"; +import { updateTask } from "../../../repositories/task-repo.js"; +import { buildClaimUrl, createTaskClaimToken } from "../../claim/claim-service.js"; import { buyCloudtentaclesSku, getCloudtentaclesAsset, listCloudtentaclesSku, -} from "../platforms/cloudtentacles/catalog-service.js"; -import { getCloudtentaclesKnapsack } from "../platforms/cloudtentacles/knapsack-service.js"; +} from "../../platforms/cloudtentacles/catalog-service.js"; +import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js"; import { backCloudtentaclesVirtualNumber, getCloudtentaclesBindInfo, probeCloudtentaclesBindUrl, -} from "../platforms/cloudtentacles/virtual-number-service.js"; -import { resolveCloudtentaclesConfig } from "../platforms/cloudtentacles/shared.js"; +} from "../../platforms/cloudtentacles/virtual-number-service.js"; +import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js"; import { notifyKuaishouCloudAssetNotEnough, notifyKuaishouCloudBindUrlRefreshFailed, -} from "../notification/domain-notifications.js"; -import { createHttpError } from "../../utils/http.js"; -import { nowIso } from "../../utils/time.js"; +} from "../../notification/domain-notifications.js"; +import { createHttpError } from "../../../utils/http.js"; +import { nowIso } from "../../../utils/time.js"; import { KUAISHOU_CLOUD_FIXED_VN_KEY, isKuaishouCloudBindUrlFresh, @@ -28,22 +28,22 @@ import { normalizeKuaishouCloudRoleInfo, resolveKuaishouCloudBindUrlExpiresAt, type JsonObject, -} from "./kuaishou-cloud/domain.js"; +} from "./domain.js"; import { prepareKuaishouCloudBindResourceWithFallback, resolveKuaishouCloudBindingResources, resolveKuaishouCloudVnKeyCandidates, -} from "./kuaishou-cloud/binding-resources.js"; +} from "./binding-resources.js"; import { resolvePersistedCloudtentaclesContextWithFallback, -} from "./kuaishou-cloud/cloudtentacles-context.js"; +} from "./cloudtentacles-context.js"; import { getTaskClaimExpiresAt, isClaimExpired, normalizeActor, parseTaskContext, -} from "./kuaishou-cloud/task-context.js"; -import type { TaskRow } from "../../types/repository-rows.js"; +} from "./task-context.js"; +import type { TaskRow } from "../../../types/repository-rows.js"; export { isKuaishouCloudBindUrlFresh, @@ -53,11 +53,11 @@ export { normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, resolveKuaishouCloudBindUrlExpiresAt, -} from "./kuaishou-cloud/domain.js"; +} from "./domain.js"; export { resolvePersistedCloudtentaclesContext, resolvePersistedCloudtentaclesContextWithFallback, -} from "./kuaishou-cloud/cloudtentacles-context.js"; +} from "./cloudtentacles-context.js"; export async function ensureTaskClaimLink(task: TaskRow) { const tokenStatus = String(task?.primary_claim_token_status || "").trim(); @@ -775,4 +775,4 @@ export async function refreshKuaishouCloudTaskRoleInfo( export { dispatchKuaishouCloudFulfillmentTask, returnKuaishouCloudFulfillmentTask, -} from "./kuaishou-cloud/task-finalization.js"; +} from "./task-finalization.js"; diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 7a5b3b73..ffd31a40 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -1,7 +1,7 @@ import { findOrderByPlatformOrderId } from '../../repositories/order-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js' import { buildClaimUrl } from '../claim/claim-service.js' -import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js' +import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud/index.js' import { logIntegration } from '../../utils/logger.js' import { OPEN_91_MANUAL_FAILED_STATUS, diff --git a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts index 114cb835..728d5bd1 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts index 5c905738..22148d77 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts @@ -1,7 +1,7 @@ import { createHash, constants, publicEncrypt } from 'node:crypto' import { createHttpError } from '../../../utils/http.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/shared.ts b/apps/backend/src/services/platforms/cloudtentacles/helpers.ts similarity index 100% rename from apps/backend/src/services/platforms/cloudtentacles/shared.ts rename to apps/backend/src/services/platforms/cloudtentacles/helpers.ts diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts index cf0063f9..cecf84c6 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts @@ -4,7 +4,7 @@ import https from 'node:https' import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js' -import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js' +import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record type HeaderAdapter = { diff --git a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts b/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts index 339ceb71..b36232c5 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts index 9afe2c75..32742700 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts @@ -2,7 +2,7 @@ import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' import { encryptCloudtentaclesPayload, md5CloudtentaclesPassword } from './crypto-service.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index ab9380be..d6f445be 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -3,7 +3,7 @@ import https from 'node:https' import { createHttpError } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024 const AMS_SIGNATURE_EXPIRED_CODE = '99998' diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts index 006d7732..328886e2 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { kuaishouEticketRequest } from './http-client.js' -import { resolveKuaishouEticketConfig } from './shared.js' +import { resolveKuaishouEticketConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/shared.ts b/apps/backend/src/services/platforms/kuaishou-eticket/helpers.ts similarity index 100% rename from apps/backend/src/services/platforms/kuaishou-eticket/shared.ts rename to apps/backend/src/services/platforms/kuaishou-eticket/helpers.ts diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts index f9e7fe0e..038494db 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts @@ -5,7 +5,7 @@ import type { IncomingHttpHeaders } from 'node:http' import { parseJsonObject } from '../../../utils/json.js' import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' -import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './shared.js' +import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts index f180f44a..dc58faf6 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { kuaishouEticketRequest } from './http-client.js' -import { resolveKuaishouEticketConfig } from './shared.js' +import { resolveKuaishouEticketConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/routes/admin/platform-config/shared.ts b/apps/backend/src/types/json.ts similarity index 100% rename from apps/backend/src/routes/admin/platform-config/shared.ts rename to apps/backend/src/types/json.ts diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts deleted file mode 100644 index e8138fa3..00000000 --- a/apps/frontend/src/services/admin.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './admin/index' From 8d5871bf926497503bcf6fc37fab570434fa4b7d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 09:03:03 +0800 Subject: [PATCH 20/73] =?UTF-8?q?=E6=8B=86=E5=88=8691=E5=8D=A1=E5=88=B8?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/open-91.ts | 2 +- apps/backend/src/services/open-91/config.ts | 44 +++ apps/backend/src/services/open-91/crypto.ts | 91 +++++ .../services/open-91/order-create-service.ts | 14 +- .../services/open-91/order-query-service.ts | 23 +- apps/backend/src/services/open-91/payload.ts | 136 +++++++ .../{shared.test.ts => protocol.test.ts} | 10 +- apps/backend/src/services/open-91/response.ts | 115 ++++++ apps/backend/src/services/open-91/shared.ts | 370 ------------------ .../platforms/ninetyone/order-service.ts | 6 +- 10 files changed, 418 insertions(+), 393 deletions(-) create mode 100644 apps/backend/src/services/open-91/config.ts create mode 100644 apps/backend/src/services/open-91/crypto.ts create mode 100644 apps/backend/src/services/open-91/payload.ts rename apps/backend/src/services/open-91/{shared.test.ts => protocol.test.ts} (98%) create mode 100644 apps/backend/src/services/open-91/response.ts delete mode 100644 apps/backend/src/services/open-91/shared.ts diff --git a/apps/backend/src/routes/open-91.ts b/apps/backend/src/routes/open-91.ts index af0a1d34..74fd3b43 100644 --- a/apps/backend/src/routes/open-91.ts +++ b/apps/backend/src/routes/open-91.ts @@ -3,7 +3,7 @@ import { Router } from 'express' import { createRateLimitMiddleware } from '../middleware/rate-limit.js' import { createOpen91Order } from '../services/open-91/order-create-service.js' import { queryOpen91Order } from '../services/open-91/order-query-service.js' -import { buildOpen91ErrorResponse } from '../services/open-91/shared.js' +import { buildOpen91ErrorResponse } from '../services/open-91/response.js' import { createRequestId, logIntegration } from '../utils/logger.js' const router = Router() diff --git a/apps/backend/src/services/open-91/config.ts b/apps/backend/src/services/open-91/config.ts new file mode 100644 index 00000000..49bc5ff2 --- /dev/null +++ b/apps/backend/src/services/open-91/config.ts @@ -0,0 +1,44 @@ +import { runtimeConfig } from '../../config/runtime.js' +import { createHttpError } from '../../utils/http.js' + +export const OPEN_91_PROVIDER = '91kaquan' +export const OPEN_91_PLATFORM = 'kuaishou' +export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' +export const OPEN_91_DEFAULT_FAIL_CODE = 1204 +export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 + +type JsonObject = Record + +export function getOpen91Config() { + const config = (runtimeConfig.platforms?.ninetyone || {}) as JsonObject + + return { + userId: String(config.userId || '').trim(), + secret: String(config.secret || '').trim(), + version: String(config.version || '1.0').trim() || '1.0', + shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, + shopName: String(config.shopName || '91卡券').trim() || '91卡券', + timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, + cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', + } +} + +export function assertOpen91Config() { + const config = getOpen91Config() + + if (!config.secret) { + throw createHttpError('91卡券 secret 未配置', { + statusCode: 500, + errorCode: 'open91_missing_secret', + }) + } + + if (config.secret.length !== 32) { + throw createHttpError('91卡券 secret 长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_secret_length', + }) + } + + return config +} diff --git a/apps/backend/src/services/open-91/crypto.ts b/apps/backend/src/services/open-91/crypto.ts new file mode 100644 index 00000000..6b4be93d --- /dev/null +++ b/apps/backend/src/services/open-91/crypto.ts @@ -0,0 +1,91 @@ +import crypto from 'node:crypto' + +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { + const entries = Object.entries(params) + .filter(([key]) => key !== 'sign') + .sort(([left], [right]) => { + if (left === right) { + return 0 + } + return left < right ? -1 : 1 + }) + + const queryString = entries + .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) + .join('&') + + return `${secret}${queryString}${secret}` +} + +export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { + return crypto + .createHash('md5') + .update(buildOpen91SignSource(params, config), 'utf8') + .digest('hex') + .toUpperCase() +} + +export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + const expected = signOpen91Payload(params, config) + const actual = normalizeOpen91String(params.sign).toUpperCase() + return expected === actual +} + +export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + if (!verifyOpen91Signature(params, config)) { + throw createHttpError('验签失败', { + statusCode: 400, + errorCode: 'open91_invalid_signature', + }) + } +} + +export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { + const normalizedSecret = normalizeOpen91String(secret) + if (normalizedSecret.length !== 32) { + throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_cards_secret_length', + }) + } + + const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) + const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) + cipher.setAutoPadding(true) + + return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` +} + +export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { + const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() + if (encoding && encoding !== 'aes-256-ecb-base64') { + throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { + statusCode: 500, + errorCode: 'open91_unsupported_cards_encoding', + }) + } + + return encryptOpen91Cards(cards, config.secret) +} + +export function stringifyOpen91SignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + if (value == null) { + return '' + } + + return String(value) +} diff --git a/apps/backend/src/services/open-91/order-create-service.ts b/apps/backend/src/services/open-91/order-create-service.ts index 68f96387..03547abc 100644 --- a/apps/backend/src/services/open-91/order-create-service.ts +++ b/apps/backend/src/services/open-91/order-create-service.ts @@ -8,16 +8,20 @@ import { import { notifyOpen91PendingConfig } from '../notification/domain-notifications.js' import { assertOpen91Config, + OPEN_91_COST_EXCEED_FAIL_CODE, + OPEN_91_DEFAULT_FAIL_CODE, +} from './config.js' +import { assertOpen91CreatePayload, - assertOpen91Signature, assertOpen91Timestamp, + normalizeOpen91CreatePayload, +} from './payload.js' +import { assertOpen91Signature } from './crypto.js' +import { buildOpen91OrderCost, buildOpen91OutTradeNo, buildOpen91SuccessResponse, - normalizeOpen91CreatePayload, - OPEN_91_COST_EXCEED_FAIL_CODE, - OPEN_91_DEFAULT_FAIL_CODE, -} from './shared.js' +} from './response.js' export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { const config = assertOpen91Config() diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index ffd31a40..034862af 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -9,20 +9,23 @@ import { } from '../platforms/ninetyone/order-service.js' import { assertOpen91Config, - assertOpen91QueryPayload, - assertOpen91Signature, - assertOpen91Timestamp, - buildOpen91CardItem, - buildOpen91Cards, - buildOpen91OrderCost, - buildOpen91OutTradeNo, - buildOpen91SuccessResponse, - normalizeOpen91QueryPayload, OPEN_91_DEFAULT_FAIL_CODE, OPEN_91_PLATFORM, OPEN_91_PROVIDER, +} from './config.js' +import { + assertOpen91QueryPayload, + assertOpen91Timestamp, + normalizeOpen91QueryPayload, +} from './payload.js' +import { assertOpen91Signature, buildOpen91Cards } from './crypto.js' +import { + buildOpen91CardItem, + buildOpen91OrderCost, + buildOpen91OutTradeNo, + buildOpen91SuccessResponse, resolveOpen91QueryState, -} from './shared.js' +} from './response.js' import type { OrderRow } from '../../types/repository-rows.js' type JsonObject = Record diff --git a/apps/backend/src/services/open-91/payload.ts b/apps/backend/src/services/open-91/payload.ts new file mode 100644 index 00000000..80edbf6e --- /dev/null +++ b/apps/backend/src/services/open-91/payload.ts @@ -0,0 +1,136 @@ +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' + +type JsonObject = Record + +export function normalizeOpen91String(value: unknown) { + return String(value || '').trim() +} + +export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + productNo: normalizeOpen91String(payload.productNo), + buyNum: normalizeOpen91BuyNum(payload.buyNum), + maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), + callbackUrl: normalizeOpen91String(payload.callbackUrl), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + if (!payload.productNo) { + throw createHttpError('缺少 productNo', { + statusCode: 400, + errorCode: 'open91_missing_product_no', + }) + } + + if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { + throw createHttpError('buyNum 必须是大于 0 的整数', { + statusCode: 400, + errorCode: 'open91_invalid_buy_num', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { + throw createHttpError('timestamp 已过期', { + statusCode: 400, + errorCode: 'open91_expired_timestamp', + }) + } +} + +function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { + if (!payload.timestamp) { + throw createHttpError('缺少 timestamp', { + statusCode: 400, + errorCode: 'open91_missing_timestamp', + }) + } + + if (!payload.version) { + throw createHttpError('缺少 version', { + statusCode: 400, + errorCode: 'open91_missing_version', + }) + } + + if (payload.version !== config.version) { + throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { + statusCode: 400, + errorCode: 'open91_invalid_version', + }) + } + + if (!payload.sign) { + throw createHttpError('缺少 sign', { + statusCode: 400, + errorCode: 'open91_missing_sign', + }) + } +} + +function normalizeOpen91BuyNum(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : 0 +} + +function normalizeOpen91OptionalAmount(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (!/^\d+(?:\.\d+)?$/.test(normalized)) { + throw createHttpError('maxAmount 格式无效', { + statusCode: 400, + errorCode: 'open91_invalid_max_amount', + }) + } + + return normalized +} + +function normalizeOpen91Timestamp(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!/^\d{10}$/.test(normalized)) { + return 0 + } + + return Number(normalized) +} diff --git a/apps/backend/src/services/open-91/shared.test.ts b/apps/backend/src/services/open-91/protocol.test.ts similarity index 98% rename from apps/backend/src/services/open-91/shared.test.ts rename to apps/backend/src/services/open-91/protocol.test.ts index 27aa7866..b72625d3 100644 --- a/apps/backend/src/services/open-91/shared.test.ts +++ b/apps/backend/src/services/open-91/protocol.test.ts @@ -2,14 +2,16 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - buildOpen91CardItem, buildOpen91SignSource, encryptOpen91Cards, - formatOpen91ExpireTime, - resolveOpen91QueryState, signOpen91Payload, verifyOpen91Signature, -} from './shared.js' +} from './crypto.js' +import { + buildOpen91CardItem, + formatOpen91ExpireTime, + resolveOpen91QueryState, +} from './response.js' test('signOpen91Payload signs only request body fields', () => { const config = { diff --git a/apps/backend/src/services/open-91/response.ts b/apps/backend/src/services/open-91/response.ts new file mode 100644 index 00000000..a9c5f721 --- /dev/null +++ b/apps/backend/src/services/open-91/response.ts @@ -0,0 +1,115 @@ +import { + OPEN_91_DEFAULT_FAIL_CODE, + OPEN_91_SUCCESS_MESSAGE, +} from './config.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { + return { + code: 200, + message, + data, + } +} + +export function buildOpen91ErrorResponse(message: unknown, code = 500) { + return { + code, + message: normalizeOpen91String(message) || '系统错误', + data: null as null, + } +} + +export function buildOpen91OutTradeNo(order: JsonObject) { + const orderId = Number(order?.id || 0) + return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' +} + +export function buildOpen91OrderCost(value = 0) { + const amount = Number(value) + return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' +} + +export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { + const normalizedClaimUrl = normalizeOpen91String(claimUrl) + return { + cardNo: normalizedClaimUrl, + cardPwd: '', + expireTime: formatOpen91ExpireTime(expireTime), + jumpLink: normalizedClaimUrl, + } +} + +export function formatOpen91ExpireTime(value: unknown = '') { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { + return normalized + } + + const date = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(date.getTime())) { + return normalized + } + + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) + return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` +} + +export function resolveOpen91QueryState( + { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, +) { + const normalizedTasks = Array.isArray(tasks) ? tasks : [] + const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) + + if (normalizedTasks.length === 0) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: '订单未生成履约任务', + } + } + + const failedTask = normalizedTasks.find((task) => { + const status = normalizeOpen91String(task?.task_status) + return ['failed', 'manual_review', 'closed'].includes(status) && !readyTaskIdSet.has(Number(task?.id || 0)) + }) + + if (failedTask) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', + } + } + + if (readyTaskIdSet.size < normalizedTasks.length) { + return { + orderStatus: 10, + failCode: 0, + failReason: '', + } + } + + return { + orderStatus: 20, + failCode: 0, + failReason: '', + } +} diff --git a/apps/backend/src/services/open-91/shared.ts b/apps/backend/src/services/open-91/shared.ts deleted file mode 100644 index 41c47303..00000000 --- a/apps/backend/src/services/open-91/shared.ts +++ /dev/null @@ -1,370 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { createHttpError } from '../../utils/http.js' - -export const OPEN_91_PROVIDER = '91kaquan' -export const OPEN_91_PLATFORM = 'kuaishou' -export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' -export const OPEN_91_DEFAULT_FAIL_CODE = 1204 -export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 - -type JsonObject = Record - -export function getOpen91Config() { - const config = (runtimeConfig.platforms?.ninetyone || {}) as JsonObject - - return { - userId: String(config.userId || '').trim(), - secret: String(config.secret || '').trim(), - version: String(config.version || '1.0').trim() || '1.0', - shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, - shopName: String(config.shopName || '91卡券').trim() || '91卡券', - timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, - cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', - } -} - -export function assertOpen91Config() { - const config = getOpen91Config() - - if (!config.secret) { - throw createHttpError('91卡券 secret 未配置', { - statusCode: 500, - errorCode: 'open91_missing_secret', - }) - } - - if (config.secret.length !== 32) { - throw createHttpError('91卡券 secret 长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_secret_length', - }) - } - - return config -} - -export function normalizeOpen91String(value: unknown) { - return String(value || '').trim() -} - -export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - productNo: normalizeOpen91String(payload.productNo), - buyNum: normalizeOpen91BuyNum(payload.buyNum), - maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), - callbackUrl: normalizeOpen91String(payload.callbackUrl), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - if (!payload.productNo) { - throw createHttpError('缺少 productNo', { - statusCode: 400, - errorCode: 'open91_missing_product_no', - }) - } - - if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { - throw createHttpError('buyNum 必须是大于 0 的整数', { - statusCode: 400, - errorCode: 'open91_invalid_buy_num', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { - if (!payload.timestamp) { - throw createHttpError('缺少 timestamp', { - statusCode: 400, - errorCode: 'open91_missing_timestamp', - }) - } - - if (!payload.version) { - throw createHttpError('缺少 version', { - statusCode: 400, - errorCode: 'open91_missing_version', - }) - } - - if (payload.version !== config.version) { - throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { - statusCode: 400, - errorCode: 'open91_invalid_version', - }) - } - - if (!payload.sign) { - throw createHttpError('缺少 sign', { - statusCode: 400, - errorCode: 'open91_missing_sign', - }) - } -} - -export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { - const nowSeconds = Math.floor(Date.now() / 1000) - if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { - throw createHttpError('timestamp 已过期', { - statusCode: 400, - errorCode: 'open91_expired_timestamp', - }) - } -} - -export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign') - .sort(([left], [right]) => { - if (left === right) { - return 0 - } - return left < right ? -1 : 1 - }) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) - .join('&') - - return `${secret}${queryString}${secret}` -} - -export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { - return crypto - .createHash('md5') - .update(buildOpen91SignSource(params, config), 'utf8') - .digest('hex') - .toUpperCase() -} - -export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - const expected = signOpen91Payload(params, config) - const actual = normalizeOpen91String(params.sign).toUpperCase() - return expected === actual -} - -export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - if (!verifyOpen91Signature(params, config)) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'open91_invalid_signature', - }) - } -} - -export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { - const normalizedSecret = normalizeOpen91String(secret) - if (normalizedSecret.length !== 32) { - throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_cards_secret_length', - }) - } - - const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) - const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) - cipher.setAutoPadding(true) - - return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` -} - -export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { - const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() - if (encoding && encoding !== 'aes-256-ecb-base64') { - throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { - statusCode: 500, - errorCode: 'open91_unsupported_cards_encoding', - }) - } - - return encryptOpen91Cards(cards, config.secret) -} - -export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { - return { - code: 200, - message, - data, - } -} - -export function buildOpen91ErrorResponse(message: unknown, code = 500) { - return { - code, - message: normalizeOpen91String(message) || '系统错误', - data: null as null, - } -} - -export function buildOpen91OutTradeNo(order: JsonObject) { - const orderId = Number(order?.id || 0) - return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' -} - -export function buildOpen91OrderCost(value = 0) { - const amount = Number(value) - return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' -} - -export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { - const normalizedClaimUrl = normalizeOpen91String(claimUrl) - return { - cardNo: normalizedClaimUrl, - cardPwd: '', - expireTime: formatOpen91ExpireTime(expireTime), - jumpLink: normalizedClaimUrl, - } -} - -export function formatOpen91ExpireTime(value: unknown = '') { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { - return normalized - } - - const date = value instanceof Date ? value : new Date(normalized) - if (Number.isNaN(date.getTime())) { - return normalized - } - - const parts = new Intl.DateTimeFormat('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(date) - - const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) - return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` -} - -export function resolveOpen91QueryState( - { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, -) { - const normalizedTasks = Array.isArray(tasks) ? tasks : [] - const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) - - if (normalizedTasks.length === 0) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: '订单未生成履约任务', - } - } - - const failedTask = normalizedTasks.find((task) => { - const status = normalizeOpen91String(task?.task_status) - return ['failed', 'manual_review', 'closed'].includes(status) && !readyTaskIdSet.has(Number(task?.id || 0)) - }) - - if (failedTask) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', - } - } - - if (readyTaskIdSet.size < normalizedTasks.length) { - return { - orderStatus: 10, - failCode: 0, - failReason: '', - } - } - - return { - orderStatus: 20, - failCode: 0, - failReason: '', - } -} - -export function stringifyOpen91SignValue(value: unknown) { - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - if (value == null) { - return '' - } - - return String(value) -} - -function normalizeOpen91BuyNum(value: unknown) { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : 0 -} - -function normalizeOpen91OptionalAmount(value: unknown) { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (!/^\d+(?:\.\d+)?$/.test(normalized)) { - throw createHttpError('maxAmount 格式无效', { - statusCode: 400, - errorCode: 'open91_invalid_max_amount', - }) - } - - return normalized -} - -function normalizeOpen91Timestamp(value: unknown) { - const normalized = normalizeOpen91String(value) - if (!/^\d{10}$/.test(normalized)) { - return 0 - } - - return Number(normalized) -} diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 6e6f6af5..c03c0107 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -4,11 +4,11 @@ import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositorie import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' import { - buildOpen91OutTradeNo, - normalizeOpen91CreatePayload, OPEN_91_PLATFORM, OPEN_91_PROVIDER, -} from '../../open-91/shared.js' +} from '../../open-91/config.js' +import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' +import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import type { OrderItemRow, OrderRow } from '../../../types/repository-rows.js' From 5c7e1457665344a81cab628e978fecda76376fe6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 09:10:11 +0800 Subject: [PATCH 21/73] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=B8=8E=E5=B9=B3=E5=8F=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/repositories/order-item-repo.ts | 4 ++-- apps/backend/src/repositories/order-repo.ts | 4 ++-- apps/backend/src/repositories/task-repo.ts | 4 ++-- apps/backend/src/routes/admin/orders.ts | 2 +- .../admin/platform-config/cloudtentacles.ts | 4 ++-- .../kuaishou-cloud-fulfillment.ts | 2 +- .../admin/platform-config/kuaishou-eticket.ts | 2 +- .../routes/admin/platform-config/ninetyone.ts | 2 +- .../admin/platform-config/notifications.ts | 2 +- apps/backend/src/routes/admin/tasks.ts | 4 ++-- .../admin/admin-order-read-helpers.ts | 4 ++-- .../src/services/admin/admin-read-service.ts | 4 ++-- .../admin/admin-read-shared-helpers.ts | 4 ++-- .../services/admin/admin-task-read-helpers.ts | 6 +++--- .../{ => cloudtentacles}/context.test.ts | 0 .../{ => cloudtentacles}/context.ts | 4 ++-- .../{ => cloudtentacles}/domain.test.ts | 0 .../{ => cloudtentacles}/domain.ts | 0 .../index.ts} | 20 +++++++++---------- .../{ => cloudtentacles}/mappers.test.ts | 0 .../{ => cloudtentacles}/mappers.ts | 2 +- .../observed-products.test.ts | 0 .../{ => cloudtentacles}/observed-products.ts | 2 +- .../payloads.test.ts} | 2 +- .../payloads.ts} | 4 ++-- .../writes.ts} | 0 .../kuaishou-cloud-fulfillment-service.ts | 2 +- .../kuaishou-eticket-service.ts | 4 ++-- .../platform-config/notification-service.ts | 4 ++-- .../src/services/admin/write-helpers.ts | 2 +- .../admin/write/kuaishou-cloud-actions.ts | 6 +++--- .../claim/kuaishou-cloud-claim-context.ts | 2 +- .../claim/kuaishou-cloud-claim-service.ts | 2 +- .../claim/kuaishou-cloud-sync-service.ts | 2 +- .../fulfillment/kuaishou-cloud/index.ts | 2 +- .../kuaishou-cloud/task-context.ts | 2 +- .../kuaishou-cloud/task-finalization.ts | 2 +- .../services/open-91/order-query-service.ts | 2 +- .../services/order/delivery-task-service.ts | 2 +- .../src/services/order/order-service.ts | 2 +- .../platforms/ninetyone/order-service.ts | 2 +- .../read-inputs.ts} | 0 .../read-models.ts} | 0 .../route-inputs.ts} | 4 ++-- .../write-inputs.ts} | 0 .../write-models.ts} | 0 .../inputs.ts} | 0 .../rows.ts} | 0 48 files changed, 62 insertions(+), 62 deletions(-) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/context.test.ts (100%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/context.ts (93%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/domain.test.ts (100%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/domain.ts (100%) rename apps/backend/src/services/admin/platform-config/{cloudtentacles-service.ts => cloudtentacles/index.ts} (94%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/mappers.test.ts (100%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/mappers.ts (99%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/observed-products.test.ts (100%) rename apps/backend/src/services/admin/platform-config/{ => cloudtentacles}/observed-products.ts (97%) rename apps/backend/src/services/admin/platform-config/{cloudtentacles.test.ts => cloudtentacles/payloads.test.ts} (99%) rename apps/backend/src/services/admin/platform-config/{cloudtentacles.ts => cloudtentacles/payloads.ts} (94%) rename apps/backend/src/services/admin/platform-config/{cloudtentacles-writes.ts => cloudtentacles/writes.ts} (100%) rename apps/backend/src/types/{admin-read-inputs.ts => admin/read-inputs.ts} (100%) rename apps/backend/src/types/{admin-read-models.ts => admin/read-models.ts} (100%) rename apps/backend/src/types/{admin-route-inputs.ts => admin/route-inputs.ts} (97%) rename apps/backend/src/types/{admin-write-inputs.ts => admin/write-inputs.ts} (100%) rename apps/backend/src/types/{admin-write-models.ts => admin/write-models.ts} (100%) rename apps/backend/src/types/{repository-inputs.ts => repository/inputs.ts} (100%) rename apps/backend/src/types/{repository-rows.ts => repository/rows.ts} (100%) diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 7752dd24..11511cc9 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -1,8 +1,8 @@ import type { PoolClient, QueryResult } from 'pg' import { query, withTransaction } from '../db/client.js' -import type { OrderItemReplaceInput } from '../types/repository-inputs.js' -import type { OrderItemRow } from '../types/repository-rows.js' +import type { OrderItemReplaceInput } from '../types/repository/inputs.js' +import type { OrderItemRow } from '../types/repository/rows.js' type QueryExecutor = (text: string, params?: unknown[]) => Promise> diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index d520ec60..f04291bf 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -3,12 +3,12 @@ import type { OrderCreateInput, OrderListQueryInput, OrderUpdateInput, -} from '../types/repository-inputs.js' +} from '../types/repository/inputs.js' import type { OrderListQueryResult, OrderListRow, OrderRow, -} from '../types/repository-rows.js' +} from '../types/repository/rows.js' type OrderPlatformLookupInput = { provider?: string diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 00008f90..e7f2bd44 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -6,11 +6,11 @@ import type { TaskListQueryInput, TaskRuntimeContextPatch, TaskUpdatePatch, -} from '../types/repository-inputs.js' +} from '../types/repository/inputs.js' import type { TaskListQueryResult, TaskRow, -} from '../types/repository-rows.js' +} from '../types/repository/rows.js' type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 13b4fdb1..b45e6513 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -5,7 +5,7 @@ import { createJsonHandler } from './session.js' import type { AdminOrderRouteParams, AdminOrderRouteQuery, -} from '../../types/admin-route-inputs.js' +} from '../../types/admin/route-inputs.js' const router = Router() diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts index 4213e747..b311c9d4 100644 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -21,7 +21,7 @@ import { useAdminCloudtentaclesSku, validateAdminCloudtentaclesSession, verifyAdminCloudtentaclesLoginCode, -} from "../../../services/admin/platform-config/cloudtentacles-service.js"; +} from "../../../services/admin/platform-config/cloudtentacles/index.js"; import type { AdminCloudtentaclesCatalogQueryRouteBody, AdminCloudtentaclesFullFlowRouteBody, @@ -32,7 +32,7 @@ import type { AdminCloudtentaclesTestLoginRouteBody, AdminCloudtentaclesValidateSessionRouteBody, AdminCloudtentaclesVirtualNumberRouteBody, -} from "../../../types/admin-route-inputs.js"; +} from "../../../types/admin/route-inputs.js"; import { createJsonHandler } from "../session.js"; import type { JsonRecord } from "../../../types/json.js"; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts index b0ef002d..1264db83 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -4,7 +4,7 @@ import { getAdminKuaishouCloudFulfillmentConfig, updateAdminKuaishouCloudFulfillmentConfig, } from "../../../services/admin/platform-config/kuaishou-cloud-fulfillment-service.js"; -import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js"; +import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin/route-inputs.js"; import { createJsonHandler } from "../session.js"; import type { JsonRecord } from "../../../types/json.js"; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts index be8aab9a..656d34c0 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts @@ -12,7 +12,7 @@ import type { AdminKuaishouEticketDetailQueryRouteBody, AdminKuaishouEticketShopInfoRouteBody, AdminKuaishouEticketSourceConfigRouteBody, -} from "../../../types/admin-route-inputs.js"; +} from "../../../types/admin/route-inputs.js"; import { createJsonHandler } from "../session.js"; import type { JsonRecord } from "../../../types/json.js"; diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts index 4e84f2b7..d36db6ab 100644 --- a/apps/backend/src/routes/admin/platform-config/ninetyone.ts +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -5,7 +5,7 @@ import { getAdminNinetyoneOrders, retryAdminNinetyoneOrder, } from "../../../services/admin/platform-config/ninetyone-service.js"; -import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js"; +import type { AdminEntityRouteParams } from "../../../types/admin/route-inputs.js"; import { createJsonHandler } from "../session.js"; import type { JsonRecord } from "../../../types/json.js"; diff --git a/apps/backend/src/routes/admin/platform-config/notifications.ts b/apps/backend/src/routes/admin/platform-config/notifications.ts index 56bb44d4..07f20686 100644 --- a/apps/backend/src/routes/admin/platform-config/notifications.ts +++ b/apps/backend/src/routes/admin/platform-config/notifications.ts @@ -13,7 +13,7 @@ import type { AdminNotificationConfigRouteBody, AdminNotificationTestRouteBody, AdminScheduledJobsConfigRouteBody, -} from "../../../types/admin-route-inputs.js"; +} from "../../../types/admin/route-inputs.js"; import { createJsonHandler } from "../session.js"; import type { JsonRecord } from "../../../types/json.js"; diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 728df2c4..b8d3ebe2 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -19,8 +19,8 @@ import type { AdminTaskKuaishouCloudDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; +} from "../../types/admin/route-inputs.js"; +import type { AdminTaskActionResponse } from "../../types/admin/write-models.js"; const router = Router(); diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index f1ea3eea..0a1b7f1a 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -4,8 +4,8 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js' import { buildOrderBindingSummary } from './admin-task-read-helpers.js' -import type { AdminOrderListItem } from '../../types/admin-read-models.js' -import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js' +import type { AdminOrderListItem } from '../../types/admin/read-models.js' +import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js' export async function mapAdminOrderListItem(item: OrderListRow): Promise { const [tasks, orderItems] = await Promise.all([ diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index da7b4138..57db7e79 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -35,13 +35,13 @@ import { import type { AdminOrderListResponse, AdminTaskListResponse, -} from '../../types/admin-read-models.js' +} from '../../types/admin/read-models.js' import type { AdminEntityIdInput, AdminOrderListQueryInput, AdminTaskListQueryInput, AdminViewerSessionInput, -} from '../../types/admin-read-inputs.js' +} from '../../types/admin/read-inputs.js' type JsonRecord = Record diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 1089fbb5..1307acc2 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -5,8 +5,8 @@ import { parseTaskState as parseTaskStateValue, } from '../../utils/task-json.js' -import type { AdminViewerSessionInput } from '../../types/admin-read-inputs.js' -import type { OrderItemRow, TaskRow } from '../../types/repository-rows.js' +import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js' +import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js' type JsonRecord = Record diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index 17330a4d..a28f3a3d 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -9,12 +9,12 @@ import { import type { AdminTaskBindingSummary, AdminTaskListItem, -} from '../../types/admin-read-models.js' -import type { AdminTaskActionPayload } from '../../types/admin-write-models.js' +} from '../../types/admin/read-models.js' +import type { AdminTaskActionPayload } from '../../types/admin/write-models.js' import type { TaskEventRow, TaskRow, -} from '../../types/repository-rows.js' +} from '../../types/repository/rows.js' import type { AdminViewerContext } from './admin-read-shared-helpers.js' type JsonRecord = Record diff --git a/apps/backend/src/services/admin/platform-config/context.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/context.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts diff --git a/apps/backend/src/services/admin/platform-config/context.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts similarity index 93% rename from apps/backend/src/services/admin/platform-config/context.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts index 43677f5d..81d6d8fd 100644 --- a/apps/backend/src/services/admin/platform-config/context.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts @@ -41,8 +41,8 @@ export function resolveKuaishouEticketAdminContext(payload: JsonObject = {}, opt }; } -import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js"; -import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js"; +import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js"; +import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js"; export function resolveCloudtentaclesAdminContext(payload: JsonObject = {}, options: JsonObject = {}) { const sourceKey = String( diff --git a/apps/backend/src/services/admin/platform-config/domain.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/domain.test.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/domain.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/domain.test.ts diff --git a/apps/backend/src/services/admin/platform-config/domain.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/domain.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/domain.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/domain.ts diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts similarity index 94% rename from apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts index 6c10aff4..b572b521 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -5,7 +5,7 @@ import { saveCloudtentaclesSourceByKey, saveCloudtentaclesSourcesList, deleteCloudtentaclesSourceByKey, -} from "../../platforms/cloudtentacles/source-config-service.js"; +} from "../../../platforms/cloudtentacles/source-config-service.js"; import { clearCloudtentaclesSessionStateByKey, deleteCloudtentaclesSessionStateByKey, @@ -14,20 +14,20 @@ import { saveCloudtentaclesSessionStateByKey, getAllCloudtentaclesSessionStates, pruneCloudtentaclesSessionStates, -} from "../../platforms/cloudtentacles/session-state-service.js"; +} from "../../../platforms/cloudtentacles/session-state-service.js"; import { loginCloudtentaclesSession, sendCloudtentaclesSmsCode, validateCloudtentaclesSession, -} from "../../platforms/cloudtentacles/session-service.js"; +} from "../../../platforms/cloudtentacles/session-service.js"; import { buyCloudtentaclesSku, getCloudtentaclesAsset, getCloudtentaclesCategories, listCloudtentaclesSku, useCloudtentaclesSku, -} from "../../platforms/cloudtentacles/catalog-service.js"; -import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js"; +} from "../../../platforms/cloudtentacles/catalog-service.js"; +import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js"; import { appointCloudtentaclesVirtualNumber, backCloudtentaclesVirtualNumber, @@ -36,8 +36,8 @@ import { getCloudtentaclesBindUrl, listCloudtentaclesVirtualNumbers, verifyCloudtentaclesLoginCode, -} from "../../platforms/cloudtentacles/virtual-number-service.js"; -import { runCloudtentaclesFullDebugFlow } from "../../platforms/cloudtentacles/debug-flow-service.js"; +} from "../../../platforms/cloudtentacles/virtual-number-service.js"; +import { runCloudtentaclesFullDebugFlow } from "../../../platforms/cloudtentacles/debug-flow-service.js"; import { hasCloudtentaclesCredentialContextChanged, pickFirstNonEmpty, @@ -48,8 +48,8 @@ import { buildAdminCloudtentaclesValidateResult, resolveAdminCloudtentaclesCredentialPayload, resolveAdminCloudtentaclesSessionPayload, -} from "./cloudtentacles.js"; -import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./cloudtentacles-writes.js"; +} from "./payloads.js"; +import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./writes.js"; import { mapAdminCloudtentaclesSession, mapAdminCloudtentaclesSourceConfig, @@ -66,7 +66,7 @@ import type { AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesValidateSessionInput, AdminCloudtentaclesVirtualNumberInput, -} from "../../../types/admin-write-inputs.js"; +} from "../../../../types/admin/write-inputs.js"; type JsonObject = Record; diff --git a/apps/backend/src/services/admin/platform-config/mappers.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/mappers.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts diff --git a/apps/backend/src/services/admin/platform-config/mappers.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts similarity index 99% rename from apps/backend/src/services/admin/platform-config/mappers.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts index 4c8cb1ba..587d5430 100644 --- a/apps/backend/src/services/admin/platform-config/mappers.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts @@ -1,7 +1,7 @@ import { maskPhone as maskPhoneValue, maskSecret as maskSecretValue, -} from "../../../utils/masking.js"; +} from "../../../../utils/masking.js"; type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/observed-products.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/observed-products.test.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/observed-products.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/observed-products.test.ts diff --git a/apps/backend/src/services/admin/platform-config/observed-products.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/observed-products.ts similarity index 97% rename from apps/backend/src/services/admin/platform-config/observed-products.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/observed-products.ts index 295dcdc2..37980aec 100644 --- a/apps/backend/src/services/admin/platform-config/observed-products.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/observed-products.ts @@ -1,4 +1,4 @@ -import { query } from '../../../db/client.js' +import { query } from '../../../../db/client.js' import { mapAdminObservedProductItem } from './domain.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts similarity index 99% rename from apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts index 7c3a8cf7..a7612bda 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts @@ -8,7 +8,7 @@ import { buildAdminCloudtentaclesValidateResult, resolveAdminCloudtentaclesCredentialPayload, resolveAdminCloudtentaclesSessionPayload, -} from './cloudtentacles.js' +} from './payloads.js' test('resolveAdminCloudtentaclesCredentialPayload merges payload and saved source defaults', () => { assert.deepEqual( diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts similarity index 94% rename from apps/backend/src/services/admin/platform-config/cloudtentacles.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts index d61d7aa4..6b1e7eb9 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts @@ -1,5 +1,5 @@ -import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js"; -import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js"; +import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js"; +import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js"; import { pickFirstNonEmpty, resolveCloudtentaclesAdminContext, diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/cloudtentacles-writes.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts index 4bdff610..cccf5c48 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts @@ -4,7 +4,7 @@ import { saveKuaishouCloudFulfillmentConfig, } from '../../order/kuaishou-cloud-fulfillment-config-service.js' import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' -import { mapAdminKuaishouCloudFulfillmentSource } from './mappers.js' +import { mapAdminKuaishouCloudFulfillmentSource } from './cloudtentacles/mappers.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts index 924c5d0a..bbc209d8 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts @@ -11,8 +11,8 @@ import { saveKuaishouEticketSourceConfig, } from '../../platforms/kuaishou-eticket/source-config-service.js' import { queryKuaishouEticketStableInfo } from '../../platforms/kuaishou-eticket/info-service.js' -import { resolveKuaishouEticketAdminContext } from './context.js' -import { mapAdminKuaishouEticketSourceConfig } from './mappers.js' +import { resolveKuaishouEticketAdminContext } from './cloudtentacles/context.js' +import { mapAdminKuaishouEticketSourceConfig } from './cloudtentacles/mappers.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index 1b419136..04937424 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -16,13 +16,13 @@ import { reloadScheduledJobs, runScheduledJobNow, } from '../../scheduler/scheduler-service.js' -import { maskPhone, maskSecret } from './mappers.js' +import { maskPhone, maskSecret } from './cloudtentacles/mappers.js' import type { AdminNotificationConfigInput, AdminNotificationTestInput, AdminScheduledJobsConfigInput, -} from '../../../types/admin-write-inputs.js' +} from '../../../types/admin/write-inputs.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/write-helpers.ts b/apps/backend/src/services/admin/write-helpers.ts index 5f4a3a3c..ab9370df 100644 --- a/apps/backend/src/services/admin/write-helpers.ts +++ b/apps/backend/src/services/admin/write-helpers.ts @@ -4,7 +4,7 @@ import { maskPhone as maskPhoneValue, } from '../../utils/masking.js' -import type { TaskRow } from '../../types/repository-rows.js' +import type { TaskRow } from '../../types/repository/rows.js' type TaskClaimLink = { token: string diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 443ed55a..50047e51 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -47,9 +47,9 @@ import { import type { AdminEntityIdInput, AdminViewerSessionInput, -} from '../../../types/admin-read-inputs.js' -import type { AdminTaskKuaishouCloudDispatchInput } from '../../../types/admin-write-inputs.js' -import type { AdminTaskActionResponse } from '../../../types/admin-write-models.js' +} from '../../../types/admin/read-inputs.js' +import type { AdminTaskKuaishouCloudDispatchInput } from '../../../types/admin/write-inputs.js' +import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' export async function prepareAdminTaskKuaishouCloudFulfillment( taskId: AdminEntityIdInput, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 3cc19e9e..cde8804f 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -7,7 +7,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import { buildClaimUrl } from './claim-service.js' -import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' +import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 8b112514..067bd67d 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -20,7 +20,7 @@ import { } from '../fulfillment/kuaishou-cloud/index.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' -import type { TaskRow } from '../../types/repository-rows.js' +import type { TaskRow } from '../../types/repository/rows.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index c055c1b6..292f5bb8 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -4,7 +4,7 @@ import { probeKuaishouCloudTaskBindUrl, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud/index.js' -import type { TaskRow } from '../../types/repository-rows.js' +import type { TaskRow } from '../../types/repository/rows.js' export async function syncKuaishouCloudRoleInfo(task: TaskRow) { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index 86b8e9cf..b35a8ae4 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -43,7 +43,7 @@ import { normalizeActor, parseTaskContext, } from "./task-context.js"; -import type { TaskRow } from "../../../types/repository-rows.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; export { isKuaishouCloudBindUrlFresh, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts index 6b259ee2..bd091073 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts @@ -1,5 +1,5 @@ import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js"; -import type { TaskRow } from "../../../types/repository-rows.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; type JsonObject = Record; diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 2b75c8ba..f069c1b5 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -20,7 +20,7 @@ import { } from "./domain.js"; import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js"; import { normalizeActor, parseTaskContext } from "./task-context.js"; -import type { TaskRow } from "../../../types/repository-rows.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; export async function dispatchKuaishouCloudFulfillmentTask( task: TaskRow, diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 034862af..b8ad2550 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -26,7 +26,7 @@ import { buildOpen91SuccessResponse, resolveOpen91QueryState, } from './response.js' -import type { OrderRow } from '../../types/repository-rows.js' +import type { OrderRow } from '../../types/repository/rows.js' type JsonObject = Record diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 9dc10cb3..7a8953b9 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -6,7 +6,7 @@ import { createTaskClaimToken } from '../claim/claim-service.js' import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' -import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' +import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type JsonObject = Record diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index b6102fa8..da7a1940 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -9,7 +9,7 @@ import { syncDeliveryTasksForOrder } from './delivery-task-service.js' import { resolveOrderItemForFulfillment } from './product-match-service.js' import { nowIso } from '../../utils/time.js' import { logIntegration } from '../../utils/logger.js' -import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' +import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type SourceOrderItem = { quantity?: number diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index c03c0107..3bed443c 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -11,7 +11,7 @@ import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' -import type { OrderItemRow, OrderRow } from '../../../types/repository-rows.js' +import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config' export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed' diff --git a/apps/backend/src/types/admin-read-inputs.ts b/apps/backend/src/types/admin/read-inputs.ts similarity index 100% rename from apps/backend/src/types/admin-read-inputs.ts rename to apps/backend/src/types/admin/read-inputs.ts diff --git a/apps/backend/src/types/admin-read-models.ts b/apps/backend/src/types/admin/read-models.ts similarity index 100% rename from apps/backend/src/types/admin-read-models.ts rename to apps/backend/src/types/admin/read-models.ts diff --git a/apps/backend/src/types/admin-route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts similarity index 97% rename from apps/backend/src/types/admin-route-inputs.ts rename to apps/backend/src/types/admin/route-inputs.ts index fb4f1cfe..b256714d 100644 --- a/apps/backend/src/types/admin-route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -2,7 +2,7 @@ import type { AdminOrderListQueryInput, AdminTaskListQueryInput, AdminViewerSessionInput, -} from './admin-read-inputs.js' +} from './read-inputs.js' import type { AdminCloudtentaclesCatalogQueryInput, AdminCloudtentaclesFullFlowInput, @@ -23,7 +23,7 @@ import type { AdminScheduledJobsConfigInput, AdminTaskKuaishouCloudDispatchInput, AdminTaskManualDispatchInput, -} from './admin-write-inputs.js' +} from './write-inputs.js' export type AdminOrderRouteQuery = AdminOrderListQueryInput export type AdminTaskRouteQuery = AdminTaskListQueryInput diff --git a/apps/backend/src/types/admin-write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts similarity index 100% rename from apps/backend/src/types/admin-write-inputs.ts rename to apps/backend/src/types/admin/write-inputs.ts diff --git a/apps/backend/src/types/admin-write-models.ts b/apps/backend/src/types/admin/write-models.ts similarity index 100% rename from apps/backend/src/types/admin-write-models.ts rename to apps/backend/src/types/admin/write-models.ts diff --git a/apps/backend/src/types/repository-inputs.ts b/apps/backend/src/types/repository/inputs.ts similarity index 100% rename from apps/backend/src/types/repository-inputs.ts rename to apps/backend/src/types/repository/inputs.ts diff --git a/apps/backend/src/types/repository-rows.ts b/apps/backend/src/types/repository/rows.ts similarity index 100% rename from apps/backend/src/types/repository-rows.ts rename to apps/backend/src/types/repository/rows.ts From 8b1904d732234c9a37da2bee699ee0302e81d243 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 09:39:59 +0800 Subject: [PATCH 22/73] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AF=BC=E8=88=AA=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/components.d.ts | 1 + .../src/views/admin/layout/AdminLayout.vue | 104 ++++- .../platform-shops/AdminPlatformShopsView.vue | 414 ++++++++++-------- .../AdminPlatformNinetyoneSection.vue | 15 - 4 files changed, 313 insertions(+), 221 deletions(-) diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index 58b3b015..3ecc6fcf 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -45,6 +45,7 @@ declare module 'vue' { ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSpace: typeof import('element-plus/es')['ElSpace'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] diff --git a/apps/frontend/src/views/admin/layout/AdminLayout.vue b/apps/frontend/src/views/admin/layout/AdminLayout.vue index f2e40359..671839d0 100644 --- a/apps/frontend/src/views/admin/layout/AdminLayout.vue +++ b/apps/frontend/src/views/admin/layout/AdminLayout.vue @@ -2,6 +2,8 @@ import { computed, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { + Bell, + Connection, Monitor, DataAnalysis, Document, @@ -27,6 +29,7 @@ import { formatAdminDateTime } from '@/utils/admin-time' const route = useRoute() const router = useRouter() const sidebarStorageKey = 'admin-sidebar-collapsed' +const isDevMode = import.meta.env.DEV const isSidebarCollapsed = ref( typeof window !== 'undefined' && window.localStorage.getItem(sidebarStorageKey) === '1', ) @@ -40,8 +43,33 @@ const roleLabel = computed(() => { return '普通运营' }) +type AdminNavItem = { + to: string + label: string + icon: typeof DataAnalysis + children?: AdminNavItem[] +} + +const platformConfigNavItems = computed(() => { + const items: AdminNavItem[] = [ + { to: '/admin/platform-shops?tab=ninetyone', label: '91卡券', icon: Connection }, + { to: '/admin/platform-shops?tab=notifications', label: '内部通知', icon: Bell }, + { to: '/admin/platform-shops?tab=kuaishouEticket', label: '快手小店核销', icon: Tickets }, + ] + + if (isDevMode) { + items.push({ + to: '/admin/platform-shops?tab=cloudtentacles', + label: 'cloudtentacles', + icon: Setting, + }) + } + + return items +}) + const navItems = computed(() => { - const items: { to: string; label: string; icon: typeof DataAnalysis }[] = [ + const items: AdminNavItem[] = [ { to: '/admin/dashboard', label: '概览', icon: DataAnalysis }, ] @@ -56,7 +84,12 @@ const navItems = computed(() => { if (isAdmin.value) { items.push( - { to: '/admin/platform-shops', label: '平台配置', icon: Setting }, + { + to: '/admin/platform-shops', + label: '平台配置', + icon: Setting, + children: platformConfigNavItems.value, + }, { to: '/admin/platform-fulfillment', label: '履约配置', icon: Tickets }, { to: '/admin/audit-logs', label: '审计', icon: Monitor }, ) @@ -65,6 +98,15 @@ const navItems = computed(() => { return items }) +const activeNavIndex = computed(() => { + if (route.path === '/admin/platform-shops') { + const tab = typeof route.query.tab === 'string' ? route.query.tab : 'ninetyone' + return `/admin/platform-shops?tab=${tab}` + } + + return route.path +}) + function toggleSidebarCollapse() { isSidebarCollapsed.value = !isSidebarCollapsed.value if (typeof window !== 'undefined') { @@ -112,17 +154,37 @@ async function submitLogout() {
- - - {{ item.label }} - + @@ -235,18 +297,33 @@ async function submitLogout() { width: 100%; } -.sidebar-menu :deep(.el-menu-item) { +.sidebar-menu :deep(.el-menu-item), +.sidebar-menu :deep(.el-sub-menu__title) { margin: 2px var(--space-2); border-radius: var(--radius-md); min-height: 44px; line-height: 44px; } -.sidebar-menu :deep(.el-menu-item.is-active) { +.sidebar-menu :deep(.el-menu-item.is-active), +.sidebar-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) { color: var(--color-primary); background: var(--color-primary-light); } +.sidebar-menu :deep(.el-sub-menu .el-menu-item) { + margin-left: var(--space-4); + min-height: 38px; + line-height: 38px; + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.sidebar-menu :deep(.el-sub-menu .el-menu-item.is-active) { + color: var(--color-primary); + font-weight: 700; +} + .logout-btn { margin: var(--space-3) var(--space-4) 0; flex-shrink: 0; @@ -263,13 +340,15 @@ async function submitLogout() { margin-bottom: var(--space-5); } -.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item) { +.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item), +.is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title) { justify-content: center; margin: 4px 10px; padding: 0; } -.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item .el-icon) { +.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item .el-icon), +.is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title .el-icon) { margin-right: 0; } @@ -313,7 +392,8 @@ async function submitLogout() { overflow: visible; } - .is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item) { + .is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item), + .is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title) { justify-content: flex-start; margin: 2px var(--space-2); padding: 0 var(--space-5); diff --git a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue index db3a162b..e37d6dc4 100644 --- a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue @@ -1,5 +1,6 @@ @@ -267,150 +287,139 @@ onMounted(loadConfigs)
@@ -422,68 +431,85 @@ onMounted(loadConfigs) gap: var(--space-4); } -/* 概览卡片网格 */ -.overview-grid { +.platform-detail { + min-width: 0; display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--space-4); } -.overview-card { - cursor: pointer; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease; -} - -.overview-card :deep(.el-card__body) { - padding: 18px; -} - -.overview-card.is-active { - border-color: var(--border-hover); - box-shadow: var(--shadow-lg); -} - -.card-head { +.active-summary { display: flex; - gap: var(--space-2); - margin-bottom: var(--space-3); + justify-content: space-between; + gap: var(--space-4); + align-items: center; + padding: var(--space-4) var(--space-5); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-sm); } -.card-value { - font-size: 26px; - font-weight: 700; - color: var(--text-primary); - line-height: 1.1; +.active-summary-copy { + min-width: 0; } -.card-unit { - color: var(--text-muted); - font-size: var(--text-sm); - margin-top: var(--space-1); -} - -.card-meta { - color: var(--text-secondary); +.summary-kicker { + color: #2563eb; font-size: var(--text-xs); - margin-top: var(--space-2); + font-weight: 800; } -/* Tab 切换 */ -.platform-tabs { - margin-top: var(--space-2); +.active-summary h2 { + margin: 4px 0 0; + color: var(--text-primary); + font-size: var(--text-xl); + line-height: 1.25; } -@media (max-width: 900px) { - .overview-grid { - grid-template-columns: repeat(2, 1fr); +.active-summary p { + margin: var(--space-2) 0 0; + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.6; +} + +.summary-metrics { + flex: 0 0 auto; + display: flex; + gap: var(--space-3); + align-items: center; +} + +.summary-metric { + min-width: 112px; + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: var(--radius-sm); + background: #f8fafc; +} + +.summary-metric span { + color: var(--text-muted); + font-size: var(--text-xs); + font-weight: 700; +} + +.summary-metric strong { + color: var(--text-primary); + font-size: var(--text-xl); + line-height: 1; +} + +@media (max-width: 720px) { + .active-summary { + align-items: stretch; + flex-direction: column; } -} -@media (max-width: 640px) { - .overview-grid { - grid-template-columns: 1fr; + .summary-metrics { + justify-content: space-between; } } diff --git a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue b/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue index 78b20e4a..3e4b4214 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue @@ -28,21 +28,6 @@ defineProps()