From 05ef5ed7bde45669103534551dcdd440cb65ab87 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 21:12:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++ docker/Dockerfile | 2 +- docker/Dockerfile.backend | 2 +- docs/docs.go | 106 ++++++++++++ docs/swagger.json | 106 ++++++++++++ docs/swagger.yaml | 64 +++++++ .../components/common/settings/info-tab.tsx | 103 ++++++++++- frontend/lib/services/admin/admin.service.ts | 35 ++++ frontend/lib/services/admin/index.ts | 1 + frontend/lib/services/admin/types.ts | 12 ++ frontend/lib/services/index.ts | 1 + internal/apps/admin/status/routers.go | 162 ++++++++++++++++++ internal/router/router.go | 4 + 13 files changed, 604 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6865d250..71988766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,16 @@ Handlers: - Every HTTP API needs complete Swagger comments; run `make swagger` after API changes. +错误处理与日志: + +- 任何关键错误在被吞掉、转换为通用响应,或由后台 worker 忽略之前, + 都必须通过 `internal/logger` 打印日志。 +- 禁止用 `_ = ...` 静默丢弃重要错误。如果某个错误因为 best-effort + 操作或确认无害而需要忽略,必须添加简短注释说明原因。 +- Handler 可以返回对用户安全的错误信息,但如果底层运行错误对生产问题 + 排查有价值,仍然必须记录日志。 +- 避免重复刷日志:在真正处理或抑制错误的边界记录一次,然后返回或响应。 + Routes and modules: - Register routes only in `internal/router/router.go`. diff --git a/docker/Dockerfile b/docker/Dockerfile index 4fa02d69..79b71dd2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -44,7 +44,7 @@ FROM alpine:${ALPINE_VERSION} ARG TZ=Asia/Shanghai -RUN apk add --no-cache ca-certificates tzdata && \ +RUN apk add --no-cache ca-certificates tzdata postgresql-client && \ cp /usr/share/zoneinfo/${TZ} /etc/localtime && \ echo "${TZ}" > /etc/timezone diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 0ecb0886..2782e2d0 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -24,7 +24,7 @@ FROM alpine:${ALPINE_VERSION} ARG TZ=Asia/Shanghai -RUN apk add --no-cache ca-certificates tzdata && \ +RUN apk add --no-cache ca-certificates tzdata postgresql-client && \ cp /usr/share/zoneinfo/${TZ} /etc/localtime && \ echo "${TZ}" > /etc/timezone diff --git a/docs/docs.go b/docs/docs.go index 76490faa..d2c7ff91 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -455,6 +455,98 @@ const docTemplate = `{ } } }, + "/api/v1/admin/db-export": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "导出数据库", + "responses": { + "200": { + "description": "数据库文件", + "schema": { + "type": "file" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "导出失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/db-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.DatabaseInfoResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/logs": { "get": { "security": [ @@ -4353,6 +4445,20 @@ const docTemplate = `{ } } }, + "status.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, "status.SystemStatusResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index de8406c3..e9f59861 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -448,6 +448,98 @@ } } }, + "/api/v1/admin/db-export": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "导出数据库", + "responses": { + "200": { + "description": "数据库文件", + "schema": { + "type": "file" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "导出失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/db-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.DatabaseInfoResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/logs": { "get": { "security": [ @@ -4346,6 +4438,20 @@ } } }, + "status.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, "status.SystemStatusResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1f18cea9..ab184116 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -485,6 +485,15 @@ definitions: user: $ref: '#/definitions/oauth.BasicUserInfo' type: object + status.DatabaseInfoResponse: + properties: + name: + type: string + type: + type: string + version: + type: string + type: object status.SystemStatusResponse: properties: alloc: @@ -1169,6 +1178,61 @@ paths: summary: 切换认证源启用状态 tags: - admin + /api/v1/admin/db-export: + get: + description: SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 + produces: + - application/octet-stream + responses: + "200": + description: 数据库文件 + schema: + type: file + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 导出失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 导出数据库 + tags: + - admin + /api/v1/admin/db-info: + get: + description: 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/status.DatabaseInfoResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取数据库信息 + tags: + - admin /api/v1/admin/logs: get: description: 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 diff --git a/frontend/components/common/settings/info-tab.tsx b/frontend/components/common/settings/info-tab.tsx index b3d3eb37..be229cf0 100644 --- a/frontend/components/common/settings/info-tab.tsx +++ b/frontend/components/common/settings/info-tab.tsx @@ -1,9 +1,14 @@ "use client" -import {Info, Server} from "lucide-react" +import {useState} from "react" +import {Database, Download, Info, Loader2, Server} from "lucide-react" import packageJson from "../../../package.json" -import {apiConfig} from "@/lib/services" +import {AdminService, apiConfig} from "@/lib/services" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Button} from "@/components/ui/button" +import {Badge} from "@/components/ui/badge" +import {useQuery} from "@tanstack/react-query" +import {toast} from "sonner" function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return ( @@ -20,6 +25,37 @@ interface InfoTabProps { } export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps) { + const [exporting, setExporting] = useState(false) + + const dbInfoQuery = useQuery({ + queryKey: ["admin", "db-info"], + queryFn: () => AdminService.getDatabaseInfo(), + }) + + const dbInfo = dbInfoQuery.data + + const handleExport = async () => { + setExporting(true) + try { + const { blob, filename } = await AdminService.exportDatabase() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + toast.success("数据库导出成功", { description: `文件已下载:${filename}` }) + } catch (err) { + toast.error("数据库导出失败", { + description: err instanceof Error ? err.message : "请求时发生未知错误", + }) + } finally { + setExporting(false) + } + } + return (
@@ -63,6 +99,69 @@ export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps + + {/* 数据库信息卡片 */} + + +
+
+
+ +
+
+ 数据库信息 + 当前使用的数据库类型及连接信息 +
+
+ +
+
+ + {dbInfoQuery.isLoading ? ( +
+ + 加载数据库信息中... +
+ ) : dbInfoQuery.isError ? ( +

获取数据库信息失败

+ ) : ( + <> +
+ 数据库类型 + + {dbInfo?.type === "postgres" ? "PostgreSQL" : "SQLite"} + +
+ + + + )} +
+
) } diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index b52a8b7d..15796c7c 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -6,6 +6,7 @@ import type { CreateSystemConfigRequest, CreateTemplateRequest, CreateUserRequest, + DatabaseInfo, DispatchTaskRequest, ListTaskExecutionsRequest, ListTaskExecutionsResponse, @@ -330,6 +331,40 @@ export class AdminService extends BaseService { return this.get('/status'); } + /** + * 获取数据库信息 + * @returns 数据库类型、名称、版本信息 + * @throws {UnauthorizedError} 当未登录时 + * @throws {ForbiddenError} 当无管理员权限时 + */ + static async getDatabaseInfo(): Promise { + return this.get('/db-info'); + } + + /** + * 导出数据库 + * SQLite 返回 .db 二进制,PostgreSQL 返回 pg_dump .sql 文本 + * @returns 包含文件 Blob 和建议文件名的对象 + * @throws {UnauthorizedError} 当未登录时 + * @throws {ForbiddenError} 当无管理员权限时 + */ + static async exportDatabase(): Promise<{ blob: Blob; filename: string }> { + const response = await import('axios').then(({ default: axios }) => + axios.get(this.getFullPath('/db-export'), { + withCredentials: true, + responseType: 'blob', + }) + ); + // 从 Content-Disposition 提取文件名 + const disposition = response.headers['content-disposition'] as string | undefined; + let filename = 'wavelet_export'; + if (disposition) { + const match = disposition.match(/filename="?([^";]+)"?/); + if (match) filename = match[1]; + } + return { blob: response.data, filename }; + } + // ==================== 系统日志 ==================== /** diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index b2166616..719acd34 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -39,4 +39,5 @@ export type { ListUsersResponse, UpdateUserStatusRequest, SystemStatus, + DatabaseInfo, } from './types'; diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 8f2ed0e7..8848cf23 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -381,3 +381,15 @@ export interface UpdateTemplateRequest { content: string; description: string; } + +/** + * 数据库信息 + */ +export interface DatabaseInfo { + /** 数据库类型:'sqlite' | 'postgres' */ + type: 'sqlite' | 'postgres'; + /** 数据库名称或文件路径 */ + name: string; + /** 数据库版本字符串 */ + version: string; +} diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index f8e169df..8ed3c61b 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -106,6 +106,7 @@ export type { ListUsersResponse, UpdateUserStatusRequest, SystemStatus, + DatabaseInfo, } from './admin'; // 用户服务 diff --git a/internal/apps/admin/status/routers.go b/internal/apps/admin/status/routers.go index 92ee1e25..554e0de5 100644 --- a/internal/apps/admin/status/routers.go +++ b/internal/apps/admin/status/routers.go @@ -19,12 +19,18 @@ limitations under the License. package status import ( + "context" "fmt" + "log" "math" "net/http" + "os" + "os/exec" "runtime" "time" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" ) @@ -200,3 +206,159 @@ func GetSystemStatus(c *gin.Context) { c.JSON(http.StatusOK, util.OK(res)) } + +// DatabaseInfoResponse 数据库信息响应结构体 +type DatabaseInfoResponse struct { + Type string `json:"type"` + Name string `json:"name"` + Version string `json:"version"` +} + +// getSQLiteInfo 返回 SQLite 数据库信息 +func getSQLiteInfo(ctx context.Context) DatabaseInfoResponse { + info := DatabaseInfoResponse{ + Type: "sqlite", + Name: config.Config.Database.SQLitePath, + Version: "SQLite", + } + if info.Name == "" { + info.Name = "./data/wavelet.db" + } + gormDB := db.DB(ctx) + if gormDB == nil { + return info + } + var ver string + if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil && ver != "" { + info.Version = "SQLite " + ver + } + return info +} + +// getPostgresInfo 返回 PostgreSQL 数据库信息 +func getPostgresInfo(ctx context.Context) DatabaseInfoResponse { + info := DatabaseInfoResponse{ + Type: "postgres", + Name: config.Config.Database.Database, + Version: "PostgreSQL", + } + gormDB := db.DB(ctx) + if gormDB == nil { + return info + } + var ver string + if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil && ver != "" { + info.Version = ver + } + return info +} + +// GetDatabaseInfo 获取当前数据库类型及版本信息 +// @Summary 获取数据库信息 +// @Description 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=status.DatabaseInfoResponse} "获取成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/db-info [get] +func GetDatabaseInfo(c *gin.Context) { + var info DatabaseInfoResponse + if !config.Config.Database.Enabled { + info = getSQLiteInfo(c.Request.Context()) + } else { + info = getPostgresInfo(c.Request.Context()) + } + c.JSON(http.StatusOK, util.OK(info)) +} + +// ExportDatabase 导出数据库 +// @Summary 导出数据库 +// @Description SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 +// @Tags admin +// @Produce application/octet-stream +// @Security SessionCookie +// @Success 200 {file} binary "数据库文件" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "导出失败" +// @Router /api/v1/admin/db-export [get] +func ExportDatabase(c *gin.Context) { + if !config.Config.Database.Enabled { + exportSQLite(c) + } else { + exportPostgres(c) + } +} + +// exportSQLite 以 HTTP 附件方式下载 SQLite .db 文件 +func exportSQLite(c *gin.Context) { + path := config.Config.Database.SQLitePath + if path == "" { + path = "./data/wavelet.db" + } + + f, err := os.Open(path) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("无法打开数据库文件: "+err.Error())) + return + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + _ = closeErr + } + }() + + fi, err := f.Stat() + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("无法读取数据库文件信息: "+err.Error())) + return + } + + c.Header("Content-Disposition", `attachment; filename="wavelet.db"`) + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", fi.Size())) + c.Status(http.StatusOK) + http.ServeContent(c.Writer, c.Request, "wavelet.db", fi.ModTime(), f) +} + +// exportPostgres 执行 pg_dump 并将输出流式传输给客户端 +func exportPostgres(c *gin.Context) { + dbCfg := config.Config.Database + + // 检查 pg_dump 是否可用 + pgDumpPath, err := exec.LookPath("pg_dump") + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具")) + return + } + + args := []string{ + "--no-password", + "-h", dbCfg.Host, + "-p", fmt.Sprintf("%d", dbCfg.Port), + "-U", dbCfg.Username, + dbCfg.Database, + } + + cmd := exec.CommandContext(c.Request.Context(), pgDumpPath, args...) + if dbCfg.Password != "" { + cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) + } else { + cmd.Env = os.Environ() + } + + fileName := fmt.Sprintf("wavelet_%s.sql", time.Now().Format("20060102_150405")) + c.Header("Content-Disposition", `attachment; filename="`+fileName+`"`) + c.Header("Content-Type", "application/octet-stream") + c.Status(http.StatusOK) + + cmd.Stdout = c.Writer + cmd.Stderr = nil // 忽略 stderr 以避免污染输出流 + + if err := cmd.Run(); err != nil { + // 响应头已发出,无法再写 JSON 错误,记录到服务器日志 + log.Printf("[db-export] pg_dump failed: %v\n", err) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index b0d7edb6..9a1586db 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -197,6 +197,10 @@ func Serve() { // System status adminRouter.GET("/status", admin_status.GetSystemStatus) + // Database info & export + adminRouter.GET("/db-info", admin_status.GetDatabaseInfo) + adminRouter.GET("/db-export", admin_status.ExportDatabase) + // System logs adminRouter.GET("/logs", admin_logs.GetLogs) adminRouter.GET("/logs/access", admin_logs.GetAccessLogs)