diff --git a/backend/Dockerfile b/backend/Dockerfile index 433f3e7..d8d9fb2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ad072f2..5f4f7f0 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/routes/admin_backups.py b/backend/app/api/routes/admin_backups.py new file mode 100644 index 0000000..bcc96f0 --- /dev/null +++ b/backend/app/api/routes/admin_backups.py @@ -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 diff --git a/backend/app/api/routes/admin_pdf_cleanup.py b/backend/app/api/routes/admin_pdf_cleanup.py index b943035..72503df 100644 --- a/backend/app/api/routes/admin_pdf_cleanup.py +++ b/backend/app/api/routes/admin_pdf_cleanup.py @@ -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已清理"} diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py index 0ef372d..17d2c82 100644 --- a/backend/app/core/paths.py +++ b/backend/app/core/paths.py @@ -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) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py new file mode 100644 index 0000000..8e64cff --- /dev/null +++ b/backend/app/schemas/backup.py @@ -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 diff --git a/backend/app/services/db_backup.py b/backend/app/services/db_backup.py new file mode 100644 index 0000000..229321a --- /dev/null +++ b/backend/app/services/db_backup.py @@ -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 '命令执行失败'}" diff --git a/backend/app/services/logs.py b/backend/app/services/logs.py index 07a3a4d..a69f287 100644 --- a/backend/app/services/logs.py +++ b/backend/app/services/logs.py @@ -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" diff --git a/backend/tests/test_db_backup.py b/backend/tests/test_db_backup.py new file mode 100644 index 0000000..f160f7f --- /dev/null +++ b/backend/tests/test_db_backup.py @@ -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") diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md index 9240fb0..abab440 100644 --- a/docs/deployment-checklist.md +++ b/docs/deployment-checklist.md @@ -56,6 +56,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a ## 备份 -- MySQL 每日备份已配置。 +- 后台「备份管理」可以查看本地备份并手动创建备份。 +- 已完成一次手动备份和一次只读检查,确认 `/data/certificate-system/db-backups` 有备份文件。 +- 如果需要每日自动备份,服务器定时策略已配置。 - `uploads`、`error-reports`、`exports` 已纳入备份策略。 - `pdf-cache` 不纳入长期备份。 diff --git a/docs/正式部署操作手册.md b/docs/正式部署操作手册.md index 6a9d1ac..06a2f37 100644 --- a/docs/正式部署操作手册.md +++ b/docs/正式部署操作手册.md @@ -608,9 +608,27 @@ docker compose down -v ## 11. 备份 -### 11.1 Compose 内置 MySQL +### 11.1 后台备份管理 -手动备份: +正式后台已经提供可视化备份入口: + +- 登录后台。 +- 进入「备份管理」。 +- 点击「立即备份」。 +- 页面会列出本地已有数据库备份、备份时间、文件大小和来源。 +- 需要恢复时,点击对应备份的「回滚」,按弹窗输入确认语。 + +后台创建的数据库备份保存在: + +```text +/data/certificate-system/db-backups +``` + +回滚前系统会自动再创建一份当前数据库备份,来源显示为「回滚前自动备份」。这份备份是为了防止选错时间点后还能退回。 + +### 11.2 Compose 内置 MySQL 命令兜底 + +一般不用手写命令。只有在后台打不开、需要紧急从服务器层面导出数据库时,再使用下面的兜底方式: ```bash mkdir -p /data/backups/mysql @@ -618,7 +636,7 @@ cd /opt/certificate-system docker compose exec -T mysql mysqldump -ucertificate -p你的业务库密码 certificate_system > /data/backups/mysql/certificate_system_$(date +%F).sql ``` -定时备份: +如果后续仍希望服务器自动每天备份,可以加定时任务: ```bash crontab -e @@ -631,7 +649,7 @@ crontab -e 10 3 * * * find /data/backups/mysql -name "certificate_system_*.sql" -mtime +30 -delete ``` -### 11.2 RDS MySQL +### 11.3 RDS MySQL 建议: @@ -640,7 +658,7 @@ crontab -e - 大版本升级前手动创建一次快照。 - 保留至少 7 到 30 天备份。 -### 11.3 文件数据 +### 11.4 文件数据 需要备份: @@ -687,6 +705,8 @@ docker compose up -d --build cat /data/backups/mysql/certificate_system_YYYY-MM-DD.sql | docker compose exec -T mysql mysql -ucertificate -p你的业务库密码 certificate_system ``` +如果后台可以正常打开,优先使用后台「备份管理」里的可视化回滚,不要直接执行恢复命令。 + ## 13. 常见问题排查 ### 13.1 页面打不开 diff --git a/docs/阿里云正式部署详细操作手册.md b/docs/阿里云正式部署详细操作手册.md index 42cda76..6a313f1 100644 --- a/docs/阿里云正式部署详细操作手册.md +++ b/docs/阿里云正式部署详细操作手册.md @@ -561,6 +561,19 @@ https://你的域名/admin/login ## 14. 数据备份 +正式后台提供「备份管理」页面,日常优先使用后台: + +- 查看本地已有数据库备份。 +- 点击「立即备份」生成当前数据库备份。 +- 选择某个备份点并输入确认语后回滚。 +- 回滚前系统会自动再备份一次当前数据库。 + +后台备份文件保存在: + +```text +/data/certificate-system/db-backups +``` + ### 14.1 如果使用 RDS 建议: @@ -572,7 +585,7 @@ https://你的域名/admin/login ### 14.2 如果使用 ECS 自建 MySQL -至少每天备份一次数据库。 +至少每天备份一次数据库。后台「备份管理」可以完成手动备份和回滚;下面命令只作为后台不可用时的服务器兜底方案。 创建备份目录: diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6a83e14..cca767d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -104,3 +104,18 @@ export interface OperationLog { summary: string; created_at: string; } + +export interface DatabaseBackup { + filename: string; + database_type: string; + created_at: string; + size_bytes: number; + size_label: string; + reason: string | null; + note: string | null; +} + +export interface RestoreDatabaseBackupResult { + restored_backup: DatabaseBackup; + pre_restore_backup: DatabaseBackup; +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 970991b..82397f3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from "vue-router"; +import AdminBackups from "../views/AdminBackups.vue"; import AdminCertificates from "../views/AdminCertificates.vue"; import AdminHome from "../views/AdminHome.vue"; import AdminImports from "../views/AdminImports.vue"; @@ -26,7 +27,8 @@ export const router = createRouter({ { path: "learners", component: AdminLearners }, { path: "certificates", component: AdminCertificates }, { path: "imports", component: AdminImports }, - { path: "logs", component: AdminLogs } + { path: "logs", component: AdminLogs }, + { path: "backups", component: AdminBackups } ] }, { path: "/query", component: CertificateQuery }, diff --git a/frontend/src/views/AdminBackups.vue b/frontend/src/views/AdminBackups.vue new file mode 100644 index 0000000..480d235 --- /dev/null +++ b/frontend/src/views/AdminBackups.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/views/AdminLogs.vue b/frontend/src/views/AdminLogs.vue index ab8ef48..efc0ca0 100644 --- a/frontend/src/views/AdminLogs.vue +++ b/frontend/src/views/AdminLogs.vue @@ -24,6 +24,7 @@ + @@ -119,6 +120,8 @@ const actionOptions = [ { value: "confirm_import_batch", label: "确认导入" }, { value: "delete_import_batch", label: "删除导入批次" }, { value: "export_certificates", label: "导出证书" }, + { value: "create_database_backup", label: "创建数据库备份" }, + { value: "restore_database_backup", label: "回滚数据库备份" }, { value: "public_search_certificate", label: "公开查询证书" }, { value: "public_download_certificate", label: "公开下载证书" }, ]; diff --git a/frontend/src/views/AdminShell.vue b/frontend/src/views/AdminShell.vue index b50a561..afccd92 100644 --- a/frontend/src/views/AdminShell.vue +++ b/frontend/src/views/AdminShell.vue @@ -12,6 +12,7 @@ 证书管理 Excel 导入 操作日志 + 备份管理 退出登录