新增后台数据库备份与回滚

This commit is contained in:
2026-06-23 12:27:16 +08:00
parent 668e0403af
commit 529e175d2c
17 changed files with 754 additions and 26 deletions

View File

@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl fonts-noto-cjk \
&& apt-get install -y --no-install-recommends curl default-mysql-client fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.routes import (
admin_backups,
admin_certificates,
admin_dashboard,
admin_exports,
@@ -25,4 +26,5 @@ api_router.include_router(admin_imports.router, prefix="/admin/import-batches",
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
api_router.include_router(admin_pdf_cleanup.router, prefix="/admin/pdf-cleanup", tags=["admin-pdf-cleanup"])
api_router.include_router(admin_backups.router, prefix="/admin/backups", tags=["admin-backups"])
api_router.include_router(public.router, prefix="/public", tags=["public"])

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Body, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import SessionLocal, get_db
from app.models import AdminUser
from app.schemas.backup import (
CreateDatabaseBackupRequest,
DatabaseBackupOut,
RestoreDatabaseBackupOut,
RestoreDatabaseBackupRequest,
)
from app.services.db_backup import BackupError, create_database_backup, list_database_backups, restore_database_backup
from app.services.logs import log_action
router = APIRouter()
@router.get("", response_model=list[DatabaseBackupOut])
def list_backups(
_: AdminUser = Depends(require_roles("system_admin")),
) -> list[dict[str, object]]:
return list_database_backups()
@router.post("", response_model=DatabaseBackupOut)
def create_backup(
payload: CreateDatabaseBackupRequest | None = Body(default=None),
db: Session = Depends(get_db),
current_admin: AdminUser = Depends(require_roles("system_admin")),
) -> dict[str, object]:
try:
backup = create_database_backup(reason=payload.reason if payload else "manual")
except BackupError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
log_action(
db,
current_admin,
"create_database_backup",
"database_backup",
backup["filename"],
detail={
"filename": backup["filename"],
"database_type": backup["database_type"],
"size_label": backup["size_label"],
"reason": backup.get("reason"),
},
)
db.commit()
return backup
@router.post("/{filename}/restore", response_model=RestoreDatabaseBackupOut)
def restore_backup(
filename: str,
payload: RestoreDatabaseBackupRequest,
db: Session = Depends(get_db),
current_admin: AdminUser = Depends(require_roles("system_admin")),
) -> dict[str, object]:
db.close()
try:
result = restore_database_backup(filename, payload.confirmation)
except BackupError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
with SessionLocal() as log_db:
log_action(
log_db,
current_admin,
"restore_database_backup",
"database_backup",
filename,
detail={
"filename": filename,
"restored_backup": result["restored_backup"],
"pre_restore_backup": result["pre_restore_backup"],
},
)
log_db.commit()
return result

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin
from app.api.deps import get_current_admin, require_roles
from app.db.session import get_db
from app.models import AdminUser
from app.services.pdf_cleanup import (
@@ -23,29 +23,18 @@ def get_pdf_stats(
@router.post("/cleanup")
def trigger_cleanup(
current_admin: AdminUser = Depends(get_current_admin),
_: AdminUser = Depends(require_roles("system_admin")),
) -> dict:
"""手动触发PDF清理"""
if current_admin.role_code != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以执行此操作",
)
return cleanup_expired_pdfs()
@router.post("/cleanup/{certificate_no}")
def cleanup_single_certificate(
certificate_no: str,
current_admin: AdminUser = Depends(get_current_admin),
_: AdminUser = Depends(require_roles("system_admin")),
) -> dict:
"""手动清理指定证书的PDF"""
if current_admin.role_code != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以执行此操作",
)
success = manual_cleanup_certificate(certificate_no)
if success:
return {"message": f"证书 {certificate_no} 的PDF已清理"}

View File

@@ -6,7 +6,7 @@ DATA_ROOT = Path(settings.data_root)
def ensure_data_dirs() -> None:
for name in ["uploads", "error-reports", "exports", "pdf-cache"]:
for name in ["uploads", "error-reports", "exports", "pdf-cache", "db-backups"]:
(DATA_ROOT / name).mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,26 @@
from datetime import datetime
from pydantic import BaseModel, Field
class CreateDatabaseBackupRequest(BaseModel):
reason: str | None = Field(default="manual", max_length=120)
class RestoreDatabaseBackupRequest(BaseModel):
confirmation: str = Field(min_length=1, max_length=200)
class DatabaseBackupOut(BaseModel):
filename: str
database_type: str
created_at: datetime
size_bytes: int
size_label: str
reason: str | None = None
note: str | None = None
class RestoreDatabaseBackupOut(BaseModel):
restored_backup: DatabaseBackupOut
pre_restore_backup: DatabaseBackupOut

View File

@@ -0,0 +1,319 @@
import gzip
import json
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.engine import make_url
from app.core.config import settings
from app.core.paths import data_path
from app.db.session import engine
BACKUP_PREFIX = "certificate_system"
BACKUP_TIMEOUT_SECONDS = 600
BACKUP_NAME_PATTERN = re.compile(
r"^certificate_system_\d{8}_\d{6}(?:_[a-z0-9_-]+)?\.(?:sql\.gz|sqlite3)$"
)
class BackupError(RuntimeError):
pass
@dataclass(frozen=True)
class DatabaseBackup:
filename: str
database_type: str
created_at: datetime
size_bytes: int
size_label: str
reason: str | None = None
note: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"filename": self.filename,
"database_type": self.database_type,
"created_at": self.created_at,
"size_bytes": self.size_bytes,
"size_label": self.size_label,
"reason": self.reason,
"note": self.note,
}
def list_database_backups() -> list[dict[str, object]]:
backups = [_backup_info(path).to_dict() for path in _backup_dir().iterdir() if _is_backup_file(path)]
return sorted(backups, key=lambda item: item["created_at"], reverse=True)
def create_database_backup(reason: str | None = "manual") -> dict[str, object]:
kind = _database_kind()
created_at = datetime.now(timezone.utc)
target = _backup_target(kind, reason, created_at)
if kind == "sqlite":
_backup_sqlite(target)
elif kind == "mysql":
_backup_mysql(target)
else:
raise BackupError(f"当前数据库类型暂不支持后台备份:{kind}")
info = _backup_info(target, created_at=created_at, reason=reason, database_type=kind)
_write_metadata(target, info)
return info.to_dict()
def restore_database_backup(filename: str, confirmation: str) -> dict[str, object]:
backup_path = _resolve_backup_path(filename)
expected_confirmation = f"RESTORE {filename}"
if confirmation.strip() != expected_confirmation:
raise BackupError(f"确认文本不正确,请输入:{expected_confirmation}")
restored_backup = _backup_info(backup_path)
kind = _database_kind()
if restored_backup.database_type != kind:
raise BackupError(f"备份类型是 {restored_backup.database_type},当前数据库类型是 {kind},不能混用恢复")
pre_restore_backup = create_database_backup(reason=f"pre_restore_{filename}")
engine.dispose()
if kind == "sqlite":
_restore_sqlite(backup_path)
elif kind == "mysql":
_restore_mysql(backup_path)
else:
raise BackupError(f"当前数据库类型暂不支持后台回滚:{kind}")
engine.dispose()
return {
"restored_backup": restored_backup.to_dict(),
"pre_restore_backup": pre_restore_backup,
}
def _backup_dir() -> Path:
path = data_path("db-backups")
path.mkdir(parents=True, exist_ok=True)
return path
def _backup_target(kind: str, reason: str | None, created_at: datetime) -> Path:
suffix = _reason_suffix(reason)
extension = "sqlite3" if kind == "sqlite" else "sql.gz"
base_name = f"{BACKUP_PREFIX}_{created_at:%Y%m%d_%H%M%S}{suffix}"
target = _backup_dir() / f"{base_name}.{extension}"
counter = 2
while target.exists():
target = _backup_dir() / f"{base_name}_{counter}.{extension}"
counter += 1
return target
def _database_kind() -> str:
driver = make_url(settings.database_url).drivername
if driver.startswith("sqlite"):
return "sqlite"
if driver.startswith("mysql"):
return "mysql"
return driver.split("+", 1)[0]
def _backup_sqlite(target: Path) -> None:
source = _sqlite_database_path()
if not source.exists():
raise BackupError(f"SQLite 数据库文件不存在:{source}")
shutil.copy2(source, target)
def _restore_sqlite(backup_path: Path) -> None:
if backup_path.suffix != ".sqlite3":
raise BackupError("SQLite 只能恢复 .sqlite3 备份")
target = _sqlite_database_path()
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_path, target)
def _sqlite_database_path() -> Path:
url = make_url(settings.database_url)
if not url.database:
raise BackupError("SQLite DATABASE_URL 缺少数据库文件路径")
return Path(url.database).expanduser().resolve()
def _backup_mysql(target: Path) -> None:
cmd, env = _mysql_command("mysqldump")
cmd.extend([
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"--events",
"--default-character-set=utf8mb4",
_mysql_database_name(),
])
try:
with gzip.open(target, "wb") as output:
subprocess.run(
cmd,
stdout=output,
stderr=subprocess.PIPE,
env=env,
check=True,
timeout=BACKUP_TIMEOUT_SECONDS,
)
except FileNotFoundError as exc:
raise BackupError("后端环境缺少 mysqldump 命令,请重新构建 backend 镜像") from exc
except subprocess.CalledProcessError as exc:
target.unlink(missing_ok=True)
raise BackupError(_subprocess_error("数据库备份失败", exc)) from exc
except subprocess.TimeoutExpired as exc:
target.unlink(missing_ok=True)
raise BackupError("数据库备份超时,请稍后重试或检查数据库连接") from exc
def _restore_mysql(backup_path: Path) -> None:
if not backup_path.name.endswith(".sql.gz"):
raise BackupError("MySQL 只能恢复 .sql.gz 备份")
cmd, env = _mysql_command("mysql")
cmd.append(_mysql_database_name())
try:
with gzip.open(backup_path, "rb") as dump:
subprocess.run(
cmd,
stdin=dump,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
check=True,
timeout=BACKUP_TIMEOUT_SECONDS,
)
except FileNotFoundError as exc:
raise BackupError("后端环境缺少 mysql 命令,请重新构建 backend 镜像") from exc
except subprocess.CalledProcessError as exc:
raise BackupError(_subprocess_error("数据库回滚失败", exc)) from exc
except subprocess.TimeoutExpired as exc:
raise BackupError("数据库回滚超时,请检查数据库连接和备份文件大小") from exc
def _mysql_command(binary: str) -> tuple[list[str], dict[str, str]]:
url = make_url(settings.database_url)
command = [
binary,
"-h",
url.host or "127.0.0.1",
"-P",
str(url.port or 3306),
]
if url.username:
command.extend(["-u", url.username])
env = os.environ.copy()
if url.password:
env["MYSQL_PWD"] = url.password
return command, env
def _mysql_database_name() -> str:
database = make_url(settings.database_url).database
if not database:
raise BackupError("MySQL DATABASE_URL 缺少数据库名")
return database
def _resolve_backup_path(filename: str) -> Path:
if not BACKUP_NAME_PATTERN.match(filename):
raise BackupError("备份文件名不合法")
path = (_backup_dir() / filename).resolve()
if path.parent != _backup_dir().resolve() or not path.exists() or not path.is_file():
raise BackupError("备份文件不存在")
return path
def _is_backup_file(path: Path) -> bool:
return path.is_file() and BACKUP_NAME_PATTERN.match(path.name) is not None
def _backup_info(
path: Path,
created_at: datetime | None = None,
reason: str | None = None,
database_type: str | None = None,
) -> DatabaseBackup:
metadata = _read_metadata(path)
stat = path.stat()
meta_created_at = _parse_datetime(metadata.get("created_at")) if metadata else None
return DatabaseBackup(
filename=path.name,
database_type=database_type or str(metadata.get("database_type") or _type_from_extension(path)),
created_at=created_at or meta_created_at or datetime.fromtimestamp(stat.st_mtime, timezone.utc),
size_bytes=stat.st_size,
size_label=_size_label(stat.st_size),
reason=reason if reason is not None else metadata.get("reason"),
note=metadata.get("note"),
)
def _write_metadata(path: Path, info: DatabaseBackup) -> None:
payload = info.to_dict()
payload["created_at"] = info.created_at.isoformat()
_metadata_path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _read_metadata(path: Path) -> dict[str, object]:
metadata_path = _metadata_path(path)
if not metadata_path.exists():
return {}
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def _metadata_path(path: Path) -> Path:
return path.with_name(f"{path.name}.json")
def _type_from_extension(path: Path) -> str:
if path.name.endswith(".sql.gz"):
return "mysql"
if path.suffix == ".sqlite3":
return "sqlite"
return "unknown"
def _parse_datetime(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _size_label(size_bytes: int) -> str:
value = float(size_bytes)
for unit in ["B", "KB", "MB", "GB"]:
if value < 1024 or unit == "GB":
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
value /= 1024
return f"{size_bytes} B"
def _reason_suffix(reason: str | None) -> str:
cleaned = re.sub(r"[^a-z0-9_-]+", "_", (reason or "manual").strip().lower()).strip("_")
if not cleaned or cleaned == "manual":
return ""
return f"_{cleaned[:48]}"
def _subprocess_error(prefix: str, exc: subprocess.CalledProcessError) -> str:
stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
return f"{prefix}{stderr or '命令执行失败'}"

View File

@@ -21,6 +21,8 @@ ACTION_LABELS = {
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
"restore_database_backup": "\u56de\u6eda\u6570\u636e\u5e93\u5907\u4efd",
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
}
@@ -30,10 +32,23 @@ OBJECT_LABELS = {
"learner": "\u5b66\u5458",
"certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21",
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
"public_access": "\u516c\u5f00\u8bbf\u95ee",
}
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
HIGH_RISK_ACTIONS = {
"void_certificate",
"delete_import_batch",
"regenerate_public_token",
"delete_learner",
"restore_database_backup",
}
MEDIUM_RISK_ACTIONS = {
"update_project",
"update_learner",
"confirm_import_batch",
"create_certificate",
"create_database_backup",
}
def log_action(
@@ -97,6 +112,10 @@ def log_summary(action: str, object_type: str, object_id: object, detail: dict[s
if action == "confirm_import_batch":
count = detail.get("imported_rows", detail.get("valid_rows", 0))
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
if action in {"create_database_backup", "restore_database_backup"}:
filename = detail.get("filename") or object_id
size_label = detail.get("size_label")
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "")
if action == "public_search_certificate":
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"

View File

@@ -0,0 +1,53 @@
import json
from datetime import datetime, timezone
import pytest
from app.services import db_backup
def test_list_database_backups_reads_metadata(tmp_path, monkeypatch):
backup = tmp_path / "certificate_system_20260623_120000.sqlite3"
backup.write_bytes(b"sqlite-data")
metadata = {
"filename": backup.name,
"database_type": "sqlite",
"created_at": datetime(2026, 6, 23, 12, 0, tzinfo=timezone.utc).isoformat(),
"reason": "manual",
}
backup.with_name(f"{backup.name}.json").write_text(json.dumps(metadata), encoding="utf-8")
monkeypatch.setattr(db_backup, "_backup_dir", lambda: tmp_path)
items = db_backup.list_database_backups()
assert items[0]["filename"] == backup.name
assert items[0]["database_type"] == "sqlite"
assert items[0]["reason"] == "manual"
assert items[0]["size_label"] == "11 B"
def test_resolve_backup_path_rejects_path_traversal(tmp_path, monkeypatch):
monkeypatch.setattr(db_backup, "_backup_dir", lambda: tmp_path)
with pytest.raises(db_backup.BackupError):
db_backup._resolve_backup_path("../certificate_system_20260623_120000.sqlite3")
def test_backup_target_does_not_overwrite_existing_file(tmp_path, monkeypatch):
created_at = datetime(2026, 6, 23, 12, 0, tzinfo=timezone.utc)
existing = tmp_path / "certificate_system_20260623_120000.sqlite3"
existing.write_bytes(b"old")
monkeypatch.setattr(db_backup, "_backup_dir", lambda: tmp_path)
target = db_backup._backup_target("sqlite", "manual", created_at)
assert target.name == "certificate_system_20260623_120000_2.sqlite3"
def test_restore_requires_exact_confirmation(tmp_path, monkeypatch):
backup = tmp_path / "certificate_system_20260623_120000.sqlite3"
backup.write_bytes(b"sqlite-data")
monkeypatch.setattr(db_backup, "_backup_dir", lambda: tmp_path)
with pytest.raises(db_backup.BackupError, match="确认文本不正确"):
db_backup.restore_database_backup(backup.name, "RESTORE wrong-file")