1
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""系统维护:数据库备份 / 恢复(仅 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}")
|
||||
Reference in New Issue
Block a user