From 813f04abfff69a917010d0dfdf0db43f670f9ef3 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 16:20:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(4.0):=20=E7=89=88=E6=9C=AC=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E8=A6=86=E7=9B=96=E9=A6=96=E6=AC=A1=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=202.x=20=E6=95=B0=E6=8D=AE=E4=B8=8E=203.0=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=EF=BC=8C=E6=8F=90=E7=A4=BA=E5=85=88=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E9=85=8D=E7=BD=AE=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2.x 的 data.json 检查移到首次启动之前,不再建出新库后在下次启动才拒绝 - 缺少 version.info 且数据库没有 rssitem 表时判定为 3.0.x,提示先升级到 3.1.x - 拒绝提示说明要先用 config.json.v3.bak 覆盖已被改写的 config.json --- backend/src/module/core/context.py | 2 + backend/src/module/update/version_check.py | 47 ++++++++++++++++++---- backend/src/test/test_context.py | 15 +++++++ backend/src/test/test_version_check.py | 32 ++++++++++++++- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index 3da95aaa..9bdd984e 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -25,6 +25,7 @@ from module.rss import RSSAnalyser from module.searcher.searcher import reset_cache as reset_poster_cache from module.update.auth import migrate_legacy_auth_tokens from module.update.startup import cache_image, first_run, run_migrations +from module.update.version_check import refuse_legacy_data from .loops import ( calendar_tick, @@ -219,6 +220,7 @@ class AppContext: if self._startup_done: return self._start_info() + refuse_legacy_data() if not Checker.check_database(): await first_run() await migrate_legacy_auth_tokens() diff --git a/backend/src/module/update/version_check.py b/backend/src/module/update/version_check.py index b6d8e029..7eaaeee5 100644 --- a/backend/src/module/update/version_check.py +++ b/backend/src/module/update/version_check.py @@ -1,11 +1,17 @@ import logging +import sqlite3 +from contextlib import closing +from pathlib import Path import semver -from module.conf import LEGACY_DATA_PATH, VERSION, VERSION_PATH +from module.conf import DATA_PATH, LEGACY_DATA_PATH, VERSION, VERSION_PATH +from module.conf.config import CONFIG_PATH logger = logging.getLogger(__name__) +DATABASE_PATH = Path(DATA_PATH.removeprefix("sqlite:///")) + # 4.0 只支持从 3.3.x 起跳升级;更早版本的跨版本迁移代码已移除。 MIN_UPGRADE_FROM = (3, 3) @@ -14,16 +20,40 @@ class UnsupportedUpgradeError(RuntimeError): """已有数据来自过旧的版本,无法直接升级到当前版本。""" -def _upgrade_hint(previous: str) -> str: +def _upgrade_hint(previous: str, via: str = "3.3.x") -> str: + # 拒绝前 Settings 已把 config.json 改写为 4.0 格式,旧版本读不懂, + # 必须先还原迁移前的备份 + backup = f"{CONFIG_PATH}.v3.bak" return ( f"Detected data from AutoBangumi {previous}. Upgrading directly to " - f"{VERSION} is not supported: please upgrade to the latest 3.3.x first, " + f"{VERSION} is not supported. If {backup} exists, copy it back to " + f"{CONFIG_PATH} first. Then upgrade to the latest {via}, " "start it once so it migrates your data, then upgrade again. " f"检测到 {previous} 版本的数据,无法直接升级到 {VERSION}:" - "请先升级到最新的 3.3.x 并启动一次完成数据迁移,再升级到本版本。" + f"若存在 {backup},请先用它覆盖 {CONFIG_PATH};" + f"再升级到最新的 {via} 并启动一次完成数据迁移,然后再升级。" ) +def refuse_legacy_data() -> None: + """残留 2.x 的 ``data.json`` 时抛 :class:`UnsupportedUpgradeError`。 + + 首次启动(尚无数据库)也要检查:否则会建出新库当作新安装启动, + 之后每次启动才被拒绝。 + """ + if LEGACY_DATA_PATH.exists(): + raise UnsupportedUpgradeError(_upgrade_hint("2.x")) + + +def _is_v30_database() -> bool: + """3.0.x 不写 version.info,数据库里也没有 3.1 起才有的 rssitem 表。""" + with closing(sqlite3.connect(DATABASE_PATH)) as conn: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'rssitem'" + ).fetchone() + return row is None + + def _read_last_version() -> semver.Version | None: try: lines = VERSION_PATH.read_text().splitlines() @@ -40,14 +70,15 @@ def _read_last_version() -> semver.Version | None: def version_check() -> semver.Version | None: """校验并记录版本,返回上一次运行的版本(未知时为 None)。 - 仅在已有数据库时调用。上一次版本低于 ``MIN_UPGRADE_FROM``,或残留 2.x 的 - ``data.json`` 时抛出 :class:`UnsupportedUpgradeError`,中止启动。 + 仅在已有数据库时调用。上一次版本低于 ``MIN_UPGRADE_FROM``,或数据库来自 + 3.0.x 时抛出 :class:`UnsupportedUpgradeError`,中止启动。 """ - if LEGACY_DATA_PATH.exists(): - raise UnsupportedUpgradeError(_upgrade_hint("2.x")) if VERSION in ("DEV_VERSION", "local"): return None if not VERSION_PATH.exists(): + # 3.3 也不补跑 3.0 → 3.1 的数据迁移(缺 version.info 时跳过),只有 3.1.x 会 + if _is_v30_database(): + raise UnsupportedUpgradeError(_upgrade_hint("3.0", via="3.1.x")) VERSION_PATH.write_text(VERSION + "\n") return None last_ver = _read_last_version() diff --git a/backend/src/test/test_context.py b/backend/src/test/test_context.py index c7ce8542..3167b894 100644 --- a/backend/src/test/test_context.py +++ b/backend/src/test/test_context.py @@ -80,6 +80,21 @@ class TestStartup: assert ctx._startup_done is True assert ctx.scheduler.running is False + async def test_first_run_with_legacy_data_json_aborts_startup(self, ctx, tmp_path): + """2.x 的卷只有 data.json 没有数据库:首次启动就拒绝,不建新库。""" + legacy = tmp_path / "data.json" + legacy.write_text("{}") + with ( + patch("module.update.version_check.LEGACY_DATA_PATH", legacy), + patch("module.core.context.Checker.check_database", return_value=False), + patch("module.core.context.first_run") as mock_first_run, + ): + with pytest.raises(UnsupportedUpgradeError, match="2.x"): + await ctx.startup() + + mock_first_run.assert_not_called() + assert ctx._startup_done is False + async def test_existing_db_runs_pending_migrations(self, ctx): """Existing DB from a supported version => run_migrations() is invoked.""" with ( diff --git a/backend/src/test/test_version_check.py b/backend/src/test/test_version_check.py index 11e903f1..4d592a9b 100644 --- a/backend/src/test/test_version_check.py +++ b/backend/src/test/test_version_check.py @@ -1,19 +1,33 @@ """version_check:记录运行版本,并拒绝从 3.3 以前的版本直接升级。""" import importlib +import sqlite3 +from contextlib import closing +from pathlib import Path import pytest vc = importlib.import_module("module.update.version_check") +def _make_db(path, *tables: str) -> None: + with closing(sqlite3.connect(path)) as conn: + for table in tables: + conn.execute(f"CREATE TABLE {table} (id INTEGER)") + + @pytest.fixture def paths(tmp_path, monkeypatch): version_path = tmp_path / "version.info" legacy_path = tmp_path / "data.json" + db_path = tmp_path / "data.db" + _make_db(db_path, "bangumi", "rssitem") monkeypatch.setattr(vc, "VERSION_PATH", version_path) monkeypatch.setattr(vc, "LEGACY_DATA_PATH", legacy_path) + monkeypatch.setattr(vc, "DATABASE_PATH", db_path) monkeypatch.setattr(vc, "VERSION", "4.0.0") + # 发布版本的配置文件(测试环境是 config_dev.json) + monkeypatch.setattr(vc, "CONFIG_PATH", Path("config/config.json")) return version_path, legacy_path @@ -23,6 +37,18 @@ def test_missing_version_file_records_current(paths): assert version_path.read_text() == "4.0.0\n" +def test_missing_version_file_with_30_database_is_refused(paths, tmp_path, monkeypatch): + """3.0.x 不写 version.info,数据库也没有 3.1 起才有的 rssitem 表。""" + version_path, _ = paths + db_path = tmp_path / "v30.db" + _make_db(db_path, "bangumi", "torrent", "user") + monkeypatch.setattr(vc, "DATABASE_PATH", db_path) + # 只有 3.1.x 会在缺少 version.info 时补跑 3.0 → 3.1 的数据迁移 + with pytest.raises(vc.UnsupportedUpgradeError, match=r"3\.0.*3\.1\.x"): + vc.version_check() + assert not version_path.exists() + + def test_upgrade_from_33_is_recorded(paths): version_path, _ = paths version_path.write_text("3.2.7\n3.3.6\n") @@ -55,8 +81,10 @@ def test_downgrade_is_not_recorded(paths): def test_upgrade_from_pre_33_is_refused(paths, previous): version_path, _ = paths version_path.write_text(previous + "\n") - with pytest.raises(vc.UnsupportedUpgradeError, match=previous): + with pytest.raises(vc.UnsupportedUpgradeError, match=previous) as exc: vc.version_check() + # 本次启动已把 config.json 改写为 4.0 格式,提示要先还原备份再回到旧版本 + assert "config.json.v3.bak" in str(exc.value) # 拒绝时不改写记录,用户回退到 3.3.x 后仍能正常迁移 assert version_path.read_text() == previous + "\n" @@ -65,7 +93,7 @@ def test_legacy_data_json_is_refused(paths): _, legacy_path = paths legacy_path.write_text("{}") with pytest.raises(vc.UnsupportedUpgradeError, match="2.x"): - vc.version_check() + vc.refuse_legacy_data() def test_malformed_version_file_is_rewritten(paths):