"""系统维护:数据库备份 / 恢复(仅 super 角色) 实现要点: - 备份:mysqldump 全库导出 → gzip 压缩 → 存到 runtime/backups/ - 恢复:上传 .sql.gz → gunzip → mysql 导入。**高危操作**,调用前需前端二次确认 - 所有文件操作限定在 backups/ 目录内,文件名做白名单校验防路径穿越 - 下载/恢复端点均验证 admin 身份,避免泄露数据库内容 """ import asyncio import gzip import logging import re import shutil from datetime import datetime from pathlib import Path from urllib.parse import urlparse from fastapi import APIRouter, Depends, UploadFile, File from fastapi.responses import FileResponse from app.config import get_settings from app.core import errors from app.core.response import ok, BizException from app.deps import AdminIdentity, require_roles router = APIRouter(prefix="/admin/system", tags=["管理后台-系统"]) logger = logging.getLogger(__name__) require_super = require_roles() # 备份目录(项目 runtime/backups/,与 uploads/videos 平级) BACKUP_DIR = Path("/root/project/video/runtime/backups") BACKUP_DIR.mkdir(parents=True, exist_ok=True) # 备份文件名白名单:video_backup_YYYYMMDD_HHMMSS.sql.gz _FILENAME_RE = re.compile(r"^video_backup_\d{8}_\d{6}\.sql\.gz$") def _parse_db_url() -> dict: """从 settings.database_url 解析 mysqldump 需要的连接参数""" url = get_settings().database_url # mysql+aiomysql://user:pass@host:port/dbname?charset=utf8mb4 p = urlparse(url) return { "host": p.hostname or "127.0.0.1", "port": p.port or 3306, "user": p.username or "root", "password": p.password or "", "database": p.path.lstrip("/").split("?")[0], } def _validate_filename(filename: str) -> Path: """校验文件名合法性,返回完整路径。任何非白名单字符直接拒绝""" if not _FILENAME_RE.match(filename): raise BizException(errors.INVALID_PARAMS, "非法的备份文件名") path = BACKUP_DIR / filename # resolve 后必须仍在 backups 目录内(防符号链接逃逸) if not str(path.resolve()).startswith(str(BACKUP_DIR.resolve())): raise BizException(errors.INVALID_PARAMS, "非法的备份文件路径") return path @router.get("/backups") async def list_backups(admin: AdminIdentity = Depends(require_super)): """列出所有备份文件(按修改时间倒序)""" items = [] for f in BACKUP_DIR.glob("video_backup_*.sql.gz"): if not _FILENAME_RE.match(f.name): continue stat = f.stat() items.append({ "filename": f.name, "size": stat.st_size, "created_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), }) items.sort(key=lambda x: x["filename"], reverse=True) return ok({"items": items, "total": len(items)}) @router.post("/backup") async def create_backup(admin: AdminIdentity = Depends(require_super)): """触发一次备份:mysqldump → gzip。同步等待完成(一般几秒到几十秒)""" db = _parse_db_url() ts = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"video_backup_{ts}.sql.gz" target = BACKUP_DIR / filename # mysqldump 通过 stdin 传密码避免命令行泄露(MYSQL_PWD 环境变量也可,但子进程隔离更稳) cmd = [ "mysqldump", f"--host={db['host']}", f"--port={db['port']}", f"--user={db['user']}", "--single-transaction", # InnoDB 一致性快照,不锁表 "--quick", # 大表逐行读取,避免内存撑爆 "--default-character-set=utf8mb4", "--routines", "--triggers", db["database"], ] try: proc_dump = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"}, ) # 用 gzip 流式压缩写入 with open(target, "wb") as fout: with gzip.GzipFile(fileobj=fout, mode="wb", compresslevel=6) as gz: while True: chunk = await proc_dump.stdout.read(1 << 20) # 1MB if not chunk: break gz.write(chunk) stderr = await proc_dump.stderr.read() await proc_dump.wait() if proc_dump.returncode != 0: target.unlink(missing_ok=True) err_msg = stderr.decode("utf-8", errors="ignore")[:500] logger.error(f"mysqldump failed: {err_msg}") raise BizException(errors.SERVER_ERROR, f"备份失败:{err_msg}") size = target.stat().st_size if size < 100: # 异常小文件视为失败 target.unlink(missing_ok=True) raise BizException(errors.SERVER_ERROR, "备份文件异常(过小)") logger.info(f"DB backup created: {filename} ({size} bytes) by admin#{admin.admin_id}") return ok({"filename": filename, "size": size}) except BizException: raise except Exception as e: target.unlink(missing_ok=True) logger.exception("backup failed") raise BizException(errors.SERVER_ERROR, f"备份异常:{e}") @router.get("/backups/{filename}/download") async def download_backup(filename: str, admin: AdminIdentity = Depends(require_super)): """下载备份文件""" path = _validate_filename(filename) if not path.exists(): raise BizException(errors.NOT_FOUND, "备份文件不存在") return FileResponse( path, media_type="application/gzip", filename=filename, ) @router.delete("/backups/{filename}") async def delete_backup(filename: str, admin: AdminIdentity = Depends(require_super)): """删除指定备份""" path = _validate_filename(filename) if not path.exists(): raise BizException(errors.NOT_FOUND, "备份文件不存在") path.unlink() logger.info(f"DB backup deleted: {filename} by admin#{admin.admin_id}") return ok() @router.post("/restore") async def restore_backup( file: UploadFile = File(...), admin: AdminIdentity = Depends(require_super), ): """上传 .sql.gz 并导入数据库(覆盖现有数据,高危操作)。 流程: 1. 接收上传文件,暂存到 backups/_restore_tmp.sql.gz 2. gunzip 解压 + mysql 导入(事务由 dump 内的 SET 语句保证) 3. 无论成败清理临时文件 """ if not file.filename or not file.filename.endswith(".sql.gz"): raise BizException(errors.INVALID_PARAMS, "仅支持 .sql.gz 格式") db = _parse_db_url() tmp_path = BACKUP_DIR / "_restore_tmp.sql.gz" try: # 限制 500MB size = 0 with open(tmp_path, "wb") as f: while True: chunk = await file.read(1 << 20) if not chunk: break size += len(chunk) if size > 500 * 1024 * 1024: raise BizException(errors.INVALID_PARAMS, "备份文件过大(>500MB)") f.write(chunk) # 解压 + 导入(用 zcat 管道到 mysql,避免中间落地大 SQL 文件) # gunzip -c file.sql.gz | mysql -h.. -u.. dbname zcat_cmd = ["gunzip", "-c", str(tmp_path)] mysql_cmd = [ "mysql", f"--host={db['host']}", f"--port={db['port']}", f"--user={db['user']}", "--default-character-set=utf8mb4", db["database"], ] proc_zcat = await asyncio.create_subprocess_exec( *zcat_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) proc_mysql = await asyncio.create_subprocess_exec( *mysql_cmd, stdin=proc_zcat.stdout, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"}, ) # zcat.stdout 已被 mysql 消费,避免 zcat 因管道堵塞而无法退出 if proc_zcat.stdout is not None: proc_zcat.stdout = None # 让 asyncio 不要等待它 _, zcat_err = await proc_zcat.wait(), b"" mysql_out, mysql_err = await proc_mysql.communicate() if proc_mysql.returncode != 0: err_msg = mysql_err.decode("utf-8", errors="ignore")[:500] logger.error(f"mysql restore failed: {err_msg}") raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}") logger.warning( f"DB restored from upload ({file.filename}, {size} bytes) by admin#{admin.admin_id}" ) return ok({"restored": True, "size": size}) except BizException: raise except Exception as e: logger.exception("restore failed") raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}") finally: tmp_path.unlink(missing_ok=True) @router.post("/backups/{filename}/restore") async def restore_from_existing( filename: str, admin: AdminIdentity = Depends(require_super), ): """从服务器已有备份恢复(等价于先下载再上传恢复,但省去传输)""" path = _validate_filename(filename) if not path.exists(): raise BizException(errors.NOT_FOUND, "备份文件不存在") db = _parse_db_url() try: zcat_cmd = ["gunzip", "-c", str(path)] mysql_cmd = [ "mysql", f"--host={db['host']}", f"--port={db['port']}", f"--user={db['user']}", "--default-character-set=utf8mb4", db["database"], ] proc_zcat = await asyncio.create_subprocess_exec( *zcat_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) proc_mysql = await asyncio.create_subprocess_exec( *mysql_cmd, stdin=proc_zcat.stdout, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"}, ) if proc_zcat.stdout is not None: proc_zcat.stdout = None await proc_zcat.wait() _, mysql_err = await proc_mysql.communicate() if proc_mysql.returncode != 0: err_msg = mysql_err.decode("utf-8", errors="ignore")[:500] logger.error(f"mysql restore failed: {err_msg}") raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}") logger.warning(f"DB restored from {filename} by admin#{admin.admin_id}") return ok({"restored": True, "filename": filename}) except BizException: raise except Exception as e: logger.exception("restore from existing failed") raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}")