From 9ba513debc66696bda3103e7fcf40f5aa58e3c57 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 13 Sep 2026 11:31:54 +0800 Subject: [PATCH] fix(classification): initialize defaults in fresh v3 environments --- app/startup/composition/classification.py | 9 ++- docs/v2-to-v3-overview.md | 2 +- tests/test_media_classification_startup.py | 67 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index 77032a4c7..89a26c2f5 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -14,6 +14,7 @@ from pydantic import ValidationError from app.application.classification.configuration import ( ClassificationPolicyConfigurationService, ClassificationPolicyValidationError, + build_default_classification_policy, needs_default_music_classification, with_default_music_classification, ) @@ -95,7 +96,10 @@ async def compose_classification( enrichment: ClassificationEnrichmentPort | None = None, ) -> ClassificationComposition: """ - 构造分类策略服务,并仅在新配置键不存在时读取一次 legacy YAML + 构造分类策略服务,并仅在新配置键不存在时读取一次 legacy YAML。 + + 新环境没有旧配置文件时,启动层会显式发布内置默认策略;已有策略或合法 + legacy 配置始终优先,避免用默认值覆盖用户事实。 已存在但损坏的新策略不会重新读取 YAML 或覆盖数据库事实,运行时保持不可用并暴露诊断。 """ @@ -189,7 +193,8 @@ async def compose_classification( legacy_path = Path(settings.CONFIG_PATH) / "category.yaml" if not await executor.run(legacy_path.exists): - await service.async_initialize() + await service.async_initialize(build_default_classification_policy()) + logger.info("未发现 category.yaml,已初始化内置默认分类策略 revision 1") return finish( ClassificationRuntime(service), migrated=False, diff --git a/docs/v2-to-v3-overview.md b/docs/v2-to-v3-overview.md index 493793f7a..4a2518bad 100644 --- a/docs/v2-to-v3-overview.md +++ b/docs/v2-to-v3-overview.md @@ -118,7 +118,7 @@ V2 自动分类主要由 TMDB 详情和 `category.yaml` 驱动,只覆盖电影 目录、订阅、下载和整理历史不再只保存易变的分类名称,而是同时记录内部分类编号 `category_id` 和当时的路径快照。分类改名或调整路径后,既有目录和订阅仍能找到同一个分类;已经建立的整理计划会继续使用计划创建时保存的分类目标,不会被后续策略修改。 -升级时,如果尚未存在 V3 分类策略,系统会读取现有 `category.yaml` 并自动迁移;旧 TMDB 规则的顺序、排除条件、年份范围和“没有规则命中时使用的默认分类”会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category` 和 `GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带版本号校验的策略接口完成。 +升级时,如果尚未存在 V3 分类策略,系统会优先读取现有 `category.yaml` 并自动迁移;没有旧配置文件的新环境会直接初始化内置的电影、电视剧和音乐默认分类。旧 TMDB 规则的顺序、排除条件、年份范围和“没有规则命中时使用的默认分类”会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category` 和 `GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带版本号校验的策略接口完成。 旧分类名称中用于表示目录层级的 `/` 会在迁移时转换为多个安全路径段,分类名称和目录显示结果保持不变;已经完成迁移的策略会在数据库升级时自动修复。 diff --git a/tests/test_media_classification_startup.py b/tests/test_media_classification_startup.py index 83cb4d819..d408d5323 100644 --- a/tests/test_media_classification_startup.py +++ b/tests/test_media_classification_startup.py @@ -386,6 +386,73 @@ async def test_edited_legacy_default_policy_is_not_automatically_changed( assert store.write_count == 0 +@pytest.mark.asyncio # type: ignore[misc] +async def test_fresh_environment_initializes_builtin_default_policy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """没有旧配置文件的新环境应持久化完整内置分类,而不是只有未分类兜底。""" + store = _MemoryPolicyStore() + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, _SystemConfig({})), + ) + + policy = composition.runtime.require_policy() + assert composition.migrated is False + assert policy.revision == 1 + assert [category.id for category in policy.categories] == [ + "movie.animation", + "movie.chinese", + "movie.foreign", + "tv.dongman.cn", + "tv.dongman.jp", + "tv.documentary", + "tv.kids", + "tv.variety", + "tv.chinese", + "tv.western", + "tv.asian", + "tv.uncategorized", + "music.uncategorized", + "music.album", + "music.compilation", + "music.ep", + "music.single", + ] + assert [rule.id for rule in policy.rules] == [ + "movie.animation.default", + "movie.chinese.default", + "tv.dongman.cn.default", + "tv.dongman.jp.default", + "tv.documentary.default", + "tv.kids.default", + "tv.variety.default", + "tv.chinese.default", + "tv.western.default", + "tv.asian.default", + "music.compilation.default", + "music.ep.default", + "music.single.default", + "music.album.default", + ] + assert policy.fallbacks == { + "电影": "movie.foreign", + "电视剧": "tv.uncategorized", + "音乐": "music.uncategorized", + } + assert store.state is not None + assert store.state.active == policy + assert store.write_count == 1 + + @pytest.mark.asyncio # type: ignore[misc] async def test_absent_policy_migrates_yaml_once_without_rewriting_file( monkeypatch: pytest.MonkeyPatch,