新增后台数据库备份与回滚
This commit is contained in:
@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import (
|
from app.api.routes import (
|
||||||
|
admin_backups,
|
||||||
admin_certificates,
|
admin_certificates,
|
||||||
admin_dashboard,
|
admin_dashboard,
|
||||||
admin_exports,
|
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_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_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_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"])
|
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||||
|
|||||||
82
backend/app/api/routes/admin_backups.py
Normal file
82
backend/app/api/routes/admin_backups.py
Normal 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
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.db.session import get_db
|
||||||
from app.models import AdminUser
|
from app.models import AdminUser
|
||||||
from app.services.pdf_cleanup import (
|
from app.services.pdf_cleanup import (
|
||||||
@@ -23,29 +23,18 @@ def get_pdf_stats(
|
|||||||
|
|
||||||
@router.post("/cleanup")
|
@router.post("/cleanup")
|
||||||
def trigger_cleanup(
|
def trigger_cleanup(
|
||||||
current_admin: AdminUser = Depends(get_current_admin),
|
_: AdminUser = Depends(require_roles("system_admin")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""手动触发PDF清理"""
|
"""手动触发PDF清理"""
|
||||||
if current_admin.role_code != "admin":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="只有管理员可以执行此操作",
|
|
||||||
)
|
|
||||||
return cleanup_expired_pdfs()
|
return cleanup_expired_pdfs()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup/{certificate_no}")
|
@router.post("/cleanup/{certificate_no}")
|
||||||
def cleanup_single_certificate(
|
def cleanup_single_certificate(
|
||||||
certificate_no: str,
|
certificate_no: str,
|
||||||
current_admin: AdminUser = Depends(get_current_admin),
|
_: AdminUser = Depends(require_roles("system_admin")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""手动清理指定证书的PDF"""
|
"""手动清理指定证书的PDF"""
|
||||||
if current_admin.role_code != "admin":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="只有管理员可以执行此操作",
|
|
||||||
)
|
|
||||||
|
|
||||||
success = manual_cleanup_certificate(certificate_no)
|
success = manual_cleanup_certificate(certificate_no)
|
||||||
if success:
|
if success:
|
||||||
return {"message": f"证书 {certificate_no} 的PDF已清理"}
|
return {"message": f"证书 {certificate_no} 的PDF已清理"}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ DATA_ROOT = Path(settings.data_root)
|
|||||||
|
|
||||||
|
|
||||||
def ensure_data_dirs() -> None:
|
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)
|
(DATA_ROOT / name).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
backend/app/schemas/backup.py
Normal file
26
backend/app/schemas/backup.py
Normal 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
|
||||||
319
backend/app/services/db_backup.py
Normal file
319
backend/app/services/db_backup.py
Normal 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 '命令执行失败'}"
|
||||||
@@ -21,6 +21,8 @@ ACTION_LABELS = {
|
|||||||
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
||||||
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
||||||
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
"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_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
||||||
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
||||||
}
|
}
|
||||||
@@ -30,10 +32,23 @@ OBJECT_LABELS = {
|
|||||||
"learner": "\u5b66\u5458",
|
"learner": "\u5b66\u5458",
|
||||||
"certificate": "\u8bc1\u4e66",
|
"certificate": "\u8bc1\u4e66",
|
||||||
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
||||||
|
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
|
||||||
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
||||||
}
|
}
|
||||||
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
|
HIGH_RISK_ACTIONS = {
|
||||||
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
|
"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(
|
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":
|
if action == "confirm_import_batch":
|
||||||
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
||||||
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
|
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":
|
if action == "public_search_certificate":
|
||||||
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
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"
|
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
||||||
|
|||||||
53
backend/tests/test_db_backup.py
Normal file
53
backend/tests/test_db_backup.py
Normal 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")
|
||||||
@@ -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` 已纳入备份策略。
|
- `uploads`、`error-reports`、`exports` 已纳入备份策略。
|
||||||
- `pdf-cache` 不纳入长期备份。
|
- `pdf-cache` 不纳入长期备份。
|
||||||
|
|||||||
@@ -608,9 +608,27 @@ docker compose down -v
|
|||||||
|
|
||||||
## 11. 备份
|
## 11. 备份
|
||||||
|
|
||||||
### 11.1 Compose 内置 MySQL
|
### 11.1 后台备份管理
|
||||||
|
|
||||||
手动备份:
|
正式后台已经提供可视化备份入口:
|
||||||
|
|
||||||
|
- 登录后台。
|
||||||
|
- 进入「备份管理」。
|
||||||
|
- 点击「立即备份」。
|
||||||
|
- 页面会列出本地已有数据库备份、备份时间、文件大小和来源。
|
||||||
|
- 需要恢复时,点击对应备份的「回滚」,按弹窗输入确认语。
|
||||||
|
|
||||||
|
后台创建的数据库备份保存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/certificate-system/db-backups
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚前系统会自动再创建一份当前数据库备份,来源显示为「回滚前自动备份」。这份备份是为了防止选错时间点后还能退回。
|
||||||
|
|
||||||
|
### 11.2 Compose 内置 MySQL 命令兜底
|
||||||
|
|
||||||
|
一般不用手写命令。只有在后台打不开、需要紧急从服务器层面导出数据库时,再使用下面的兜底方式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /data/backups/mysql
|
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
|
docker compose exec -T mysql mysqldump -ucertificate -p你的业务库密码 certificate_system > /data/backups/mysql/certificate_system_$(date +%F).sql
|
||||||
```
|
```
|
||||||
|
|
||||||
定时备份:
|
如果后续仍希望服务器自动每天备份,可以加定时任务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
crontab -e
|
crontab -e
|
||||||
@@ -631,7 +649,7 @@ crontab -e
|
|||||||
10 3 * * * find /data/backups/mysql -name "certificate_system_*.sql" -mtime +30 -delete
|
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 天备份。
|
- 保留至少 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
|
cat /data/backups/mysql/certificate_system_YYYY-MM-DD.sql | docker compose exec -T mysql mysql -ucertificate -p你的业务库密码 certificate_system
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果后台可以正常打开,优先使用后台「备份管理」里的可视化回滚,不要直接执行恢复命令。
|
||||||
|
|
||||||
## 13. 常见问题排查
|
## 13. 常见问题排查
|
||||||
|
|
||||||
### 13.1 页面打不开
|
### 13.1 页面打不开
|
||||||
|
|||||||
@@ -561,6 +561,19 @@ https://你的域名/admin/login
|
|||||||
|
|
||||||
## 14. 数据备份
|
## 14. 数据备份
|
||||||
|
|
||||||
|
正式后台提供「备份管理」页面,日常优先使用后台:
|
||||||
|
|
||||||
|
- 查看本地已有数据库备份。
|
||||||
|
- 点击「立即备份」生成当前数据库备份。
|
||||||
|
- 选择某个备份点并输入确认语后回滚。
|
||||||
|
- 回滚前系统会自动再备份一次当前数据库。
|
||||||
|
|
||||||
|
后台备份文件保存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/certificate-system/db-backups
|
||||||
|
```
|
||||||
|
|
||||||
### 14.1 如果使用 RDS
|
### 14.1 如果使用 RDS
|
||||||
|
|
||||||
建议:
|
建议:
|
||||||
@@ -572,7 +585,7 @@ https://你的域名/admin/login
|
|||||||
|
|
||||||
### 14.2 如果使用 ECS 自建 MySQL
|
### 14.2 如果使用 ECS 自建 MySQL
|
||||||
|
|
||||||
至少每天备份一次数据库。
|
至少每天备份一次数据库。后台「备份管理」可以完成手动备份和回滚;下面命令只作为后台不可用时的服务器兜底方案。
|
||||||
|
|
||||||
创建备份目录:
|
创建备份目录:
|
||||||
|
|
||||||
|
|||||||
@@ -104,3 +104,18 @@ export interface OperationLog {
|
|||||||
summary: string;
|
summary: string;
|
||||||
created_at: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
import AdminBackups from "../views/AdminBackups.vue";
|
||||||
import AdminCertificates from "../views/AdminCertificates.vue";
|
import AdminCertificates from "../views/AdminCertificates.vue";
|
||||||
import AdminHome from "../views/AdminHome.vue";
|
import AdminHome from "../views/AdminHome.vue";
|
||||||
import AdminImports from "../views/AdminImports.vue";
|
import AdminImports from "../views/AdminImports.vue";
|
||||||
@@ -26,7 +27,8 @@ export const router = createRouter({
|
|||||||
{ path: "learners", component: AdminLearners },
|
{ path: "learners", component: AdminLearners },
|
||||||
{ path: "certificates", component: AdminCertificates },
|
{ path: "certificates", component: AdminCertificates },
|
||||||
{ path: "imports", component: AdminImports },
|
{ path: "imports", component: AdminImports },
|
||||||
{ path: "logs", component: AdminLogs }
|
{ path: "logs", component: AdminLogs },
|
||||||
|
{ path: "backups", component: AdminBackups }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ path: "/query", component: CertificateQuery },
|
{ path: "/query", component: CertificateQuery },
|
||||||
|
|||||||
182
frontend/src/views/AdminBackups.vue
Normal file
182
frontend/src/views/AdminBackups.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>备份管理</h2>
|
||||||
|
<p>查看本地数据库备份,必要时手动备份或回滚到指定时间点。</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<el-button @click="loadBackups">刷新</el-button>
|
||||||
|
<el-button type="primary" :loading="creating" @click="createBackup">立即备份</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
class="risk-alert"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
title="回滚会覆盖当前数据库。系统会先自动备份当前库,再执行恢复;建议在无人操作后台时使用。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-card class="panel" shadow="never">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h3>本地备份点</h3>
|
||||||
|
<p>备份文件保存在服务器的系统数据目录中,只能由系统管理员操作。</p>
|
||||||
|
</div>
|
||||||
|
<el-tag type="info">{{ backups.length }} 个备份</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="backups" border empty-text="还没有数据库备份">
|
||||||
|
<el-table-column label="备份时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="filename" label="文件名" min-width="300" show-overflow-tooltip />
|
||||||
|
<el-table-column label="数据库" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag>{{ row.database_type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="size_label" label="大小" width="110" />
|
||||||
|
<el-table-column label="来源" min-width="170">
|
||||||
|
<template #default="{ row }">{{ reasonLabel(row.reason) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" plain size="small" :loading="restoring === row.filename" @click="confirmRestore(row)">
|
||||||
|
回滚
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import { http, type DatabaseBackup, type RestoreDatabaseBackupResult } from "../api";
|
||||||
|
|
||||||
|
const backups = ref<DatabaseBackup[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const creating = ref(false);
|
||||||
|
const restoring = ref("");
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reason: string | null) {
|
||||||
|
if (!reason || reason === "manual") return "手动备份";
|
||||||
|
if (reason.startsWith("pre_restore_")) return "回滚前自动备份";
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackups() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get<DatabaseBackup[]>("/admin/backups");
|
||||||
|
backups.value = data;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackup() {
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
await http.post<DatabaseBackup>("/admin/backups", { reason: "manual" });
|
||||||
|
ElMessage.success("数据库备份已创建");
|
||||||
|
await loadBackups();
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestore(row: DatabaseBackup) {
|
||||||
|
const confirmation = `RESTORE ${row.filename}`;
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`将数据库回滚到 ${formatTime(row.created_at)}。请输入确认语:${confirmation}`,
|
||||||
|
"确认回滚数据库",
|
||||||
|
{
|
||||||
|
confirmButtonText: "确认回滚",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
inputPlaceholder: confirmation,
|
||||||
|
inputValidator: (input) => input.trim() === confirmation || "确认语不正确",
|
||||||
|
type: "warning",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
restoring.value = row.filename;
|
||||||
|
const { data } = await http.post<RestoreDatabaseBackupResult>(`/admin/backups/${encodeURIComponent(row.filename)}/restore`, {
|
||||||
|
confirmation: String(value),
|
||||||
|
});
|
||||||
|
ElMessage.success(`已回滚,回滚前备份:${data.pre_restore_backup.filename}`);
|
||||||
|
await loadBackups();
|
||||||
|
} finally {
|
||||||
|
restoring.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadBackups);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p,
|
||||||
|
.panel-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.page-head,
|
||||||
|
.panel-head {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<el-option label="学员" value="learner" />
|
<el-option label="学员" value="learner" />
|
||||||
<el-option label="证书" value="certificate" />
|
<el-option label="证书" value="certificate" />
|
||||||
<el-option label="导入批次" value="import_batch" />
|
<el-option label="导入批次" value="import_batch" />
|
||||||
|
<el-option label="数据库备份" value="database_backup" />
|
||||||
<el-option label="公开访问" value="public_access" />
|
<el-option label="公开访问" value="public_access" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||||
@@ -119,6 +120,8 @@ const actionOptions = [
|
|||||||
{ value: "confirm_import_batch", label: "确认导入" },
|
{ value: "confirm_import_batch", label: "确认导入" },
|
||||||
{ value: "delete_import_batch", label: "删除导入批次" },
|
{ value: "delete_import_batch", label: "删除导入批次" },
|
||||||
{ value: "export_certificates", label: "导出证书" },
|
{ value: "export_certificates", label: "导出证书" },
|
||||||
|
{ value: "create_database_backup", label: "创建数据库备份" },
|
||||||
|
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
||||||
{ value: "public_search_certificate", label: "公开查询证书" },
|
{ value: "public_search_certificate", label: "公开查询证书" },
|
||||||
{ value: "public_download_certificate", label: "公开下载证书" },
|
{ value: "public_download_certificate", label: "公开下载证书" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
||||||
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
||||||
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user