diff --git a/app/application/configuration.py b/app/application/configuration.py index 0c06d29fc..06de79225 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any, Optional, Protocol class SystemConfigReader(Protocol): @@ -80,6 +80,12 @@ class ChainRuntimeConfig: """Chain 在一次宿主生命周期内使用的基础配置快照。""" media_extensions: tuple[str, ...] + superuser: str = "admin" + media_recognize_share: bool = False + auxiliary_auth_enable: bool = False + global_image_cache: bool = False + auto_download_user: Optional[str] = None + resource_url: Optional[str] = None @dataclass(frozen=True, slots=True) diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 2c2e15cdb..320a8f9f1 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -13,7 +13,6 @@ from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.foundation.identity import normalize_internal_user_id from app.application.messaging.message import MessageTemplateHelper -from app.runtime.config import settings from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.schemas.message import MessageResponse @@ -176,7 +175,9 @@ class NotificationMixin: # 仅发送管理员 logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") # 读取管理员消息IDS - send_message.targets = useroper.get_settings(settings.SUPERUSER) + send_message.targets = useroper.get_settings( + self.runtime_config.superuser + ) admin_sended = True elif action == "user" and send_message.username: # 发送对应用户 @@ -196,7 +197,7 @@ class NotificationMixin: ) # 读取管理员消息IDS send_message.targets = useroper.get_settings( - settings.SUPERUSER + self.runtime_config.superuser ) admin_sended = True else: @@ -205,7 +206,7 @@ class NotificationMixin: f"用户 {send_message.username} 不存在,消息无法发送到对应用户" ) continue - elif send_message.username == settings.SUPERUSER: + elif send_message.username == self.runtime_config.superuser: # 管理员同名已发送 admin_sended = True else: @@ -292,7 +293,9 @@ class NotificationMixin: # 仅发送管理员 logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") # 读取管理员消息IDS - send_message.targets = useroper.get_settings(settings.SUPERUSER) + send_message.targets = useroper.get_settings( + self.runtime_config.superuser + ) admin_sended = True elif action == "user" and send_message.username: # 发送对应用户 @@ -312,7 +315,7 @@ class NotificationMixin: ) # 读取管理员消息IDS send_message.targets = useroper.get_settings( - settings.SUPERUSER + self.runtime_config.superuser ) admin_sended = True else: @@ -321,7 +324,7 @@ class NotificationMixin: f"用户 {send_message.username} 不存在,消息无法发送到对应用户" ) continue - elif send_message.username == settings.SUPERUSER: + elif send_message.username == self.runtime_config.superuser: # 管理员同名已发送 admin_sended = True else: diff --git a/app/chain/_recognition.py b/app/chain/_recognition.py index 031ac1d12..cff662722 100644 --- a/app/chain/_recognition.py +++ b/app/chain/_recognition.py @@ -15,7 +15,6 @@ from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.runtime.cache import fresh, async_fresh -from app.runtime.config import settings from app.runtime.events import Event from app.runtime.log import logger from app.schemas.media import normalize_media_source, resolve_media_identity @@ -24,8 +23,8 @@ from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConf class RecognitionMixin: - @staticmethod def _can_use_media_recognize_share( + self, meta: Optional[MetaBase], media_source: Optional[MediaSource], media_id: Optional[str], @@ -34,7 +33,7 @@ class RecognitionMixin: 仅在名称识别场景下使用共享识别,显式ID识别不再重复回查 """ return bool( - settings.MEDIA_RECOGNIZE_SHARE + self.runtime_config.media_recognize_share and meta and not media_source and not media_id @@ -515,4 +514,3 @@ class RecognitionMixin: f"({plugin_info.media_source}:{plugin_info.media_id})" ) return plugin_info - diff --git a/app/chain/interaction.py b/app/chain/interaction.py index d947f2241..7452f6932 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -19,7 +19,6 @@ from app.domain import title as title_rules from app.domain.context import Context, MediaInfo from app.domain.meta.metabase import MetaBase from app.foundation import url as url_tools -from app.runtime.config import settings from app.runtime.log import logger from app.schemas.download import DownloadDirectory from app.schemas.file import FileURI @@ -1130,7 +1129,7 @@ class MediaInteractionChain(ChainBase): source=source, title=title, userid=userid, - link=settings.MP_DOMAIN("#/resource"), + link=self.runtime_config.resource_url, buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id, @@ -1533,12 +1532,11 @@ class MediaInteractionChain(ChainBase): for sea, no_exist in season_map.items() ] - @staticmethod - def _should_auto_download(userid: Union[str, int]) -> bool: + def _should_auto_download(self, userid: Union[str, int]) -> bool: """ 判断当前用户是否命中自动下载名单。 """ - auto_download_user = settings.AUTO_DOWNLOAD_USER + auto_download_user = self.runtime_config.auto_download_user return bool( auto_download_user and ( diff --git a/app/chain/recommend.py b/app/chain/recommend.py index ce8732f55..5bb75dae4 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -8,7 +8,7 @@ from app.chain.douban import DoubanChain from app.chain.listenbrainz import ListenBrainzChain from app.chain.tmdb import TmdbChain from app.runtime.cache import cached, fresh -from app.runtime.config import settings, global_vars +from app.runtime.config import global_vars from app.domain.context import MusicInfo from app.application.image import ImageHelper from app.runtime.log import logger @@ -272,7 +272,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): :param datas: 数据列表 :param progress_callback: 定时服务进度更新回调 """ - if not settings.GLOBAL_IMAGE_CACHE: + if not self.runtime_config.global_image_cache: return total_num = len(datas) diff --git a/app/chain/user.py b/app/chain/user.py index d34f74a85..0c4b16e72 100644 --- a/app/chain/user.py +++ b/app/chain/user.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Any, Literal, Optional, Tuple, Union from app.chain import ChainBase -from app.runtime.config import settings from app.application.security.token import get_password_hash, verify_password from app.application.chain.data import UserPortProxy as UserOper from app.runtime.log import logger @@ -73,7 +72,7 @@ class UserChain(ChainBase): return True, user_or_message else: # 用户不存在或密码错误,考虑辅助认证 - if settings.AUXILIARY_AUTH_ENABLE: + if self.runtime_config.auxiliary_auth_enable: logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...") aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials) if aux_success: @@ -91,7 +90,7 @@ class UserChain(ChainBase): return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE elif credentials.grant_type == "authorization_code": # 处理其他认证类型的分支 - if settings.AUXILIARY_AUTH_ENABLE: + if self.runtime_config.auxiliary_auth_enable: aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials) if aux_success: return True, aux_user_or_message diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index c8ea18f97..ff07f4bbf 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -207,14 +207,20 @@ def _build_scheduler_runtime_config() -> SchedulerRuntimeConfig: def _build_chain_runtime_config() -> ChainRuntimeConfig: - """构建 Chain 通用媒体文件后缀配置快照。""" + """构建 Chain 在本次实例生命周期内使用的部署配置快照。""" return ChainRuntimeConfig( media_extensions=tuple( settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT - ) + ), + superuser=settings.SUPERUSER, + media_recognize_share=settings.MEDIA_RECOGNIZE_SHARE, + auxiliary_auth_enable=settings.AUXILIARY_AUTH_ENABLE, + global_image_cache=settings.GLOBAL_IMAGE_CACHE, + auto_download_user=settings.AUTO_DOWNLOAD_USER, + resource_url=settings.MP_DOMAIN("#/resource"), ) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 77898b91a..03caa5d61 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -567,6 +567,9 @@ app/api/dependencies/ # 按领域拆分依赖工厂 - 登录、仪表板和整理历史 API 不再直接导入 `settings`;`Scheduler` 已清除全部直接 `settings` 访问, 用户认证配置改走 `SystemConfigService`;`StorageChain` 的媒体后缀改走 Chain snapshot。canonical 配置债务 从 169/15 降到 164 个 settings import 文件/14 个 SystemConfigOper 构造点。 +- Chain snapshot 继续覆盖超级用户、共享识别、辅助认证、全局图片缓存、自动下载用户和资源页链接; + 消息、识别、交互、推荐和用户链的 5 个直接 `settings` 导入被移除,当前低水位进一步降到 + 161 个 settings import 文件,插件 SDK 与兼容入口未改。 - 直接调用 endpoint 和显式构造 `ChainRuntimeContext` 的旧测试/兼容入口仍有 fallback;正式 FastAPI 与 Startup 路径始终使用 HostRuntime 注入。插件 SDK 的 `app.sdk.config.settings`、动态 API 返回和事件字段未改。 diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index 63c7e531a..22bbc0671 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -9,7 +9,7 @@ "root": "app" }, "settings_imports": { - "count": 166, + "count": 161, "files": [ "app/adapters/cache/backends.py", "app/adapters/cache/redis.py", @@ -69,14 +69,10 @@ "app/application/security/token.py", "app/application/security/url.py", "app/application/torrent.py", - "app/chain/_messaging.py", - "app/chain/_recognition.py", "app/chain/_transfer.py", "app/chain/download.py", - "app/chain/interaction.py", "app/chain/media.py", "app/chain/message.py", - "app/chain/recommend.py", "app/chain/scraping.py", "app/chain/search.py", "app/chain/site.py", @@ -84,7 +80,6 @@ "app/chain/system.py", "app/chain/torrents.py", "app/chain/transfer.py", - "app/chain/user.py", "app/cli.py", "app/db/base.py", "app/db/engine.py", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 844b04f79..d573a70f1 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6364, - "edge_sha256": "0f241a46ed27309c2071d4c99f00ba067ad02b3974581cc206ddec4c522a982c", + "edge_count": 6360, + "edge_sha256": "09c923d8b167a889e22829c320e05f8801c62400f672b1386d6524c55b064564", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -2851,7 +2851,6 @@ "app.chain._messaging -> app.foundation", "app.chain._messaging -> app.foundation.identity", "app.chain._messaging -> app.runtime", - "app.chain._messaging -> app.runtime.config", "app.chain._messaging -> app.runtime.extensions", "app.chain._messaging -> app.runtime.extensions.service_config", "app.chain._messaging -> app.runtime.log", @@ -2891,7 +2890,6 @@ "app.chain._recognition -> app.domain.meta.metamusic", "app.chain._recognition -> app.runtime", "app.chain._recognition -> app.runtime.cache", - "app.chain._recognition -> app.runtime.config", "app.chain._recognition -> app.runtime.events", "app.chain._recognition -> app.runtime.log", "app.chain._recognition -> app.schemas", @@ -3015,7 +3013,6 @@ "app.chain.interaction -> app.foundation", "app.chain.interaction -> app.foundation.url", "app.chain.interaction -> app.runtime", - "app.chain.interaction -> app.runtime.config", "app.chain.interaction -> app.runtime.log", "app.chain.interaction -> app.schemas", "app.chain.interaction -> app.schemas.download", @@ -3366,7 +3363,6 @@ "app.chain.user -> app.application.security.token", "app.chain.user -> app.chain", "app.chain.user -> app.runtime", - "app.chain.user -> app.runtime.config", "app.chain.user -> app.runtime.log", "app.chain.user -> app.schemas", "app.chain.user -> app.schemas.event", diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index dffb61ea8..66662ff95 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -1,8 +1,11 @@ """配置快照与窄读写端口测试。""" import asyncio +from dataclasses import FrozenInstanceError from unittest.mock import AsyncMock, MagicMock +import pytest + from app.application.configuration import ( ApiRuntimeConfig, ChainRuntimeConfig, @@ -78,5 +81,19 @@ def test_api_runtime_provider_returns_frozen_snapshot_per_request() -> None: assert before_reload.ai_agent_enable is False assert after_reload.ai_agent_enable is True - configure_runtime_configuration, - get_api_runtime_config_snapshot, + + +def test_chain_runtime_config_is_an_instance_scoped_frozen_snapshot() -> None: + """Chain 配置应随实例固定,避免同一业务调用中途读取到 reload 后的新值。""" + snapshot = ChainRuntimeConfig( + media_extensions=(".mkv",), + superuser="root", + media_recognize_share=True, + resource_url="https://example.test/#/resource", + ) + + assert snapshot.superuser == "root" + assert snapshot.media_recognize_share is True + assert snapshot.resource_url == "https://example.test/#/resource" + with pytest.raises(FrozenInstanceError): + snapshot.superuser = "changed" # type: ignore[misc] diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index 60ab49e97..e7ba78d17 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -4,6 +4,7 @@ 共享识别成功后回填本地缓存、音乐识别上报/查询载荷,以及命中缓存不重复上报等场景。 """ import asyncio +from dataclasses import replace from unittest.mock import AsyncMock, Mock, patch from app.chain import ChainBase @@ -41,6 +42,14 @@ def _tmdb_media( ) +def _enable_media_recognize_share(chain: ChainBase) -> None: + """为单个链实例启用共享识别配置快照。""" + chain.runtime_config = replace( + chain.runtime_config, + media_recognize_share=True, + ) + + def test_report_shared_result_after_local_recognize_success(): """本地识别成功后应上报共享识别结果。""" chain = ChainBase() @@ -64,6 +73,7 @@ def test_report_shared_result_after_local_recognize_success(): def test_query_shared_result_when_local_recognize_failed(): """本地识别失败后应回查共享识别结果,并按共享ID再次识别。""" chain = ChainBase() + _enable_media_recognize_share(chain) meta = _build_meta("测试剧集") shared_media = _tmdb_media("测试剧集", 200, MediaType.TV, year="2024") @@ -109,6 +119,7 @@ def test_query_shared_result_when_local_recognize_failed(): def test_async_query_shared_result_when_local_recognize_failed(): """异步识别失败后也应回查共享识别结果。""" chain = ChainBase() + _enable_media_recognize_share(chain) meta = _build_meta("测试异步剧集") shared_media = _tmdb_media("测试异步剧集", 300, MediaType.TV, year="2025") async_run_module = AsyncMock(side_effect=[None, shared_media]) @@ -157,6 +168,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): def test_backfill_local_cache_after_shared_recognize_success(): """共享识别后二次本地识别成功时,应回填原始名称对应的本地识别缓存。""" chain = ChainBase() + _enable_media_recognize_share(chain) meta = _build_meta("测试缓存回填", MediaType.MOVIE) shared_media = MediaInfo( media_source=MediaSource.TMDB, @@ -304,6 +316,7 @@ def test_report_shared_result_with_distinct_keyword_meta(): def test_query_shared_result_with_distinct_keyword_meta(): """本地识别失败后应按辅助前名称回查共享结果。""" chain = ChainBase() + _enable_media_recognize_share(chain) meta = _build_meta("辅助识别后的名称", MediaType.TV) meta.year = "2024" share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) @@ -594,6 +607,7 @@ def test_chain_recognize_media_reports_music_share_result(): def test_chain_recognize_media_queries_music_share_when_local_failed(): """音乐本地识别失败后应回查共享识别并按数据源原生 ID 二次识别。""" chain = MediaChain() + _enable_media_recognize_share(chain) meta = MetaMusic(title="晴天", artists=["周杰伦"]) music = _music_info() @@ -641,6 +655,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): def test_chain_recognize_media_queries_music_share_after_local_fallback(): """本地标签兜底没有远端身份时,仍应通过共享结果补成标准音乐身份。""" chain = MediaChain() + _enable_media_recognize_share(chain) meta = MetaMusic(title="晴天", artists=["周杰伦"]) fallback = MusicInfo(title="晴天", artists=["周杰伦"]) music = _music_info() @@ -669,9 +684,6 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): ), patch.object( chain, "_update_local_recognize_cache", - ), patch( - "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", - True, ): result = chain.recognize_media(meta=meta, cache=False) @@ -687,6 +699,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): """异步音乐识别也必须在返回本地兜底前尝试共享身份补全。""" chain = MediaChain() + _enable_media_recognize_share(chain) meta = MetaMusic(title="晴天", artists=["周杰伦"]) fallback = MusicInfo(title="晴天", artists=["周杰伦"]) music = _music_info() @@ -717,9 +730,6 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): chain, "_async_update_local_recognize_cache", new=AsyncMock(), - ), patch( - "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", - True, ): result = await chain.async_recognize_media(meta=meta, cache=False) return result, query_share, recognize_source @@ -745,6 +755,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): def test_chain_recognize_media_skips_music_report_for_fallback_result(): """共享也未命中时保留音乐标签兜底,且不把无身份结果上报。""" chain = MediaChain() + _enable_media_recognize_share(chain) meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) @@ -753,9 +764,7 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result(): return_value=None, ) as query_mock, patch( "app.chain._recognition.MoviePilotServerHelper.report_recognize_share" - ) as report_mock, patch( - "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True - ): + ) as report_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is fallback diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py index 6f920c66f..c672d4f04 100644 --- a/tests/test_media_recognize_share_statistics.py +++ b/tests/test_media_recognize_share_statistics.py @@ -41,6 +41,7 @@ def _bare_chain() -> ChainBase: """构造不执行初始化的识别链实例,并挂上无插件响应的事件管理器桩。""" chain = object.__new__(ChainBase) chain.eventmanager = Mock(check=Mock(return_value=False)) + chain.runtime_config = SimpleNamespace(media_recognize_share=True) return chain @@ -57,7 +58,6 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): type=MediaType.MOVIE, ) increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, media])) monkeypatch.setattr(chain, "_update_local_recognize_cache", Mock()) monkeypatch.setattr( @@ -86,7 +86,6 @@ def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): chain = _bare_chain() meta = _build_meta("共享识别失败电影") increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, None])) monkeypatch.setattr( MoviePilotServerHelper, @@ -122,7 +121,6 @@ def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): type=MediaType.MOVIE, ) increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr( chain, "async_run_module",