From db22ff691b89f6c652c342e558156e546d7cdb73 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 20:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=BF=AB=E6=89=8B=E8=BD=BB?= =?UTF-8?q?=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: