Просмотр исходного кода

fix: 修复数据库备份功能的架构问题

修复容器内无 Docker 命令导致备份失败的问题,改为直接连接数据库。

**问题分析**:
- 应用运行在 Docker 容器内,容器本身没有 docker 命令
- 原代码尝试使用 `docker exec` 访问数据库容器(Docker-in-Docker 反模式)
- 导致所有数据库备份操作失败,日志显示 "executable file not found"

**架构改进**:
1. 安装 PostgreSQL 客户端工具到应用容器(postgresql-client)
2. 新增 DSN 解析模块(db-config.ts),从连接字符串提取主机、端口等参数
3. 重写所有数据库操作函数,直接通过网络连接数据库:
   - `executePgDump()` - 使用 pg_dump 直连导出
   - `executePgRestore()` - 使用 pg_restore 直连导入
   - `getDatabaseInfo()` - 使用 psql 查询数据库信息
   - `checkDatabaseConnection()` - 使用 pg_isready 检查连接

**代码变更**:
- 新增 `src/lib/database-backup/db-config.ts` - DSN 解析
- 重构 `src/lib/database-backup/docker-executor.ts` - 移除所有 Docker 依赖
- 更新 3 个 API 路由 - 使用新的简化函数签名
- 修改 `deploy/Dockerfile` - 安装 postgresql-client
- 更新 `.env.example` - 移除废弃的 POSTGRES_CONTAINER_NAME
- 更新前端显示 - "容器名称" → "数据库地址"

**测试建议**:
需要重新构建 Docker 镜像以包含 PostgreSQL 客户端工具:
```bash
docker compose pull && docker compose up -d --build
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 месяцев назад
Родитель
Сommit
e22322208b

+ 0 - 6
.env.example

@@ -13,12 +13,6 @@ DB_USER=postgres
 DB_PASSWORD=your-secure-password_change-me
 DB_NAME=claude_code_hub
 
-# 数据库备份配置
-# PostgreSQL Docker 容器名称(用于数据库导入/导出功能)
-# - 生产环境默认: claude-code-hub-db
-# - 开发环境(dev/): claude-relay-postgres-dev
-POSTGRES_CONTAINER_NAME=claude-code-hub-db
-
 # 应用配置
 APP_PORT=23000
 

+ 5 - 0
deploy/Dockerfile

@@ -28,6 +28,11 @@ ENV PORT=3000
 ENV HOST=0.0.0.0
 WORKDIR /app
 
+# 安装 PostgreSQL 客户端工具(用于数据库备份/恢复功能)
+RUN apt-get update && \
+    apt-get install -y postgresql-client && \
+    rm -rf /var/lib/apt/lists/*
+
 COPY --from=build --chown=node:node /app/public ./public
 COPY --from=build --chown=node:node /app/drizzle ./drizzle
 COPY --from=build --chown=node:node /app/.next/standalone ./

+ 6 - 11
src/app/api/admin/database/export/route.ts

@@ -1,10 +1,7 @@
-import { executePgDump, checkDockerContainer } from "@/lib/database-backup/docker-executor";
+import { executePgDump, checkDatabaseConnection } from "@/lib/database-backup/docker-executor";
 import { logger } from "@/lib/logger";
 import { getSession } from "@/lib/auth";
 
-const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || "claude-code-hub-db";
-const DATABASE_NAME = process.env.DB_NAME || "claude_code_hub";
-
 /**
  * 导出数据库备份
  *
@@ -21,21 +18,20 @@ export async function GET() {
       return new Response("Unauthorized", { status: 401 });
     }
 
-    // 2. 检查 Docker 容器是否可用
-    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+    // 2. 检查数据库连接
+    const isAvailable = await checkDatabaseConnection();
     if (!isAvailable) {
       logger.error({
-        action: "database_export_container_unavailable",
-        containerName: CONTAINER_NAME,
+        action: "database_export_connection_unavailable",
       });
       return Response.json(
-        { error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署` },
+        { error: "数据库连接不可用,请检查数据库服务状态" },
         { status: 503 }
       );
     }
 
     // 3. 执行 pg_dump
-    const stream = executePgDump(CONTAINER_NAME, DATABASE_NAME);
+    const stream = executePgDump();
 
     // 4. 生成文件名(带时间戳)
     const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
@@ -44,7 +40,6 @@ export async function GET() {
     logger.info({
       action: "database_export_initiated",
       filename,
-      databaseName: DATABASE_NAME,
     });
 
     // 5. 返回流式响应

+ 6 - 11
src/app/api/admin/database/import/route.ts

@@ -1,11 +1,8 @@
 import { writeFile, unlink } from "fs/promises";
-import { executePgRestore, checkDockerContainer } from "@/lib/database-backup/docker-executor";
+import { executePgRestore, checkDatabaseConnection } from "@/lib/database-backup/docker-executor";
 import { logger } from "@/lib/logger";
 import { getSession } from "@/lib/auth";
 
-const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || "claude-code-hub-db";
-const DATABASE_NAME = process.env.DB_NAME || "claude_code_hub";
-
 /**
  * 导入数据库备份
  *
@@ -28,15 +25,14 @@ export async function POST(request: Request) {
       return new Response("Unauthorized", { status: 401 });
     }
 
-    // 2. 检查 Docker 容器是否可用
-    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+    // 2. 检查数据库连接
+    const isAvailable = await checkDatabaseConnection();
     if (!isAvailable) {
       logger.error({
-        action: "database_import_container_unavailable",
-        containerName: CONTAINER_NAME,
+        action: "database_import_connection_unavailable",
       });
       return Response.json(
-        { error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署` },
+        { error: "数据库连接不可用,请检查数据库服务状态" },
         { status: 503 }
       );
     }
@@ -60,7 +56,6 @@ export async function POST(request: Request) {
       filename: file.name,
       fileSize: file.size,
       cleanFirst,
-      databaseName: DATABASE_NAME,
     });
 
     // 5. 保存上传文件到临时目录
@@ -74,7 +69,7 @@ export async function POST(request: Request) {
     });
 
     // 6. 执行 pg_restore,返回 SSE 流
-    const stream = executePgRestore(CONTAINER_NAME, DATABASE_NAME, tempFilePath, cleanFirst);
+    const stream = executePgRestore(tempFilePath, cleanFirst);
 
     // 7. 清理临时文件的逻辑(在流结束后执行)
     const cleanupStream = new TransformStream({

+ 19 - 17
src/app/api/admin/database/status/route.ts

@@ -1,11 +1,9 @@
-import { checkDockerContainer, getDatabaseInfo } from "@/lib/database-backup/docker-executor";
+import { checkDatabaseConnection, getDatabaseInfo } from "@/lib/database-backup/docker-executor";
+import { getDatabaseConfig } from "@/lib/database-backup/db-config";
 import { logger } from "@/lib/logger";
 import { getSession } from "@/lib/auth";
 import type { DatabaseStatus } from "@/types/database-backup";
 
-const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || "claude-code-hub-db";
-const DATABASE_NAME = process.env.DB_NAME || "claude_code_hub";
-
 /**
  * 获取数据库状态信息
  *
@@ -22,36 +20,40 @@ export async function GET() {
       return new Response("Unauthorized", { status: 401 });
     }
 
-    // 2. 检查 Docker 容器是否可用
-    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+    // 2. 获取数据库配置
+    const dbConfig = getDatabaseConfig();
+
+    // 3. 检查数据库连接
+    const isAvailable = await checkDatabaseConnection();
 
     if (!isAvailable) {
       const status: DatabaseStatus = {
         isAvailable: false,
-        containerName: CONTAINER_NAME,
-        databaseName: DATABASE_NAME,
+        containerName: `${dbConfig.host}:${dbConfig.port}`,
+        databaseName: dbConfig.database,
         databaseSize: "N/A",
         tableCount: 0,
         postgresVersion: "N/A",
-        error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署`,
+        error: "数据库连接不可用,请检查数据库服务状态",
       };
 
       logger.warn({
-        action: "database_status_container_unavailable",
-        containerName: CONTAINER_NAME,
+        action: "database_status_connection_unavailable",
+        host: dbConfig.host,
+        port: dbConfig.port,
       });
 
       return Response.json(status, { status: 200 });
     }
 
-    // 3. 获取数据库详细信息
+    // 4. 获取数据库详细信息
     try {
-      const info = await getDatabaseInfo(CONTAINER_NAME, DATABASE_NAME);
+      const info = await getDatabaseInfo();
 
       const status: DatabaseStatus = {
         isAvailable: true,
-        containerName: CONTAINER_NAME,
-        databaseName: DATABASE_NAME,
+        containerName: `${dbConfig.host}:${dbConfig.port}`,
+        databaseName: dbConfig.database,
         databaseSize: info.size,
         tableCount: info.tableCount,
         postgresVersion: info.version,
@@ -66,8 +68,8 @@ export async function GET() {
     } catch (infoError) {
       const status: DatabaseStatus = {
         isAvailable: true,
-        containerName: CONTAINER_NAME,
-        databaseName: DATABASE_NAME,
+        containerName: `${dbConfig.host}:${dbConfig.port}`,
+        databaseName: dbConfig.database,
         databaseSize: "Unknown",
         tableCount: 0,
         postgresVersion: "Unknown",

+ 1 - 1
src/app/internal/data-gen/_components/data-generator-page.tsx

@@ -284,7 +284,7 @@ export function DataGeneratorPage() {
 
           <Card>
             <CardHeader>
-              <CardTitle>生成的日志数据</CardTitle>
+              <CardTitle>使用日志</CardTitle>
               <CardDescription>共 {result.logs.length} 条记录</CardDescription>
             </CardHeader>
             <CardContent>

+ 1 - 1
src/app/settings/data/_components/database-status.tsx

@@ -123,7 +123,7 @@ export function DatabaseStatusDisplay() {
 
       {/* 详细信息 */}
       <div className="text-xs text-muted-foreground space-y-1">
