fix(database): keep migrations in alembic transactions (#6400)

This commit is contained in:
InfinityPacer
2026-08-23 00:27:55 +08:00
committed by GitHub
parent a59f1b928a
commit 176d9255e5
16 changed files with 659 additions and 154 deletions
-14
View File
@@ -14,20 +14,8 @@ from app.runtime.config import settings
from app.db.base import Base from app.db.base import Base
from app.db.engine import get_engine from app.db.engine import get_engine
from app.db.models import load_all_models from app.db.models import load_all_models
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import configure_transaction_runners
from app.runtime.log import logger from app.runtime.log import logger
from app.startup.database import build_database_governance from app.startup.database import build_database_governance
from app.startup.transaction import TransactionalWriteRunner
def _configure_migration_transaction_runner() -> None:
"""在 Alembic 调用旧无会话 Oper 前装配可独立提交的兼容事务。"""
runner = TransactionalWriteRunner(
sync_session=SessionFactory,
async_session=async_session_scope,
)
configure_transaction_runners(sync=runner.sync, async_=runner.async_)
def _build_alembic_config(engine: Engine | None = None) -> Config: def _build_alembic_config(engine: Engine | None = None) -> Config:
@@ -161,8 +149,6 @@ def update_db(alembic_cfg: Config | None = None):
更新数据库 更新数据库
""" """
try: try:
# 早期迁移脚本会调用 SystemConfigOper(),此时 modules_initializer 尚未执行。
_configure_migration_transaction_runner()
alembic_cfg = alembic_cfg or _build_alembic_config() alembic_cfg = alembic_cfg or _build_alembic_config()
upgrade(alembic_cfg, 'head') upgrade(alembic_cfg, 'head')
except Exception as error: except Exception as error:
+23 -6
View File
@@ -6,8 +6,8 @@ Create Date: 2024-09-11 08:07:02.753307
""" """
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
from app.schemas.types import SystemConfigKey import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '262735d025da' revision = '262735d025da'
@@ -19,9 +19,18 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# 初始化消息通知范围 # 初始化消息通知范围
_systemconfig = SystemConfigOper() systemconfig = sa.table(
if not _systemconfig.get(SystemConfigKey.NotificationSwitchs): "systemconfig",
_systemconfig.set(SystemConfigKey.NotificationSwitchs, [ sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "NotificationSwitchs"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
if not row or not row[0]:
value = [
{ {
'type': '资源下载', 'type': '资源下载',
'action': 'all', 'action': 'all',
@@ -54,7 +63,15 @@ def upgrade() -> None:
'type': '其它', 'type': '其它',
'action': 'admin', 'action': 'admin',
}, },
]) ]
if row:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=value
)
)
else:
connection.execute(systemconfig.insert().values(key=key, value=value))
# ### end Alembic commands ### # ### end Alembic commands ###
+79 -43
View File
@@ -8,13 +8,11 @@ Create Date: 2024-07-20 08:43:40.741251
import secrets import secrets
from alembic import op
import sqlalchemy as sa
from app.runtime.config import settings from app.runtime.config import settings
from app.application.security.token import get_password_hash from app.application.security.token import get_password_hash
from app.db import SessionFactory
from app.db.models import *
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '294b007932ef' revision = '294b007932ef'
@@ -27,50 +25,88 @@ def upgrade() -> None:
""" """
v2.0.0 数据库初始化 v2.0.0 数据库初始化
""" """
with SessionFactory() as db: connection = op.get_bind()
# 初始化超级管理员 user = sa.table(
_user = User.get_by_name(db=db, name=settings.SUPERUSER) "user",
if not _user: sa.column("name", sa.String()),
if settings.SUPERUSER_PASSWORD: sa.column("email", sa.String()),
init_password = settings.SUPERUSER_PASSWORD sa.column("hashed_password", sa.String()),
else: sa.column("is_active", sa.Boolean()),
# 生成随机密码 sa.column("is_superuser", sa.Boolean()),
init_password = secrets.token_urlsafe(16) sa.column("avatar", sa.String()),
logger.info( sa.column("is_otp", sa.Boolean()),
f"【超级管理员初始密码】{init_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。") sa.column("otp_secret", sa.String()),
_user = User( sa.column("permissions", sa.JSON()),
sa.column("settings", sa.JSON()),
)
# 初始化超级管理员
existing_user = connection.execute(
sa.select(user.c.name).where(user.c.name == settings.SUPERUSER)
).first()
if not existing_user:
if settings.SUPERUSER_PASSWORD:
init_password = settings.SUPERUSER_PASSWORD
else:
# 生成随机密码
init_password = secrets.token_urlsafe(16)
logger.info(
f"【超级管理员初始密码】{init_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
connection.execute(
user.insert().values(
name=settings.SUPERUSER, name=settings.SUPERUSER,
hashed_password=get_password_hash(init_password), hashed_password=get_password_hash(init_password),
email="admin@movie-pilot.org", email="admin@movie-pilot.org",
is_active=True,
is_superuser=True, is_superuser=True,
avatar="" avatar="",
is_otp=False,
otp_secret=None,
permissions={},
settings={},
) )
_user.create(db) )
# 初始化本地存储
_systemconfig = SystemConfigOper() # 初始化本地存储
if not _systemconfig.get(SystemConfigKey.Storages): systemconfig = sa.table(
_systemconfig.set(SystemConfigKey.Storages, [ "systemconfig",
{ sa.column("key", sa.String()),
"type": "local", sa.column("value", sa.JSON()),
"name": "本地", )
"config": {} key = "Storages"
}, row = connection.execute(
{ sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
"type": "alipan", ).first()
"name": "阿里云盘", if not row or not row[0]:
"config": {} value = [
}, {
{ "type": "local",
"type": "u115", "name": "本地",
"name": "115网盘", "config": {}
"config": {} },
}, {
{ "type": "alipan",
"type": "rclone", "name": "阿里云盘",
"name": "RClone", "config": {}
"config": {} },
} {
]) "type": "u115",
"name": "115网盘",
"config": {}
},
{
"type": "rclone",
"name": "RClone",
"config": {}
}
]
if row:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=value
)
)
else:
connection.execute(systemconfig.insert().values(key=key, value=value))
def downgrade() -> None: def downgrade() -> None:
+22 -6
View File
@@ -7,10 +7,9 @@ Create Date: 2025-06-28 08:40:14.516836
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import sqlite from sqlalchemy.dialects import sqlite
from app.db.oper.systemconfig import SystemConfigOper
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '3891a5e722a1' revision = '3891a5e722a1'
@@ -22,14 +21,31 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# rename AList存储 # rename AList存储
_systemconfig = SystemConfigOper() systemconfig = sa.table(
_storages = _systemconfig.get(SystemConfigKey.Storages) "systemconfig",
sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "Storages"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
_storages = row[0] if row else None
if _storages: if _storages:
changed = False
for storage in _storages: for storage in _storages:
if storage["type"] == "alist": if storage["type"] == "alist":
storage["name"] = "OpenList" if storage.get("name") != "OpenList":
storage["name"] = "OpenList"
changed = True
break break
_systemconfig.set(SystemConfigKey.Storages, _storages) if changed:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=_storages
)
)
# ### end Alembic commands ### # ### end Alembic commands ###
+26 -22
View File
@@ -5,10 +5,8 @@ Revises: 486e56a62dcb
Create Date: 2025-06-11 19:52:57.185355 Create Date: 2025-06-11 19:52:57.185355
""" """
import json from alembic import op
import sqlalchemy as sa
from app.db import SessionFactory
from app.db.models import User
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '3df653756eec' revision = '3df653756eec'
@@ -18,24 +16,30 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### connection = op.get_bind()
with SessionFactory() as db: user = sa.table(
# 所有用户 "user",
users = User.list(db) sa.column("id", sa.Integer()),
for user in users: sa.column("is_superuser", sa.Boolean()),
if user.is_superuser: sa.column("permissions", sa.JSON()),
continue )
if not user.permissions: users = connection.execute(
permissions = { sa.select(user.c.id, user.c.is_superuser, user.c.permissions)
"discovery": True, ).mappings().all()
"search": True, permissions = {
"subscribe": True, "discovery": True,
"manage": False, "search": True,
} "subscribe": True,
user.update(db, { "manage": False,
"permissions": permissions, }
}) for item in users:
# ### end Alembic commands ### if item["is_superuser"] or item["permissions"]:
continue
connection.execute(
user.update()
.where(user.c.id == item["id"])
.values(permissions=permissions)
)
def downgrade() -> None: def downgrade() -> None:
+22 -5
View File
@@ -7,8 +7,8 @@ Create Date: 2025-05-13 19:49:51.271319
""" """
import re import re
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
from app.schemas.types import SystemConfigKey import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '486e56a62dcb' revision = '486e56a62dcb'
@@ -20,16 +20,33 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ### ### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ###
_systemconfig = SystemConfigOper() systemconfig = sa.table(
templates = _systemconfig.get(SystemConfigKey.NotificationTemplates) "systemconfig",
sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "NotificationTemplates"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
templates = row[0] if row else None
if isinstance(templates, dict): if isinstance(templates, dict):
_re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)' _re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)'
changed = False
for k, v in templates.items(): for k, v in templates.items():
# 替换season为season_fmt # 替换season为season_fmt
result = re.sub(_re, r'\1season_fmt\2', v) result = re.sub(_re, r'\1season_fmt\2', v)
if result != v:
changed = True
templates[k] = result templates[k] = result
# 将更新后的模板存回系统配置 # 将更新后的模板存回系统配置
_systemconfig.set(SystemConfigKey.NotificationTemplates, templates) if changed:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=templates
)
)
# ### end Alembic commands ### # ### end Alembic commands ###
+22 -5
View File
@@ -6,9 +6,9 @@ Revises: e8b1c4d7a2f9
Create Date: 2026-08-10 Create Date: 2026-08-10
""" """
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
import sqlalchemy as sa
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "4dadad1d161a" revision = "4dadad1d161a"
@@ -21,7 +21,17 @@ def upgrade() -> None:
# V3 为大版本升级,通知模板直接覆盖用户旧设置,且迁移只执行一次; # V3 为大版本升级,通知模板直接覆盖用户旧设置,且迁移只执行一次;
# 默认模板同时兼容影视与音乐(音乐的下载、入库通知补齐艺术家/专辑/音质信息)。 # 默认模板同时兼容影视与音乐(音乐的下载、入库通知补齐艺术家/专辑/音质信息)。
# 覆盖前先将用户现有模板完整输出到日志,作为备份供用户恢复参考。 # 覆盖前先将用户现有模板完整输出到日志,作为备份供用户恢复参考。
old_value = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) systemconfig = sa.table(
"systemconfig",
sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "NotificationTemplates"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
old_value = row[0] if row else None
if old_value: if old_value:
logger.info(f"即将使用 V3 默认通知模板覆盖用户现有通知模板,现有模板内容备份如下:\n{old_value}") logger.info(f"即将使用 V3 默认通知模板覆盖用户现有通知模板,现有模板内容备份如下:\n{old_value}")
value = { value = {
@@ -67,8 +77,15 @@ def upgrade() -> None:
'{% if overview %}\\n简介:{{ overview }}{% endif %}' '{% if overview %}\\n简介:{{ overview }}{% endif %}'
}""" }"""
} }
SystemConfigOper().set(SystemConfigKey.NotificationTemplates, value) if row and row[0] != value:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=value
)
)
elif not row:
connection.execute(systemconfig.insert().values(key=key, value=value))
def downgrade() -> None: def downgrade() -> None:
pass pass
+21 -5
View File
@@ -6,8 +6,8 @@ Create Date: 2025-05-03 17:29:07.635618
""" """
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
from app.schemas.types import SystemConfigKey import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '89d24811e894' revision = '89d24811e894'
@@ -57,9 +57,25 @@ def upgrade() -> None:
'{% if overview %}\\n简介:{{ overview }}{% endif %}' '{% if overview %}\\n简介:{{ overview }}{% endif %}'
}""" }"""
} }
_systemconfig = SystemConfigOper() systemconfig = sa.table(
if not _systemconfig.get(SystemConfigKey.NotificationTemplates): "systemconfig",
_systemconfig.set(SystemConfigKey.NotificationTemplates, value) sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "NotificationTemplates"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
if not row or not row[0]:
if row:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=value
)
)
else:
connection.execute(systemconfig.insert().values(key=key, value=value))
# ### end Alembic commands ### # ### end Alembic commands ###
+20 -8
View File
@@ -6,8 +6,8 @@ Create Date: 2024-11-14 12:49:13.838120
""" """
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
from app.schemas.types import SystemConfigKey import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'a295e41830a6' revision = 'a295e41830a6'
@@ -19,16 +19,28 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# 初始化AList存储 # 初始化AList存储
_systemconfig = SystemConfigOper() systemconfig = sa.table(
_storages = _systemconfig.get(SystemConfigKey.Storages) "systemconfig",
if _storages: sa.column("key", sa.String()),
if "alist" not in [storage["type"] for storage in _storages]: sa.column("value", sa.JSON()),
_storages.append({ )
connection = op.get_bind()
key = "Storages"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
_storages = row[0] if row else None
if _storages and "alist" not in [storage["type"] for storage in _storages]:
_storages.append({
"type": "alist", "type": "alist",
"name": "AList", "name": "AList",
"config": {} "config": {}
}) })
_systemconfig.set(SystemConfigKey.Storages, _storages) connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=_storages
)
)
# ### end Alembic commands ### # ### end Alembic commands ###
+21 -3
View File
@@ -6,8 +6,8 @@ Create Date: 2024-10-16 15:05:01.775429
""" """
from app.db.oper.systemconfig import SystemConfigOper from alembic import op
from app.schemas.types import SystemConfigKey import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'a73f2dbf5c09' revision = 'a73f2dbf5c09'
@@ -19,7 +19,25 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# 初始化下载优先规则 # 初始化下载优先规则
SystemConfigOper().set(SystemConfigKey.TorrentsPriority, ["torrent", "upload", "seeder"]) systemconfig = sa.table(
"systemconfig",
sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
key = "TorrentsPriority"
value = ["torrent", "upload", "seeder"]
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == key)
).first()
if row and row[0] != value:
connection.execute(
systemconfig.update().where(systemconfig.c.key == key).values(
value=value
)
)
elif not row:
connection.execute(systemconfig.insert().values(key=key, value=value))
# ### end Alembic commands ### # ### end Alembic commands ###
+1 -6
View File
@@ -5,14 +5,10 @@ Revises: 0fb94bf69b38
Create Date: 2024-10-09 13:44:13.926529 Create Date: 2024-10-09 13:44:13.926529
""" """
import contextlib
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from app.runtime.log import logger from app.runtime.log import logger
from app.db import SessionFactory
from app.db.models import UserConfig
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'e2dbe1421fa4' revision = 'e2dbe1421fa4'
@@ -72,8 +68,7 @@ def upgrade() -> None:
except Exception as e: except Exception as e:
logger.error(f"Could not alter column {column_name} in table {table}: {e}") logger.error(f"Could not alter column {column_name} in table {table}: {e}")
with SessionFactory() as db: conn.execute(sa.delete(sa.table("userconfig")))
UserConfig.truncate(db)
def downgrade() -> None: def downgrade() -> None:
+19 -9
View File
@@ -63,9 +63,6 @@ def upgrade() -> None:
]) ])
# 只升级系统旧默认模板;用户编辑过的模板保持原样。 # 只升级系统旧默认模板;用户编辑过的模板保持原样。
from app.db.oper.systemconfig import SystemConfigOper
from app.schemas.types import SystemConfigKey
legacy_organize = """ legacy_organize = """
{ {
'title': '{{ title_year }}' 'title': '{{ title_year }}'
@@ -123,18 +120,31 @@ def upgrade() -> None:
'{% if labels %}\\n标签:{{ labels }}{% endif %}' '{% if labels %}\\n标签:{{ labels }}{% endif %}'
'{% if description %}\\n描述:{{ description }}{% endif %}' '{% if description %}\\n描述:{{ description }}{% endif %}'
}""" }"""
config_oper = SystemConfigOper() systemconfig = sa.table(
templates = dict(config_oper.get(SystemConfigKey.NotificationTemplates) or {}) "systemconfig",
sa.column("key", sa.String()),
sa.column("value", sa.JSON()),
)
connection = op.get_bind()
config_key = "NotificationTemplates"
row = connection.execute(
sa.select(systemconfig.c.value).where(systemconfig.c.key == config_key)
).first()
templates = dict(row[0] or {}) if row else {}
changed = False changed = False
for key, legacy, replacement in ( for template_key, legacy, replacement in (
("organizeSuccess", legacy_organize, music_organize), ("organizeSuccess", legacy_organize, music_organize),
("downloadAdded", legacy_download, music_download), ("downloadAdded", legacy_download, music_download),
): ):
if str(templates.get(key) or "").strip() == legacy.strip(): if str(templates.get(template_key) or "").strip() == legacy.strip():
templates[key] = replacement templates[template_key] = replacement
changed = True changed = True
if changed: if changed:
config_oper.set(SystemConfigKey.NotificationTemplates, templates) connection.execute(
systemconfig.update().where(systemconfig.c.key == config_key).values(
value=templates
)
)
def downgrade() -> None: def downgrade() -> None:
+2 -5
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6404, "edge_count": 6401,
"edge_sha256": "850e490b866ed8eeee03515e39a3821c3078f934c622a60d9db5e279bab1bfbb", "edge_sha256": "cfc86c48464cace2831f69ccaf8bb9ad1c40a1a4626cd11b8d2bf10d5508b0b8",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -5983,14 +5983,11 @@
"app.startup.database_initializer -> app.db.base", "app.startup.database_initializer -> app.db.base",
"app.startup.database_initializer -> app.db.engine", "app.startup.database_initializer -> app.db.engine",
"app.startup.database_initializer -> app.db.models", "app.startup.database_initializer -> app.db.models",
"app.startup.database_initializer -> app.db.session",
"app.startup.database_initializer -> app.db.uow",
"app.startup.database_initializer -> app.runtime", "app.startup.database_initializer -> app.runtime",
"app.startup.database_initializer -> app.runtime.config", "app.startup.database_initializer -> app.runtime.config",
"app.startup.database_initializer -> app.runtime.log", "app.startup.database_initializer -> app.runtime.log",
"app.startup.database_initializer -> app.startup", "app.startup.database_initializer -> app.startup",
"app.startup.database_initializer -> app.startup.database", "app.startup.database_initializer -> app.startup.database",
"app.startup.database_initializer -> app.startup.transaction",
"app.startup.domain_initializer -> app.adapters", "app.startup.domain_initializer -> app.adapters",
"app.startup.domain_initializer -> app.adapters.system", "app.startup.domain_initializer -> app.adapters.system",
"app.startup.domain_initializer -> app.adapters.system.rust", "app.startup.domain_initializer -> app.adapters.system.rust",
+332 -2
View File
@@ -1,3 +1,4 @@
import importlib
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import sys import sys
@@ -5,10 +6,25 @@ from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
import uuid import uuid
from alembic.migration import MigrationContext
from alembic.operations import Operations
import pytest import pytest
from alembic.util import CommandError from alembic.util import CommandError
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text import sqlalchemy as sa
from sqlalchemy import (
Boolean,
Column,
Integer,
JSON,
MetaData,
String,
Table,
create_engine,
event,
inspect,
text,
)
from sqlalchemy.engine.url import make_url from sqlalchemy.engine.url import make_url
from app.startup import database_initializer as db_init from app.startup import database_initializer as db_init
@@ -82,7 +98,12 @@ def test_prepare_database_creates_backup_before_schema_changes(monkeypatch) -> N
db_init.prepare_database(before_alembic=lambda: calls.append("before_alembic")) db_init.prepare_database(before_alembic=lambda: calls.append("before_alembic"))
assert calls == ["backup", "create_all", "before_alembic", "alembic"] assert calls == [
"backup",
"create_all",
"before_alembic",
"alembic",
]
assert logged_messages == [ assert logged_messages == [
"数据库需要从版本 old 升级到 head,正在创建迁移前备份" "数据库需要从版本 old 升级到 head,正在创建迁移前备份"
] ]
@@ -434,6 +455,315 @@ def downgrade():
assert active_columns == {"id", "migrated"} assert active_columns == {"id", "migrated"}
def test_migration_config_write_rolls_back_with_alembic_transaction(
monkeypatch,
) -> None:
"""配置 DML 不得脱离 Alembic 事务提前提交。"""
migration = importlib.import_module(
"database.versions.e8b1c4d7a2f9_2_2_18"
)
engine = create_engine("sqlite://")
metadata = MetaData()
systemconfig = Table(
"systemconfig",
metadata,
Column("id", Integer, primary_key=True),
Column("key", String),
Column("value", JSON),
)
for table_name in ("subscribe", "subscribehistory", "transferhistory"):
Table(table_name, metadata, Column("id", Integer, primary_key=True))
metadata.create_all(engine)
legacy_organize = """
{
'title': '{{ title_year }}'
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'类型:{{ type }}'
'{% if category %},类别:{{ category }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
'{{ file_count }}个文件,大小:{{ total_size }}'
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
}"""
legacy_download = """
{
'title': '{{ title_year }}'
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if resource_term %}\\n质量:{{ resource_term }}{% endif %}'
'{% if size %}\\n大小:{{ size }}{% endif %}'
'{% if torrent_title %}\\n种子:{{ torrent_title }}{% endif %}'
'{% if pubdate %}\\n发布时间:{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间:{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数:{{ seeders }}{% endif %}'
'{% if volume_factor %}\\n促销:{{ volume_factor }}{% endif %}'
'{% if hit_and_run %}\\nHit&Run{{ hit_and_run }}{% endif %}'
'{% if labels %}\\n标签:{{ labels }}{% endif %}'
'{% if description %}\\n描述:{{ description }}{% endif %}'
}"""
original_templates = {
"organizeSuccess": legacy_organize,
"downloadAdded": legacy_download,
}
with engine.begin() as connection:
connection.execute(
systemconfig.insert().values(
key="NotificationTemplates",
value=original_templates,
)
)
with engine.connect() as connection:
transaction = connection.begin()
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
config_write_seen = False
def fail_after_config_write(
_connection,
_cursor,
statement,
_parameters,
_context,
_executemany,
) -> None:
nonlocal config_write_seen
if statement.lstrip().upper().startswith("UPDATE SYSTEMCONFIG"):
config_write_seen = True
raise RuntimeError("injected migration failure")
event.listen(engine, "after_cursor_execute", fail_after_config_write)
try:
with pytest.raises(RuntimeError, match="injected migration failure"):
migration.upgrade()
finally:
event.remove(engine, "after_cursor_execute", fail_after_config_write)
transaction.rollback()
assert config_write_seen
with engine.connect() as connection:
columns = {
column["name"]
for column in inspect(connection).get_columns("subscribe")
}
# SQLite 默认驱动不回滚 DDL,但配置写入仍必须服从 Alembic 事务。
assert "audio_quality" in columns
stored_templates = connection.execute(
systemconfig.select().with_only_columns(systemconfig.c.value).where(
systemconfig.c.key == "NotificationTemplates"
)
).scalar_one()
assert stored_templates == original_templates
def test_initial_migration_rolls_back_user_and_storages_together(
monkeypatch,
) -> None:
"""2.0.0 管理员与存储初始化必须共享 Alembic 事务。"""
migration = importlib.import_module(
"database.versions.294b007932ef_2_0_0"
)
engine = create_engine("sqlite://")
metadata = MetaData()
Table(
"user",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String, nullable=False),
Column("email", String),
Column("hashed_password", String),
Column("is_active", Boolean),
Column("is_superuser", Boolean),
Column("avatar", String),
Column("is_otp", Boolean),
Column("otp_secret", String),
Column("permissions", JSON),
Column("settings", JSON),
)
systemconfig = Table(
"systemconfig",
metadata,
Column("id", Integer, primary_key=True),
Column("key", String),
Column("value", JSON),
)
metadata.create_all(engine)
monkeypatch.setattr(migration.settings, "SUPERUSER", "migration-admin")
monkeypatch.setattr(
migration.settings,
"SUPERUSER_PASSWORD",
"migration-password",
)
monkeypatch.setattr(
migration,
"get_password_hash",
lambda password: f"hashed:{password}",
)
with engine.connect() as connection:
transaction = connection.begin()
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
def fail_storages_write(
_connection,
_cursor,
statement,
_parameters,
_context,
_executemany,
) -> None:
if statement.lstrip().upper().startswith("INSERT INTO SYSTEMCONFIG"):
raise RuntimeError("injected storages failure")
event.listen(engine, "after_cursor_execute", fail_storages_write)
try:
with pytest.raises(RuntimeError, match="injected storages failure"):
migration.upgrade()
finally:
event.remove(engine, "after_cursor_execute", fail_storages_write)
transaction.rollback()
with engine.connect() as connection:
assert connection.execute(text("SELECT COUNT(*) FROM user")).scalar_one() == 0
assert connection.execute(
text("SELECT COUNT(*) FROM systemconfig")
).scalar_one() == 0
def test_userconfig_cleanup_migration_uses_alembic_transaction(
monkeypatch,
) -> None:
"""2.0.3 用户配置清理必须随当前 Alembic 事务一起回滚。"""
migration = importlib.import_module(
"database.versions.e2dbe1421fa4_2_0_3"
)
engine = create_engine("sqlite://")
metadata = MetaData()
table_columns = {
"downloadhistory": (("note", JSON), ("media_category", String)),
"subscribe": (
("note", JSON),
("custom_words", String),
("media_category", String),
("filter_groups", JSON),
),
"mediaserveritem": (("note", JSON),),
"message": (("note", JSON),),
"plugindata": (("value", JSON),),
"site": (("note", JSON),),
"sitestatistic": (("note", JSON),),
"systemconfig": (("value", JSON),),
"userconfig": (("value", JSON),),
}
tables = {
table_name: Table(
table_name,
metadata,
Column("id", Integer, primary_key=True),
*(Column(column_name, column_type) for column_name, column_type in columns),
)
for table_name, columns in table_columns.items()
}
metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(
tables["userconfig"].insert().values(value={"retained": True})
)
with engine.connect() as connection:
transaction = connection.begin()
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
migration.upgrade()
assert connection.execute(
sa.select(sa.func.count()).select_from(tables["userconfig"])
).scalar_one() == 0
transaction.rollback()
with engine.connect() as connection:
assert connection.execute(
sa.select(sa.func.count()).select_from(tables["userconfig"])
).scalar_one() == 1
def test_user_permission_migration_uses_alembic_transaction(
monkeypatch,
) -> None:
"""2.1.6 权限初始化必须保留原筛选语义并随迁移事务回滚。"""
migration = importlib.import_module(
"database.versions.3df653756eec_2_1_6"
)
engine = create_engine("sqlite://")
metadata = MetaData()
user = Table(
"user",
metadata,
Column("id", Integer, primary_key=True),
Column("is_superuser", Boolean),
Column("permissions", JSON),
)
metadata.create_all(engine)
existing_permissions = {"manage": True}
with engine.begin() as connection:
connection.execute(user.insert(), [
{"id": 1, "is_superuser": False, "permissions": None},
{"id": 2, "is_superuser": False, "permissions": existing_permissions},
{"id": 3, "is_superuser": True, "permissions": None},
])
with engine.connect() as connection:
transaction = connection.begin()
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
migration.upgrade()
migrated = connection.execute(
sa.select(user.c.id, user.c.permissions).order_by(user.c.id)
).all()
assert migrated == [
(
1,
{
"discovery": True,
"search": True,
"subscribe": True,
"manage": False,
},
),
(2, existing_permissions),
(3, None),
]
transaction.rollback()
with engine.connect() as connection:
assert connection.execute(
sa.select(user.c.id, user.c.permissions).order_by(user.c.id)
).all() == [
(1, None),
(2, existing_permissions),
(3, None),
]
def test_local_setup_returns_failure_when_database_migration_fails( def test_local_setup_returns_failure_when_database_migration_fails(
monkeypatch, monkeypatch,
capsys, capsys,
+28 -7
View File
@@ -16,6 +16,9 @@ from pathlib import Path
import importlib.util import importlib.util
import pytest import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
@@ -207,9 +210,7 @@ def test_message_without_template_config_stays_unchanged(
assert message.text is None assert message.text is None
def test_v3_migration_overwrites_templates_once( def test_v3_migration_overwrites_templates_once(monkeypatch) -> None:
notification_templates: SystemConfigOper,
) -> None:
""" """
V3 大版本迁移应无条件覆盖用户旧通知模板配置,并写入全部 4 类模板。 V3 大版本迁移应无条件覆盖用户旧通知模板配置,并写入全部 4 类模板。
""" """
@@ -221,12 +222,32 @@ def test_v3_migration_overwrites_templates_once(
migration = importlib.util.module_from_spec(spec) migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration) spec.loader.exec_module(migration)
notification_templates.set( engine = sa.create_engine("sqlite://")
SystemConfigKey.NotificationTemplates, {"organizeSuccess": "用户自定义模板"} metadata = sa.MetaData()
systemconfig = sa.Table(
"systemconfig",
metadata,
sa.Column("key", sa.String(), primary_key=True),
sa.Column("value", sa.JSON()),
) )
migration.upgrade() with engine.begin() as connection:
metadata.create_all(connection)
connection.execute(
systemconfig.insert().values(
key=SystemConfigKey.NotificationTemplates.value,
value={"organizeSuccess": "用户自定义模板"},
)
)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
templates = connection.execute(
sa.select(systemconfig.c.value).where(
systemconfig.c.key == SystemConfigKey.NotificationTemplates.value
)
).scalar_one()
templates = notification_templates.get(SystemConfigKey.NotificationTemplates)
assert set(templates.keys()) == { assert set(templates.keys()) == {
"organizeSuccess", "downloadAdded", "subscribeAdded", "subscribeComplete", "organizeSuccess", "downloadAdded", "subscribeAdded", "subscribeComplete",
} }
@@ -11,7 +11,8 @@ from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.domain.meta.metabase import MetaBase from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.db.oper.transferhistory import TransferHistoryOper from app.db.oper.transferhistory import TransferHistoryOper
from app.schemas import FileItem, TransferInfo from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None: def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None:
@@ -156,18 +157,25 @@ def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None:
metadata, metadata,
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
) )
config_oper = Mock() systemconfig = sa.Table(
config_oper.get.return_value = { "systemconfig",
metadata,
sa.Column("key", sa.String(), primary_key=True),
sa.Column("value", sa.JSON()),
)
custom_templates = {
"organizeSuccess": "custom organize template", "organizeSuccess": "custom organize template",
"downloadAdded": "custom download template", "downloadAdded": "custom download template",
} }
monkeypatch.setattr(
"app.db.oper.systemconfig.SystemConfigOper",
lambda: config_oper,
)
with engine.begin() as connection: with engine.begin() as connection:
metadata.create_all(connection) metadata.create_all(connection)
connection.execute(
systemconfig.insert().values(
key="NotificationTemplates",
value=custom_templates,
)
)
context = MigrationContext.configure(connection) context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context)) monkeypatch.setattr(migration, "op", Operations(context))
@@ -186,6 +194,11 @@ def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None:
column["name"] column["name"]
for column in inspector.get_columns("transferhistory") for column in inspector.get_columns("transferhistory")
} }
stored_templates = connection.execute(
sa.select(systemconfig.c.value).where(
systemconfig.c.key == "NotificationTemplates"
)
).scalar_one()
assert { assert {
"audio_quality", "audio_quality",
@@ -206,7 +219,7 @@ def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None:
"sample_rate", "sample_rate",
"bitrate", "bitrate",
}.issubset(transfer_columns) }.issubset(transfer_columns)
config_oper.set.assert_not_called() assert stored_templates == custom_templates
def test_transfer_history_preserves_album_entity_context() -> None: def test_transfer_history_preserves_album_entity_context() -> None: