mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat: 数据库迁移前自动备份 (#6360)
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 表示不按时间清理
|
||||
|
||||
@@ -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定时同步
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user