-        <p>容器名称: <span className="font-mono">{status.containerName}</span></p>
+        <p>数据库地址: <span className="font-mono">{status.containerName}</span></p>
         <p>数据库名称: <span className="font-mono">{status.databaseName}</span></p>
       </div>
     </div>

+ 51 - 0
src/lib/database-backup/db-config.ts

@@ -0,0 +1,51 @@
+/**
+ * 数据库连接配置
+ * 从 DSN 环境变量解析数据库连接参数
+ */
+
+export interface DatabaseConfig {
+  host: string;
+  port: number;
+  user: string;
+  password: string;
+  database: string;
+}
+
+/**
+ * 从 PostgreSQL DSN 解析数据库连接配置
+ *
+ * 支持格式:
+ * - postgresql://user:password@host:port/database
+ * - postgres://user:password@host:port/database
+ *
+ * @param dsn - 数据库连接字符串
+ * @returns DatabaseConfig
+ */
+export function parseDatabaseDSN(dsn: string): DatabaseConfig {
+  try {
+    const url = new URL(dsn);
+
+    return {
+      host: url.hostname || 'localhost',
+      port: url.port ? parseInt(url.port, 10) : 5432,
+      user: url.username || 'postgres',
+      password: url.password || '',
+      database: url.pathname.slice(1) || 'postgres', // 移除开头的 /
+    };
+  } catch (error) {
+    throw new Error(`Invalid database DSN: ${error instanceof Error ? error.message : String(error)}`);
+  }
+}
+
+/**
+ * 获取当前数据库配置
+ */
+export function getDatabaseConfig(): DatabaseConfig {
+  const dsn = process.env.DSN;
+
+  if (!dsn) {
+    throw new Error('DSN environment variable is not set');
+  }
+
+  return parseDatabaseDSN(dsn);
+}

+ 123 - 95
src/lib/database-backup/docker-executor.ts

@@ -1,78 +1,70 @@
-import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
+import { spawn } from "child_process";
 import { createReadStream } from "fs";
 import { logger } from "@/lib/logger";
-
-/**
- * 检查 Docker 容器是否可用
- */
-export async function checkDockerContainer(containerName: string): Promise<boolean> {
-  return new Promise((resolve) => {
-    const process = spawn("docker", ["inspect", containerName]);
-
-    process.on("close", (code) => {
-      resolve(code === 0);
-    });
-
-    process.on("error", () => {
-      resolve(false);
-    });
-  });
-}
+import { getDatabaseConfig } from "./db-config";
 
 /**
  * 执行 pg_dump 导出数据库
  *
- * @param containerName Docker 容器名称
- * @param databaseName 数据库名称
  * @returns ReadableStream 数据流
  */
