feat: add database backup and restore in system settings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:07:00 +08:00
parent 97fb95436f
commit 52ebbfbeae
11 changed files with 594 additions and 65 deletions
+83 -1
View File
@@ -1,4 +1,4 @@
"""系统设置:管理员账号。"""
"""系统设置:管理员账号、数据备份与恢复"""
from __future__ import annotations
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from apps.api.auth import auth_disabled, require_auth, verify_credentials
from packages.config import get_settings, reload_settings
from packages.config.env_file import update_env_file
from packages.db.backup import backup_db, ensure_backup_dir, list_backups, resolve_backup_file, restore_db
router = APIRouter(
prefix="/settings",
@@ -26,6 +27,87 @@ class UpdateAccountBody(BaseModel):
new_password: str | None = Field(default=None, max_length=256)
class BackupActionBody(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
class RestoreBody(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
backup_name: str = Field(min_length=1, max_length=128)
def _verify_password_or_401(current_password: str) -> None:
s = get_settings()
if auth_disabled():
raise HTTPException(status_code=400, detail="auth disabled")
if not verify_credentials(s.admin_username, current_password):
raise HTTPException(status_code=401, detail="current password incorrect")
@router.get("/backup")
def get_backup_info() -> dict:
s = get_settings()
backup_dir = s.backup_dir_path
writable = False
try:
ensure_backup_dir(backup_dir)
probe = backup_dir / ".write_probe"
probe.write_text("", encoding="utf-8")
probe.unlink(missing_ok=True)
writable = True
items = list_backups(backup_dir)
except OSError as e:
items = list_backups(backup_dir) if backup_dir.is_dir() else []
if not items:
raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e
return {
"backup_dir": str(backup_dir),
"db_path": str(s.db_path),
"writable": writable,
"backups": items,
}
@router.post("/backup")
def create_backup(body: BackupActionBody) -> dict:
_verify_password_or_401(body.current_password)
s = get_settings()
try:
dest = backup_db(s.db_path, s.backup_dir_path, tz=s.tz)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except OSError as e:
raise HTTPException(status_code=500, detail=f"backup failed: {e}") from e
st = dest.stat()
return {
"ok": True,
"message": "备份完成",
"name": dest.name,
"path": str(dest),
"size_bytes": st.st_size,
}
@router.post("/restore")
def restore_backup(body: RestoreBody) -> dict:
_verify_password_or_401(body.current_password)
s = get_settings()
try:
backup_path = resolve_backup_file(s.backup_dir_path, body.backup_name.strip())
restore_db(backup_path, s.db_path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except OSError as e:
raise HTTPException(status_code=500, detail=f"restore failed: {e}") from e
return {
"ok": True,
"message": f"已从 {body.backup_name} 恢复数据库",
"restored_from": body.backup_name,
}
@router.get("/account")
def get_account() -> dict:
s = get_settings()