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 (
获取数据库信息失败
+ ) : ( + <> +