-export function executePgDump(
-  containerName: string,
-  databaseName: string
-): ReadableStream<Uint8Array> {
-  const process = spawn("docker", [
-    "exec",
-    containerName,
+export function executePgDump(): ReadableStream<Uint8Array> {
+  const dbConfig = getDatabaseConfig();
+
+  const pgProcess = spawn(
     "pg_dump",
-    "-Fc", // Custom format (compressed)
-    "-v", // Verbose
-    "-d",
-    databaseName,
-  ]);
+    [
+      "-h",
+      dbConfig.host,
+      "-p",
+      dbConfig.port.toString(),
+      "-U",
+      dbConfig.user,
+      "-d",
+      dbConfig.database,
+      "-Fc", // Custom format (compressed)
+      "-v", // Verbose
+    ],
+    {
+      env: {
+        ...process.env,
+        PGPASSWORD: dbConfig.password,
+      },
+    }
+  );
 
   logger.info({
     action: "pg_dump_start",
-    containerName,
-    databaseName,
+    host: dbConfig.host,
+    port: dbConfig.port,
+    database: dbConfig.database,
   });
 
   return new ReadableStream({
     start(controller) {
       // 监听 stdout (数据输出)
-      process.stdout.on("data", (chunk: Buffer) => {
+      pgProcess.stdout.on("data", (chunk: Buffer) => {
         controller.enqueue(new Uint8Array(chunk));
       });
 
       // 监听 stderr (日志输出)
-      process.stderr.on("data", (chunk: Buffer) => {
+      pgProcess.stderr.on("data", (chunk: Buffer) => {
         logger.info(`[pg_dump] ${chunk.toString().trim()}`);
       });
 
       // 进程结束
-      process.on("close", (code) => {
+      pgProcess.on("close", (code: number | null) => {
         if (code === 0) {
           logger.info({
             action: "pg_dump_complete",
-            containerName,
-            databaseName,
+            database: dbConfig.database,
           });
           controller.close();
         } else {
           const error = `pg_dump 失败,退出代码: ${code}`;
           logger.error({
             action: "pg_dump_error",
-            containerName,
-            databaseName,
+            database: dbConfig.database,
             exitCode: code,
           });
           controller.error(new Error(error));
@@ -80,7 +72,7 @@ export function executePgDump(
       });
 
       // 进程错误
-      process.on("error", (err) => {
+      pgProcess.on("error", (err: Error) => {
         logger.error({
           action: "pg_dump_spawn_error",
           error: err.message,
@@ -90,11 +82,10 @@ export function executePgDump(
     },
 
     cancel() {
-      process.kill();
+      pgProcess.kill();
       logger.warn({
         action: "pg_dump_cancelled",
-        containerName,
-        databaseName,
+        database: dbConfig.database,
       });
     },
   });
@@ -103,26 +94,23 @@ export function executePgDump(
 /**
  * 执行 pg_restore 导入数据库
  *
- * @param containerName Docker 容器名称
- * @param databaseName 数据库名称
  * @param filePath 备份文件路径
  * @param cleanFirst 是否清除现有数据
  * @returns ReadableStream SSE 格式的进度流
  */
-export function executePgRestore(
-  containerName: string,
-  databaseName: string,
-  filePath: string,
-  cleanFirst: boolean
-): ReadableStream<Uint8Array> {
+export function executePgRestore(filePath: string, cleanFirst: boolean): ReadableStream<Uint8Array> {
+  const dbConfig = getDatabaseConfig();
+
   const args = [
-    "exec",
-    "-i", // 交互模式(接收 stdin)
-    containerName,
-    "pg_restore",
-    "-v", // Verbose(输出详细进度)
+    "-h",
+    dbConfig.host,
+    "-p",
+    dbConfig.port.toString(),
+    "-U",
+    dbConfig.user,
     "-d",
-    databaseName,
+    dbConfig.database,
+    "-v", // Verbose(输出详细进度)
   ];
 
   // 覆盖模式:清除现有数据
@@ -130,26 +118,32 @@ export function executePgRestore(
     args.push("--clean", "--if-exists");
   }
 
-  const process = spawn("docker", args);
+  const pgProcess = spawn("pg_restore", args, {
+    env: {
+      ...process.env,
+      PGPASSWORD: dbConfig.password,
+    },
+  });
 
   logger.info({
     action: "pg_restore_start",
-    containerName,
-    databaseName,
+    host: dbConfig.host,
+    port: dbConfig.port,
+    database: dbConfig.database,
     cleanFirst,
     filePath,
   });
 
   // 将备份文件通过 stdin 传给 pg_restore
   const fileStream = createReadStream(filePath);
-  fileStream.pipe(process.stdin);
+  fileStream.pipe(pgProcess.stdin);
 
   const encoder = new TextEncoder();
 
   return new ReadableStream({
     start(controller) {
       // 监听 stderr(pg_restore 的进度信息都输出到 stderr)
-      process.stderr.on("data", (chunk: Buffer) => {
+      pgProcess.stderr.on("data", (chunk: Buffer) => {
         const message = chunk.toString().trim();
         logger.info(`[pg_restore] ${message}`);
 
@@ -159,7 +153,7 @@ export function executePgRestore(
       });
 
       // 监听 stdout(一般为空,但为了完整性还是处理)
-      process.stdout.on("data", (chunk: Buffer) => {
+      pgProcess.stdout.on("data", (chunk: Buffer) => {
         const message = chunk.toString().trim();
         if (message) {
           logger.info(`[pg_restore stdout] ${message}`);
@@ -167,12 +161,11 @@ export function executePgRestore(
       });
 
       // 进程结束
-      process.on("close", (code) => {
+      pgProcess.on("close", (code: number | null) => {
         if (code === 0) {
           logger.info({
             action: "pg_restore_complete",
-            containerName,
-            databaseName,
+            database: dbConfig.database,
           });
 
           const completeMessage = `data: ${JSON.stringify({
@@ -184,8 +177,7 @@ export function executePgRestore(
         } else {
           logger.error({
             action: "pg_restore_error",
-            containerName,
-            databaseName,
+            database: dbConfig.database,
             exitCode: code,
           });
 
@@ -201,7 +193,7 @@ export function executePgRestore(
       });
 
       // 进程错误
-      process.on("error", (err) => {
+      pgProcess.on("error", (err: Error) => {
         logger.error({
           action: "pg_restore_spawn_error",
           error: err.message,
@@ -217,12 +209,11 @@ export function executePgRestore(
     },
 
     cancel() {
-      process.kill();
+      pgProcess.kill();
       fileStream.destroy();
       logger.warn({
         action: "pg_restore_cancelled",
-        containerName,
-        databaseName,
+        database: dbConfig.database,
       });
     },
   });
@@ -231,50 +222,59 @@ export function executePgRestore(
 /**
  * 获取数据库信息
  */
-export async function getDatabaseInfo(
-  containerName: string,
-  databaseName: string
-): Promise<{
+export async function getDatabaseInfo(): Promise<{
   size: string;
   tableCount: number;
   version: string;
 }> {
+  const dbConfig = getDatabaseConfig();
+
   return new Promise((resolve, reject) => {
     // 查询数据库大小和表数量
     const query = `
       SELECT
-        pg_size_pretty(pg_database_size('${databaseName}')) as size,
+        pg_size_pretty(pg_database_size('${dbConfig.database}')) as size,
         (SELECT count(*) FROM information_schema.tables
          WHERE table_schema = 'public' AND table_type = 'BASE TABLE') as table_count,
         version() as version;
     `;
 
-    const process = spawn("docker", [
-      "exec",
-      containerName,
+    const pgProcess = spawn(
       "psql",
-      "-U",
-      "postgres",
-      "-d",
-      databaseName,
-      "-t", // 不显示列名
-      "-A", // 不对齐
-      "-c",
-      query,
-    ]);
+      [
+        "-h",
+        dbConfig.host,
+        "-p",
+        dbConfig.port.toString(),
+        "-U",
+        dbConfig.user,
+        "-d",
+        dbConfig.database,
+        "-t", // 不显示列名
+        "-A", // 不对齐
+        "-c",
+        query,
+      ],
+      {
+        env: {
+          ...process.env,
+          PGPASSWORD: dbConfig.password,
+        },
+      }
+    );
 
     let output = "";
     let error = "";
 
-    process.stdout.on("data", (chunk) => {
+    pgProcess.stdout.on("data", (chunk: Buffer) => {
       output += chunk.toString();
     });
 
-    process.stderr.on("data", (chunk) => {
+    pgProcess.stderr.on("data", (chunk: Buffer) => {
       error += chunk.toString();
     });
 
-    process.on("close", (code) => {
+    pgProcess.on("close", (code: number | null) => {
       if (code === 0) {
         const lines = output.trim().split("\n");
         if (lines.length > 0) {
@@ -292,8 +292,36 @@ export async function getDatabaseInfo(
       }
     });
 
-    process.on("error", (err) => {
+    pgProcess.on("error", (err: Error) => {
       reject(err);
     });
   });
 }
+
+/**
+ * 检查数据库连接是否可用
+ */
+export async function checkDatabaseConnection(): Promise<boolean> {
+  const dbConfig = getDatabaseConfig();
+
+  return new Promise((resolve) => {
+    const pgProcess = spawn(
+      "pg_isready",
+      ["-h", dbConfig.host, "-p", dbConfig.port.toString(), "-U", dbConfig.user, "-d", dbConfig.database],
+      {
+        env: {
+          ...process.env,
+          PGPASSWORD: dbConfig.password,
+        },
+      }
+    );
+
+    pgProcess.on("close", (code: number | null) => {
+      resolve(code === 0);
+    });
+
+    pgProcess.on("error", () => {
+      resolve(false);
+    });
+  });
+}