refactor: add configuration dependency ratchet

This commit is contained in:
jxxghp
2026-08-21 21:20:56 +08:00
parent 9df599f502
commit ff0ce8d1c1
16 changed files with 671 additions and 34 deletions
+23 -4
View File
@@ -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:
+27 -6
View File
@@ -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,
)
+57 -13
View File
@@ -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()
+3 -3
View File
@@ -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):
+12 -2
View File
@@ -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:
+11 -1
View File
@@ -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(
@@ -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-201203)、阶段 1ARCH-210212)、阶段 2ARCH-220222)与 ARCH-230231 已完成,后续任务按 ID 独立提交和回滚
> 实施进度:阶段 0(ARCH-201203)、阶段 1ARCH-210212)、阶段 2ARCH-220222)与阶段 3ARCH-230232已完成,后续任务按 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-240Module Contract V2
+108
View File
@@ -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:
+12 -1
View File
@@ -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
@@ -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
}
}
+4 -3
View File
@@ -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",
+15
View File
@@ -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")
+45
View File
@@ -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,
@@ -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"
+21
View File
@@ -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
+47
View File
@@ -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