feat: 数据库迁移前自动备份 (#6360)

This commit is contained in:
InfinityPacer
2026-08-19 18:35:56 +08:00
committed by GitHub
parent f700db5ac8
commit 2b3766e86e
12 changed files with 495 additions and 58 deletions
+1
View File
@@ -90,6 +90,7 @@ _LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_DATABASE_BACKUP_SETTING_KEYS = {
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
"DB_BACKUP_ON_UPGRADE",
"DB_BACKUP_PATH",
"DB_BACKUP_RETENTION_DAYS",
"DB_BACKUP_MAX_COUNT",
+2 -13
View File
@@ -38,7 +38,6 @@ from uvicorn import Config
from app.adapters.system.stdio import configure_rotating_stdio
from app.adapters.system.host import SystemUtils
# 禁用输出
stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE")
if stdio_log_file:
# 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。
@@ -56,9 +55,8 @@ elif SystemUtils.is_frozen():
from app.factory import app
from app.runtime.config import global_vars, settings
from app.startup.database_initializer import init_db, update_db
from app.startup.database_initializer import prepare_database
# 设置进程名
setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -70,7 +68,6 @@ class MoviePilotServer(uvicorn.Server):
super().handle_exit(sig, frame)
# uvicorn服务
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=settings.API_WORKERS,
timeout_graceful_shutdown=60))
@@ -109,7 +106,6 @@ def start_tray():
import pystray
# 托盘图标
TrayIcon = pystray.Icon(
settings.PROJECT_NAME,
icon=Image.open(settings.ROOT_PATH / 'app.ico'),
@@ -124,7 +120,6 @@ def start_tray():
)
)
)
# 启动托盘图标
threading.Thread(target=TrayIcon.run, daemon=True).start()
@@ -138,17 +133,11 @@ def signal_handler(signum, frame):
def run_application() -> None:
"""初始化进程并启动 API 服务"""
# 注册信号处理器
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# 启动托盘
start_tray()
# 初始化数据库
init_db()
# 更新数据库
update_db()
# 启动API服务
prepare_database()
Server.run()
+2
View File
@@ -194,6 +194,8 @@ class ConfigModel(BaseModel):
DB_BACKUP_ENABLE: bool = False
# 定时备份的 Cron 表达式,留空时不注册定时任务
DB_BACKUP_CRON: str = "0 3 * * *"
# 检测到现有数据库需要迁移时,在结构变更前创建恢复点
DB_BACKUP_ON_UPGRADE: bool = True
# 备份根目录;未配置时使用 CONFIG_PATH/database_backup
DB_BACKUP_PATH: Optional[str] = None
# 本地备份的保留天数,0 表示不按时间清理
-2
View File
@@ -348,13 +348,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
},
}
# 创建定时服务
self._scheduler = BackgroundScheduler(
timezone=settings.TZ,
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
)
# 数据库备份复用宿主调度器,不创建独立定时线程。
self._register_database_backup_job()
# CookieCloud定时同步
+114 -18
View File
@@ -1,24 +1,130 @@
from collections.abc import Callable
from configparser import ConfigParser as _ConfigParser
import traceback
from alembic.command import upgrade
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from alembic.util import CommandError
from sqlalchemy import inspect
from sqlalchemy.engine import Engine
from app.runtime.config import settings
from app.db import Base
from app.db.engine import get_engine
from app.db.models import load_all_models
from app.runtime.log import logger
from app.startup.database import build_database_governance
def _build_alembic_config(engine: Engine | None = None) -> Config:
"""构造与应用活动数据库一致的 Alembic 配置。"""
engine = engine or get_engine()
alembic_cfg = Config()
alembic_cfg.file_config = _ConfigParser(interpolation=None)
alembic_cfg.set_main_option(
'script_location',
str(settings.ROOT_PATH / 'database'),
)
alembic_cfg.set_main_option(
'sqlalchemy.url',
engine.url.render_as_string(hide_password=False),
)
return alembic_cfg
def _migration_state(
engine: Engine,
alembic_cfg: Config,
) -> tuple[bool, tuple[str, ...], tuple[str, ...]]:
"""读取数据库迁移状态,并在结构写入前校验版本链。"""
script = ScriptDirectory.from_config(alembic_cfg)
target_heads = tuple(script.get_heads())
with engine.connect() as connection:
table_names = set(inspect(connection).get_table_names())
current_heads = tuple(
MigrationContext.configure(connection).get_current_heads()
)
has_existing_database = bool(table_names - {'alembic_version'})
_validate_migration_lineage(script, current_heads, target_heads)
return has_existing_database, current_heads, target_heads
def _validate_migration_lineage(
script: ScriptDirectory,
current_heads: tuple[str, ...],
target_heads: tuple[str, ...],
) -> None:
"""拒绝无法沿当前迁移链安全升级的数据库版本。"""
if len(target_heads) != 1:
raise RuntimeError(
f"数据库迁移脚本必须只有一个 head,当前为 {target_heads}"
)
if len(current_heads) > 1:
raise RuntimeError(
f"数据库存在多个 current revision,无法自动迁移:{current_heads}"
)
if not current_heads:
return
current = current_heads[0]
target = target_heads[0]
try:
script.get_revision(current)
except CommandError as error:
raise RuntimeError(
f"当前 MoviePilot 无法识别数据库 revision{current}"
) from error
if current == target:
return
ancestors = {
revision.revision
for revision in script.walk_revisions(base='base', head=target)
}
if current not in ancestors:
raise RuntimeError(
f"数据库 revision {current} 不是当前 head {target} 的可升级祖先"
)
def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> None:
"""在建表或迁移前完成版本校验及可选备份。"""
engine = get_engine()
alembic_cfg = _build_alembic_config(engine)
has_existing_database, current_heads, target_heads = _migration_state(
engine,
alembic_cfg,
)
requires_migration = (
has_existing_database
and set(current_heads) != set(target_heads)
)
if (
requires_migration
and settings.DB_BACKUP_ENABLE
and settings.DB_BACKUP_ON_UPGRADE
):
current_version = current_heads[0] if current_heads else "未标记"
target_version = target_heads[0]
logger.info(
f"数据库需要从版本 {current_version} 升级到 {target_version}"
"正在创建迁移前备份"
)
build_database_governance().create_backup()
init_db()
if before_alembic:
# 首次初始化需要先建立用户表,再把管理员密码交给 Alembic 基础迁移消费。
before_alembic()
update_db(alembic_cfg)
def init_db():
"""
初始化数据库
"""
# 函数内导入而非模块级:写成模块级会让 import 本模块的一方也被迫拉起引擎模块。
# 引擎一律用 get_engine() 取——旧名字 `app.db.Engine` 只为仓库外插件保留,且它一经
# 属性访问就把引擎建出来,模块级写法会使本模块反过来依赖「数据库已在别处初始化完成」。
from app.db.engine import get_engine
# 确保所有模型都已注册到 Base.metadata 中
load_all_models()
@@ -26,25 +132,15 @@ def init_db():
Base.metadata.create_all(bind=get_engine())
def update_db():
def update_db(alembic_cfg: Config | None = None):
"""
更新数据库
"""
script_location = settings.ROOT_PATH / 'database'
try:
alembic_cfg = Config()
alembic_cfg.file_config = _ConfigParser(interpolation=None)
alembic_cfg.set_main_option('script_location', str(script_location))
# 与引擎构建使用同一套 URL 推导:两处各自拼接会在配置变更时悄悄漂移,
# 导致迁移连到与应用不同的库上
db_url = settings.DB_SQLITE_URL() if settings.DB_TYPE.lower() != "postgresql" \
else settings.DB_POSTGRESQL_URL()
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
alembic_cfg = alembic_cfg or _build_alembic_config()
upgrade(alembic_cfg, 'head')
except Exception as error:
logger.error(
f'数据库更新失败:{str(error)} - {traceback.format_exc()}'
f"数据库更新失败:{error}\n{traceback.format_exc()}"
)
raise