fix(4.0): 版本门禁覆盖首次启动的 2.x 数据与 3.0 数据库,提示先还原配置备份

- 2.x 的 data.json 检查移到首次启动之前,不再建出新库后在下次启动才拒绝
- 缺少 version.info 且数据库没有 rssitem 表时判定为 3.0.x,提示先升级到 3.1.x
- 拒绝提示说明要先用 config.json.v3.bak 覆盖已被改写的 config.json
This commit is contained in:
Estrella Pan
2026-10-08 16:20:06 +02:00
parent 9af029485e
commit 813f04abff
4 changed files with 86 additions and 10 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 (

View File

@@ -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):