diff --git a/app/agent/tools/impl/query_system_settings.py b/app/agent/tools/impl/query_system_settings.py index aca39d894..d53f8feef 100644 --- a/app/agent/tools/impl/query_system_settings.py +++ b/app/agent/tools/impl/query_system_settings.py @@ -16,7 +16,10 @@ from app.agent.tools.impl._system_setting_utils import ( should_redact_setting, ) from app.runtime.config import settings -from app.application.configuration import get_configured_system_config as SystemConfigOper +from app.application.configuration import ( + SystemConfigReader, + get_configured_system_config as SystemConfigOper, +) from app.runtime.log import logger @@ -84,6 +87,23 @@ class QuerySystemSettingsTool(MoviePilotTool): require_admin: bool = True args_schema: Type[BaseModel] = QuerySystemSettingsInput _secret_read_confirmed: bool = PrivateAttr(default=False) + _system_config: Optional[SystemConfigReader] = PrivateAttr(default=None) + + def __init__( + self, + session_id: str, + user_id: str, + *, + system_config: Optional[SystemConfigReader] = None, + **kwargs, + ) -> None: + """注入授权范围内的配置读取端口,并兼容组合根默认服务。""" + super().__init__(session_id=session_id, user_id=user_id, **kwargs) + self._system_config = system_config + + def _get_system_config(self) -> SystemConfigReader: + """返回显式注入端口,旧构造形态则延迟读取组合根服务。""" + return self._system_config or SystemConfigOper() async def _run_confirmed(self, **kwargs) -> str: """仅供宿主在消费有效确认后执行一次未脱敏读取。""" @@ -105,12 +125,11 @@ class QuerySystemSettingsTool(MoviePilotTool): return f"筛选系统设置: {group} / {keyword}" return f"查询系统设置分组: {group}" - @staticmethod - def _load_setting_value(spec: SettingSpec): + def _load_setting_value(self, spec: SettingSpec): """读取指定设置项的当前值。""" if spec.source == "settings": return getattr(settings, spec.key) - return SystemConfigOper().get(spec.systemconfig_key) + return self._get_system_config().get(spec.systemconfig_key) @staticmethod def _summarize_value(value, *, redacted: bool = False) -> dict: diff --git a/app/agent/tools/impl/update_system_settings.py b/app/agent/tools/impl/update_system_settings.py index 7b7e3e062..f6aebda87 100644 --- a/app/agent/tools/impl/update_system_settings.py +++ b/app/agent/tools/impl/update_system_settings.py @@ -4,7 +4,7 @@ import copy import json from typing import Any, Literal, Optional, Type, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, PrivateAttr from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag @@ -18,7 +18,10 @@ from app.agent.tools.impl._system_setting_utils import ( ) from app.runtime.config import settings from app.runtime.events import eventmanager -from app.application.configuration import get_configured_system_config as SystemConfigOper +from app.application.configuration import ( + SystemConfigService, + get_configured_system_config as SystemConfigOper, +) from app.runtime.log import logger from app.schemas.event import ConfigChangeEventData from app.schemas.types import EventType @@ -74,6 +77,8 @@ class UpdateSystemSettingsInput(BaseModel): class UpdateSystemSettingsTool(MoviePilotTool): + """通过授权配置服务修改可登记系统设置。""" + name: str = "update_system_settings" tags: list[str] = [ ToolTag.Write, @@ -87,6 +92,23 @@ class UpdateSystemSettingsTool(MoviePilotTool): ) require_admin: bool = True args_schema: Type[BaseModel] = UpdateSystemSettingsInput + _system_config: Optional[SystemConfigService] = PrivateAttr(default=None) + + def __init__( + self, + session_id: str, + user_id: str, + *, + system_config: Optional[SystemConfigService] = None, + **kwargs, + ) -> None: + """注入配置读写服务,并兼容组合根默认装配。""" + super().__init__(session_id=session_id, user_id=user_id, **kwargs) + self._system_config = system_config + + def _get_system_config(self) -> SystemConfigService: + """返回显式注入服务,旧构造形态则延迟读取组合根服务。""" + return self._system_config or SystemConfigOper() def get_tool_message(self, **kwargs) -> Optional[str]: """根据更新参数生成友好的提示消息。""" @@ -101,12 +123,11 @@ class UpdateSystemSettingsTool(MoviePilotTool): } return f"{action_map.get(operation, '更新系统设置')}: {setting_key}" - @staticmethod - def _load_setting_value(spec: SettingSpec): + def _load_setting_value(self, spec: SettingSpec): """读取指定设置项的当前值。""" if spec.source == "settings": return getattr(settings, spec.key) - return SystemConfigOper().get(spec.systemconfig_key) + return self._get_system_config().get(spec.systemconfig_key) @staticmethod def _normalize_systemconfig_value(value: Any): @@ -266,7 +287,7 @@ class UpdateSystemSettingsTool(MoviePilotTool): else: normalized_value = self._normalize_systemconfig_value(next_value) event_value = normalized_value - success = await SystemConfigOper().async_set( + success = await self._get_system_config().async_set( spec.systemconfig_key, normalized_value, ) diff --git a/app/application/configuration.py b/app/application/configuration.py index c58d731da..7bd32df1b 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -2,21 +2,27 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, Protocol -class ConfigurationRepository(Protocol): - """配置服务所需的最小持久化端口。""" +class SystemConfigReader(Protocol): + """持久化用户配置的最小只读端口。""" def get(self, key: Any = None) -> Any: """读取配置。""" - def set(self, key: Any, value: Any) -> bool | None: - """写入配置。""" - async def async_get(self, key: Any = None) -> Any: """异步读取配置。""" + +class SystemConfigWriter(Protocol): + """持久化用户配置的最小写入端口。""" + + def set(self, key: Any, value: Any) -> bool | None: + """写入配置。""" + async def async_set(self, key: Any, value: Any) -> bool | None: """异步写入配置。""" @@ -24,35 +30,58 @@ class ConfigurationRepository(Protocol): """删除配置。""" +class ConfigurationRepository(SystemConfigReader, SystemConfigWriter, Protocol): + """兼容同时提供读写能力的旧配置仓储。""" + + +@dataclass(frozen=True, slots=True) +class TransferRetryConfig: + """整理失败重试用例在一次调用中使用的稳定配置快照。""" + + max_failed_retries: Any + + class SystemConfigService: """系统配置读写应用服务。""" - def __init__(self, repository: ConfigurationRepository) -> None: - """注入配置数据端口。""" - self._repository = repository + def __init__( + self, + repository: ConfigurationRepository | None = None, + *, + reader: SystemConfigReader | None = None, + writer: SystemConfigWriter | None = None, + ) -> None: + """注入可分离的读写端口,并兼容旧的单仓储装配参数。""" + resolved_reader = reader or repository + resolved_writer = writer or repository + if resolved_reader is None or resolved_writer is None: + raise ValueError("系统配置服务必须同时提供 reader 与 writer") + self._reader = resolved_reader + self._writer = resolved_writer def get(self, key: Any = None) -> Any: """读取配置。""" - return self._repository.get(key) + return self._reader.get(key) def set(self, key: Any, value: Any) -> bool | None: """写入配置。""" - return self._repository.set(key, value) + return self._writer.set(key, value) async def async_get(self, key: Any = None) -> Any: """异步读取配置。""" - return await self._repository.async_get(key) + return await self._reader.async_get(key) async def async_set(self, key: Any, value: Any) -> bool | None: """异步写入配置。""" - return await self._repository.async_set(key, value) + return await self._writer.async_set(key, value) def delete(self, key: Any) -> Any: """删除配置。""" - return self._repository.delete(key) + return self._writer.delete(key) _configured_system_config: SystemConfigService | None = None +_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None def configure_system_config(service: SystemConfigService) -> None: @@ -66,3 +95,18 @@ def get_configured_system_config() -> SystemConfigService: if _configured_system_config is None: raise RuntimeError("系统配置服务尚未配置") return _configured_system_config + + +def configure_transfer_retry_config( + provider: Callable[[], TransferRetryConfig], +) -> None: + """由组合根登记整理失败重试快照工厂。""" + global _transfer_retry_config_provider + _transfer_retry_config_provider = provider + + +def get_transfer_retry_config() -> TransferRetryConfig: + """为一次整理历史判定创建不可变配置快照。""" + if _transfer_retry_config_provider is None: + raise RuntimeError("整理失败重试配置尚未装配") + return _transfer_retry_config_provider() diff --git a/app/application/history.py b/app/application/history.py index e26137df1..57b57e61b 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -2,13 +2,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Dict, Optional, Protocol, Union +from app.application.configuration import TransferRetryConfig, get_transfer_retry_config from app.domain.context import MediaInfo, MusicInfo from app.schemas.media import resolve_media_identity from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.foundation.text import cut as jieba_cut from app.runtime.cache import TTLCache -from app.runtime.config import settings from app.runtime.log import logger from app.schemas.history import ( DownloadHistory as DownloadHistoryView, @@ -480,7 +480,7 @@ def is_skip_action(action: str) -> bool: return action in (HistoryGateAction.SKIP, HistoryGateAction.SKIP_RETRY_EXHAUSTED) -def max_failed_retries() -> int: +def max_failed_retries(config: TransferRetryConfig | None = None) -> int: """ 读取失败重试上限并钳制到合法区间。 @@ -488,7 +488,7 @@ def max_failed_retries() -> int: 永久漏件,无限重试会让永久失败的文件反复刷通知,两端都不接受。 :return: 合法的最大重试次数 """ - raw = settings.TRANSFER_MAX_FAILED_RETRIES + raw = (config or get_transfer_retry_config()).max_failed_retries try: value = int(raw) except (TypeError, ValueError): diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 9ec067673..5a1348b9b 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Tuple, Union +from dataclasses import dataclass +from typing import Any, List, Optional, Tuple, Union from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.config import settings @@ -19,6 +20,13 @@ from app.adapters.network.http import RequestUtils from app.domain.media import is_media_source_enabled +@dataclass(frozen=True, slots=True) +class BangumiConfigSnapshot: + """Bangumi 模块一次配置 generation 使用的稳定网络快照。""" + + proxy: Any + + class BangumiModule(_ModuleBase): """ Bangumi媒体信息匹配 @@ -27,11 +35,13 @@ class BangumiModule(_ModuleBase): bangumiapi: BangumiApi = None scraper: MediaScraperHelper = None + _config: BangumiConfigSnapshot = BangumiConfigSnapshot(proxy=None) def init_module(self) -> None: """ 初始化Bangumi客户端 """ + self._config = BangumiConfigSnapshot(proxy=settings.PROXY) self.bangumiapi = BangumiApi() self.scraper = MediaScraperHelper() @@ -46,7 +56,7 @@ class BangumiModule(_ModuleBase): """ 测试模块连接性 """ - ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.bgm.tv/") + ret = RequestUtils(proxies=self._config.proxy).get_res("https://api.bgm.tv/") if ret and ret.status_code == 200: return True, "" elif ret: diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index 59ea27b08..33c480472 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -35,7 +35,12 @@ from app.application.messaging.message import ( MessageQueueManager, stop_message, ) -from app.application.configuration import SystemConfigService, configure_system_config +from app.application.configuration import ( + SystemConfigService, + TransferRetryConfig, + configure_system_config, + configure_transfer_retry_config, +) from app.application.database import configure_database_governance from app.application.service import configure_service_directory from app.application.plugin.runtime import configure_plugin_runtime @@ -449,6 +454,11 @@ async def init_modules() -> HostRuntime: user=lambda: UserOper(), ) configure_system_config(SystemConfigService(repository=SystemConfigOper())) + configure_transfer_retry_config( + lambda: TransferRetryConfig( + max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES, + ) + ) configure_database_governance(build_database_governance()) configure_agent_chat_service(AgentChatService(repository=AgentChatOper())) configure_user_lookups( diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index c8597679a..f484fa213 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与 ARCH-230~231 已完成,后续任务按 ID 独立提交和回滚 +> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与阶段 3(ARCH-230~232)已完成,后续任务按 ID 独立提交和回滚 ## 1. 结论先行 @@ -502,6 +502,21 @@ app/api/dependencies/ # 按领域拆分依赖工厂 5. Agent tool 通过注入的设置服务读取可授权字段,不直接构造 Oper。 6. 保留 `app.sdk.config.settings` 给旧插件;宿主 canonical 新代码不得因此继续扩大直接依赖。 +**实施记录(2026-08-21)**: + +- 新增 `configuration-debt-baseline.json` 与单向 ratchet。基线排除 `app/plugins`、`app/sdk` 和 + `app/runtime/compat`,当前 canonical 宿主为 169 个直接导入 `settings` 的文件、15 个真实 + `app.db.oper.systemconfig.SystemConfigOper` 构造点;删除旧债务继续通过,新增或换位置均失败。 +- `SystemConfigReader` / `SystemConfigWriter` 已成为持久用户配置的窄端口;`SystemConfigService` + 支持分别注入 reader/writer,同时保留 `repository=` 兼容装配。Agent 系统设置查询与修改工具支持 + 显式注入授权配置端口,旧工具构造签名与密钥确认/脱敏行为不变。 +- 整理失败重试从 Application 直接读取全局 `settings` 改为 `TransferRetryConfig` frozen snapshot; + 启动组合根和测试组合根负责生成每次用例快照,reload 后新调用读取新 generation,旧调用不漂移。 +- Bangumi 模块作为长生命周期样板,在 `init_module()` / `on_config_changed()` 时更新不可变网络快照, + `test()` 不再逐次读取全局代理。该模式先验证后推广,不批量改写 169 个存量调用方。 +- 219 个架构、配置、Agent 安全、整理重试、Module reload 专项测试通过,Pylint 10/10;依赖基线 + 仅把 `app.application.history -> app.runtime.config` 替换为窄配置端口边,禁止边不变。 + ### 阶段 4:把动态模块和事件变成可演进契约 #### ARCH-240:Module Contract V2 diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 668a723e8..9ab4b4960 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -20,6 +20,7 @@ BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" DEPENDENCY_BASELINE_PATH = BASELINE_ROOT / "dependency-baseline.json" RUNTIME_BASELINE_PATH = BASELINE_ROOT / "runtime-contract-baseline.json" TRANSACTION_BASELINE_PATH = BASELINE_ROOT / "transaction-debt-baseline.json" +CONFIGURATION_BASELINE_PATH = BASELINE_ROOT / "configuration-debt-baseline.json" PLUGIN_BASELINE_PATH = BASELINE_ROOT / "official-plugin-baseline.json" PLUGIN_HOOKS = ( "get_actions", @@ -58,6 +59,12 @@ SESSION_FACTORY_NAMES = { "get_session_factory", } +CONFIGURATION_EXCLUDED_ROOTS = ( + APP_ROOT / "plugins", + APP_ROOT / "sdk", + APP_ROOT / "runtime" / "compat", +) + def discover_modules() -> dict[str, Path]: """返回宿主 Python 模块与源码路径,排除运行时插件副本。""" @@ -78,6 +85,58 @@ def parse_source(path: Path) -> ast.Module: return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) +def collect_configuration_debt_baseline() -> dict[str, Any]: + """收集宿主 canonical 代码直接读取 settings 和构造数据库配置适配器的债务。""" + settings_files: list[str] = [] + oper_calls: list[dict[str, Any]] = [] + for path in sorted(APP_ROOT.rglob("*.py")): + if any(path.is_relative_to(root) for root in CONFIGURATION_EXCLUDED_ROOTS): + continue + relative = path.relative_to(PROJECT_ROOT).as_posix() + tree = parse_source(path) + direct_oper_names: set[str] = set() + imports_settings = False + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.module == "app.runtime.config" and any( + alias.name == "settings" for alias in node.names + ): + imports_settings = True + if node.module == "app.db.oper.systemconfig": + direct_oper_names.update( + alias.asname or alias.name + for alias in node.names + if alias.name == "SystemConfigOper" + ) + if imports_settings: + settings_files.append(relative) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id in direct_oper_names: + oper_calls.append({"file": relative, "name": node.func.id}) + return { + "schema_version": 1, + "scope": { + "root": "app", + "excluded": [ + "app/plugins", + "app/sdk", + "app/runtime/compat", + ], + }, + "settings_imports": { + "count": len(settings_files), + "files": settings_files, + }, + "system_config_oper_constructions": { + "count": len(oper_calls), + "calls": oper_calls, + }, + } + + def iter_import_candidates( module_name: str, path: Path, @@ -1052,6 +1111,37 @@ def transaction_ratchet_matches( ) +def configuration_ratchet_matches( + expected: dict[str, Any], + actual: dict[str, Any], +) -> bool: + """配置债务只允许删除既有文件或构造点,不允许新增直接依赖。""" + if expected.get("schema_version") != actual.get("schema_version"): + return False + if expected.get("scope") != actual.get("scope"): + return False + sections = ( + ("settings_imports", "files"), + ("system_config_oper_constructions", "calls"), + ) + for section, entries_key in sections: + expected_section = expected.get(section, {}) + actual_section = actual.get(section, {}) + if actual_section.get("count", 0) > expected_section.get("count", 0): + return False + expected_entries = { + json.dumps(item, ensure_ascii=False, sort_keys=True) + for item in expected_section.get(entries_key, []) + } + actual_entries = { + json.dumps(item, ensure_ascii=False, sort_keys=True) + for item in actual_section.get(entries_key, []) + } + if not actual_entries.issubset(expected_entries): + return False + return True + + def _compare_semantic_values( expected: Any, actual: Any, @@ -1112,6 +1202,8 @@ def build_comparison_report(path: Path, actual: dict[str, Any]) -> dict[str, Any semantic_match = expected_semantic == actual_semantic if path.name == TRANSACTION_BASELINE_PATH.name: semantic_match = transaction_ratchet_matches(expected, actual) + elif path.name == CONFIGURATION_BASELINE_PATH.name: + semantic_match = configuration_ratchet_matches(expected, actual) return { "baseline": str(_display_path(path)), "semantic_match": semantic_match, @@ -1144,6 +1236,21 @@ def check_json( file=sys.stderr, ) return False + if path.name == CONFIGURATION_BASELINE_PATH.name: + if configuration_ratchet_matches(expected, actual): + if expected != actual: + print( + "配置债务已下降;门禁继续通过,可在本任务提交中显式运行 " + "scripts/architecture/baseline.py --write-host 固化新低水位", + file=sys.stderr, + ) + return True + print( + f"配置债务出现新增:{_display_path(path)};" + "宿主直接 settings 导入或 SystemConfigOper 构造不得增长", + file=sys.stderr, + ) + return False expected_semantic = semantic_baseline(path, expected) actual_semantic = semantic_baseline(path, actual) if expected_semantic == actual_semantic: @@ -1225,6 +1332,7 @@ def main(argv: Optional[list[str]] = None) -> int: (DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()), (RUNTIME_BASELINE_PATH, collect_runtime_baseline()), (TRANSACTION_BASELINE_PATH, collect_transaction_debt_baseline()), + (CONFIGURATION_BASELINE_PATH, collect_configuration_debt_baseline()), ] write_hint = "--write-host" else: diff --git a/tests/conftest.py b/tests/conftest.py index a64639ea9..877e8ecc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,13 @@ def configure_plugin_system_services(): decode_access_token, ) from app.api.data import configure_api_data_ports - from app.application.configuration import SystemConfigService, configure_system_config + from app.application.configuration import ( + SystemConfigService, + TransferRetryConfig, + configure_system_config, + configure_transfer_retry_config, + ) + from app.runtime.config import settings from app.application.service import configure_service_directory from app.db.session import ( SessionFactory, @@ -41,6 +47,11 @@ def configure_plugin_system_services(): configure_token_codec(create_access_token, decode_access_token) configure_system_config(SystemConfigService(repository=SystemConfigOper())) + configure_transfer_retry_config( + lambda: TransferRetryConfig( + max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES, + ) + ) from app.application.chain.data import configure_chain_data_ports from app.application.subscription.write import configure_subscribe_writer from app.application.plugin.runtime import configure_plugin_runtime diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json new file mode 100644 index 000000000..599c933d5 --- /dev/null +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -0,0 +1,250 @@ +{ + "schema_version": 1, + "scope": { + "excluded": [ + "app/plugins", + "app/sdk", + "app/runtime/compat" + ], + "root": "app" + }, + "settings_imports": { + "count": 169, + "files": [ + "app/adapters/cache/backends.py", + "app/adapters/cache/redis.py", + "app/adapters/external/cookiecloud.py", + "app/adapters/external/market.py", + "app/adapters/external/ocr.py", + "app/adapters/external/server.py", + "app/adapters/network/browser.py", + "app/adapters/network/doh.py", + "app/adapters/system/fsproxy.py", + "app/adapters/system/plugin/dependency.py", + "app/adapters/system/plugin/package.py", + "app/adapters/system/resource.py", + "app/adapters/system/rust.py", + "app/adapters/web/security/access.py", + "app/agent/capabilities/adapter.py", + "app/agent/llm/capability.py", + "app/agent/llm/helper.py", + "app/agent/llm/provider.py", + "app/agent/memory/__init__.py", + "app/agent/orchestrator.py", + "app/agent/prompt/__init__.py", + "app/agent/runtime.py", + "app/agent/skills/registry.py", + "app/agent/tools/base.py", + "app/agent/tools/impl/_plugin_tool_utils.py", + "app/agent/tools/impl/_terminal_session.py", + "app/agent/tools/impl/add_download_tasks.py", + "app/agent/tools/impl/create_agent_task.py", + "app/agent/tools/impl/query_agent_tasks.py", + "app/agent/tools/impl/query_system_settings.py", + "app/agent/tools/impl/recognize_media.py", + "app/agent/tools/impl/scrape_metadata.py", + "app/agent/tools/impl/search_web.py", + "app/agent/tools/impl/send_voice_message.py", + "app/agent/tools/impl/update_agent_task.py", + "app/agent/tools/impl/update_system_settings.py", + "app/api/endpoints/agent.py", + "app/api/endpoints/anthropic.py", + "app/api/endpoints/dashboard.py", + "app/api/endpoints/history.py", + "app/api/endpoints/login.py", + "app/api/endpoints/media.py", + "app/api/endpoints/message.py", + "app/api/endpoints/openai.py", + "app/api/endpoints/plugin.py", + "app/api/endpoints/storage.py", + "app/api/endpoints/subscribe.py", + "app/api/endpoints/system.py", + "app/api/endpoints/tmdb.py", + "app/api/endpoints/torrent.py", + "app/api/endpoints/transfer.py", + "app/api/servcookie.py", + "app/application/formatting.py", + "app/application/image.py", + "app/application/maintenance.py", + "app/application/rss.py", + "app/application/security/auth.py", + "app/application/security/passkey.py", + "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", + "app/chain/storage.py", + "app/chain/subscribe.py", + "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", + "app/db/session.py", + "app/doctor/checks.py", + "app/doctor/runner.py", + "app/factory.py", + "app/main.py", + "app/modules/acoustid/__init__.py", + "app/modules/anilist/__init__.py", + "app/modules/anilist/anilist.py", + "app/modules/bangumi/__init__.py", + "app/modules/bangumi/bangumi.py", + "app/modules/discord/discord.py", + "app/modules/douban/__init__.py", + "app/modules/douban/apiv2.py", + "app/modules/emby/emby.py", + "app/modules/fanart/__init__.py", + "app/modules/feishu/feishu.py", + "app/modules/filemanager/module.py", + "app/modules/filemanager/storages/alipan.py", + "app/modules/filemanager/storages/alist.py", + "app/modules/filemanager/storages/local.py", + "app/modules/filemanager/storages/rclone.py", + "app/modules/filemanager/storages/smb.py", + "app/modules/filemanager/storages/u115.py", + "app/modules/filemanager/transhandler.py", + "app/modules/indexer/parser/__init__.py", + "app/modules/indexer/parser/rousi.py", + "app/modules/indexer/spider/__init__.py", + "app/modules/indexer/spider/haidan.py", + "app/modules/indexer/spider/hddolby.py", + "app/modules/indexer/spider/mtorrent.py", + "app/modules/indexer/spider/rousi.py", + "app/modules/indexer/spider/sunnypt.py", + "app/modules/indexer/spider/tnode.py", + "app/modules/indexer/spider/torrentleech.py", + "app/modules/indexer/spider/yema.py", + "app/modules/jellyfin/jellyfin.py", + "app/modules/listenbrainz/__init__.py", + "app/modules/lrclib/__init__.py", + "app/modules/musicbrainz/__init__.py", + "app/modules/musicbrainz/music_cache.py", + "app/modules/postgresql/__init__.py", + "app/modules/qbittorrent/__init__.py", + "app/modules/qqbot/qqbot.py", + "app/modules/redis/__init__.py", + "app/modules/rtorrent/__init__.py", + "app/modules/slack/slack.py", + "app/modules/subtitle/__init__.py", + "app/modules/telegram/telegram.py", + "app/modules/theaudiodb/__init__.py", + "app/modules/themoviedb/__init__.py", + "app/modules/themoviedb/category.py", + "app/modules/themoviedb/scraper.py", + "app/modules/themoviedb/tmdb_cache.py", + "app/modules/themoviedb/tmdbapi.py", + "app/modules/themoviedb/tmdbv3api/objs/discover.py", + "app/modules/themoviedb/tmdbv3api/tmdb.py", + "app/modules/thetvdb/__init__.py", + "app/modules/thetvdb/tvdb_v4_official.py", + "app/modules/transmission/__init__.py", + "app/modules/trimemedia/api.py", + "app/modules/webpush/__init__.py", + "app/modules/wechat/wechatbot.py", + "app/modules/wechatclawbot/wechatclawbot.py", + "app/monitor/dispatcher.py", + "app/monitor/monitor.py", + "app/monitor/snapshot.py", + "app/monitor/syslimits.py", + "app/monitor/watcher.py", + "app/runtime/extensions/host_module_adapter.py", + "app/runtime/extensions/module_manager.py", + "app/runtime/extensions/plugin/catalog.py", + "app/runtime/extensions/plugin_manager.py", + "app/runtime/state.py", + "app/runtime/thread.py", + "app/scheduler.py", + "app/startup/agent_initializer.py", + "app/startup/database.py", + "app/startup/database_initializer.py", + "app/startup/domain_initializer.py", + "app/startup/lifecycle/__init__.py", + "app/startup/modules_initializer.py", + "app/startup/plugins_initializer.py", + "app/startup/routers_initializer.py", + "app/workflow/actions/add_subscribe.py", + "app/workflow/actions/fetch_medias.py", + "app/workflow/actions/fetch_rss.py", + "app/workflow/actions/scan_file.py", + "app/workflow/actions/send_message.py" + ] + }, + "system_config_oper_constructions": { + "calls": [ + { + "file": "app/scheduler.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/modules_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + }, + { + "file": "app/startup/plugins_initializer.py", + "name": "SystemConfigOper" + } + ], + "count": 15 + } +} diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d6e5f1a64..3ee2c6a6a 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": 6203, - "edge_sha256": "75ac7a20854abb707ea6c851326a183873a64e93f67053ffcd79ca5b01a105f6", + "edge_count": 6204, + "edge_sha256": "9a988284502bb26fae5266321765fbf0e10b9fe608fd448c6868e67a824ea862", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -2464,6 +2464,8 @@ "app.application.formatting -> app.schemas", "app.application.formatting -> app.schemas.transfer", "app.application.formatting -> app.schemas.workflow", + "app.application.history -> app.application", + "app.application.history -> app.application.configuration", "app.application.history -> app.domain", "app.application.history -> app.domain.context", "app.application.history -> app.domain.meta", @@ -2473,7 +2475,6 @@ "app.application.history -> app.foundation.text", "app.application.history -> app.runtime", "app.application.history -> app.runtime.cache", - "app.application.history -> app.runtime.config", "app.application.history -> app.runtime.log", "app.application.history -> app.schemas", "app.application.history -> app.schemas.history", diff --git a/tests/test_agent_system_settings_tools.py b/tests/test_agent_system_settings_tools.py index a22159a4d..d94a5bc4f 100644 --- a/tests/test_agent_system_settings_tools.py +++ b/tests/test_agent_system_settings_tools.py @@ -16,6 +16,21 @@ from app.schemas.types import SystemConfigKey class TestAgentSystemSettingsTools(unittest.TestCase): + def test_query_system_settings_accepts_injected_reader(self): + """Agent 配置工具通过窄端口读取授权字段,无需自行构造数据库 Oper。""" + reader = MagicMock() + reader.get.return_value = [{"name": "qb", "enabled": True}] + tool = QuerySystemSettingsTool( + session_id="session-injected", + user_id="10001", + system_config=reader, + ) + + payload = json.loads(asyncio.run(tool.run(setting_key="Downloaders"))) + + self.assertTrue(payload["success"]) + reader.get.assert_called_once_with(SystemConfigKey.Downloaders) + def test_query_system_settings_returns_exact_systemconfig_value(self): tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001") diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py index 2ddd3175b..7069fdfb1 100644 --- a/tests/test_architecture_baseline_cli.py +++ b/tests/test_architecture_baseline_cli.py @@ -67,6 +67,28 @@ def _transaction_sample(methods: list[dict[str, str]]) -> dict: } +def _configuration_sample( + settings_files: list[str], + oper_calls: list[dict[str, str]], +) -> dict: + """构造最小配置债务 fixture,供单向 ratchet 行为测试。""" + return { + "schema_version": 1, + "scope": { + "root": "app", + "excluded": ["app/plugins", "app/sdk", "app/runtime/compat"], + }, + "settings_imports": { + "count": len(settings_files), + "files": settings_files, + }, + "system_config_oper_constructions": { + "count": len(oper_calls), + "calls": oper_calls, + }, + } + + def test_architecture_legacy_action_requires_scope(capsys): """旧操作未明确宿主或插件范围时必须拒绝执行。""" with pytest.raises(SystemExit) as error: @@ -259,6 +281,29 @@ def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None: ) +def test_configuration_ratchet_allows_removal_but_rejects_new_access() -> None: + """配置债务低水位允许下降,但新增或换位置的直接访问必须失败。""" + existing_call = {"file": "app/startup/demo.py", "name": "SystemConfigOper"} + new_call = {"file": "app/application/demo.py", "name": "SystemConfigOper"} + expected = _configuration_sample(["app/application/old.py"], [existing_call]) + + assert architecture_baseline.configuration_ratchet_matches( + expected, + _configuration_sample([], []), + ) + assert not architecture_baseline.configuration_ratchet_matches( + expected, + _configuration_sample( + ["app/application/old.py", "app/application/new.py"], + [existing_call], + ), + ) + assert not architecture_baseline.configuration_ratchet_matches( + expected, + _configuration_sample(["app/application/old.py"], [new_call]), + ) + + def test_architecture_write_host_only_updates_host_files( tmp_path: Path, monkeypatch, diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 8d21c88c1..18b6eeb23 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -17,6 +17,7 @@ def test_architecture_contract_baselines_match_current_source(): BASELINE_ROOT / "dependency-baseline.json", BASELINE_ROOT / "runtime-contract-baseline.json", BASELINE_ROOT / "transaction-debt-baseline.json", + BASELINE_ROOT / "configuration-debt-baseline.json", ) contents_before = { path: path.read_bytes() @@ -132,6 +133,25 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None: assert baseline["oper_session_factories"] == {"count": 0, "calls": []} +def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None: + """配置债务基线必须排除插件兼容面,并冻结两个可下降的直接访问集合。""" + baseline_path = BASELINE_ROOT / "configuration-debt-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + + assert baseline["schema_version"] == 1 + assert baseline["scope"]["excluded"] == [ + "app/plugins", + "app/sdk", + "app/runtime/compat", + ] + assert baseline["settings_imports"]["count"] == len( + baseline["settings_imports"]["files"] + ) + assert baseline["system_config_oper_constructions"]["count"] == len( + baseline["system_config_oper_constructions"]["calls"] + ) + + def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources(): """非功能基线必须同时记录正常/安全模式和隔离资源增量。""" baseline_path = BASELINE_ROOT / "startup-performance-baseline.json" diff --git a/tests/test_bangumi_module.py b/tests/test_bangumi_module.py index 2d785cefa..859600e36 100644 --- a/tests/test_bangumi_module.py +++ b/tests/test_bangumi_module.py @@ -1,6 +1,8 @@ import asyncio +from unittest.mock import MagicMock, patch from app.modules.bangumi import BangumiModule +from app.runtime.config import settings class _FakeBangumiApi: @@ -68,3 +70,22 @@ def test_async_bangumi_person_detail_normalizes_numeric_birthday(): person = asyncio.run(module.async_bangumi_person_detail(1002)) assert person.birthday == "19" + + +def test_bangumi_test_uses_generation_snapshot_until_reload(monkeypatch): + """长生命周期模块应在 init/reload 时换快照,而不是每次调用读取全局配置。""" + module = BangumiModule() + monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy") + module.init_module() + old_proxy = settings.PROXY + monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy") + new_proxy = settings.PROXY + + with patch("app.modules.bangumi.RequestUtils") as request_utils: + request_utils.return_value.get_res.return_value = MagicMock(status_code=200) + module.test() + assert request_utils.call_args.kwargs["proxies"] == old_proxy + + module.on_config_changed() + module.test() + assert request_utils.call_args.kwargs["proxies"] == new_proxy diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py new file mode 100644 index 000000000..91b0f6731 --- /dev/null +++ b/tests/test_configuration_ports.py @@ -0,0 +1,47 @@ +"""配置快照与窄读写端口测试。""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from app.application.configuration import ( + SystemConfigService, + TransferRetryConfig, + configure_transfer_retry_config, + get_transfer_retry_config, +) + + +def test_system_config_service_supports_separate_reader_and_writer() -> None: + """应用服务可以分别注入只读与写入适配器。""" + reader = MagicMock() + reader.get.return_value = "old" + reader.async_get = AsyncMock(return_value="async-old") + writer = MagicMock() + writer.set.return_value = True + writer.async_set = AsyncMock(return_value=True) + service = SystemConfigService(reader=reader, writer=writer) + + assert service.get("key") == "old" + assert service.set("key", "new") is True + assert asyncio.run(service.async_get("key")) == "async-old" + assert asyncio.run(service.async_set("key", "new")) is True + service.delete("key") + + reader.get.assert_called_once_with("key") + writer.set.assert_called_once_with("key", "new") + writer.delete.assert_called_once_with("key") + + +def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None: + """配置工厂在每次用例入口创建新快照,旧快照不受 reload 后状态影响。""" + state = {"value": 2} + configure_transfer_retry_config( + lambda: TransferRetryConfig(max_failed_retries=state["value"]) + ) + + before_reload = get_transfer_retry_config() + state["value"] = 4 + after_reload = get_transfer_retry_config() + + assert before_reload.max_failed_retries == 2 + assert after_reload.max_failed_retries == 4