fix(architecture): close canonical search type gate

This commit is contained in:
jxxghp
2026-08-30 02:48:42 +08:00
parent c960a0184d
commit 4575b11d84
13 changed files with 504 additions and 162 deletions

View File

@@ -11,6 +11,7 @@ from app.adapters.web.security.access import verify_resource_token, verify_token
from app.api.response import ResponseAPIRouter
from app.application.security.url import SecurityUtils
from app.chain.search.facade import SearchChain
from app.domain.context import Context
from app.domain.media import normalize_music_type
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
@@ -128,6 +129,11 @@ def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]:
return [_serialize_signed_subtitle_result(subtitle) for subtitle in subtitles]
def _serialize_context_results(contexts: list[Context]) -> list[dict[str, Any]]:
"""序列化 canonical 搜索上下文,集中保持传输层投影合同。"""
return [context.to_dict() for context in contexts]
def _sign_subtitle_search_event(event: dict) -> dict:
"""
签名字幕搜索流事件中的下载链接。
@@ -339,7 +345,7 @@ async def search_latest(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
查询搜索结果
"""
torrents = await SearchChain().async_last_search_results() or []
return [torrent.to_dict() for torrent in torrents]
return _serialize_context_results(torrents)
@router.get(
@@ -354,16 +360,18 @@ async def search_latest_context(_: _SchemaTokenPayload = Depends(verify_token))
search_chain = SearchChain()
params = await search_chain.async_last_search_params() or {}
if params.get("result_type") == "subtitle":
results = await search_chain.async_last_subtitle_search_results() or []
subtitle_results = (
await search_chain.async_last_subtitle_search_results() or []
)
serialized_results = _serialize_signed_subtitle_results(subtitle_results)
else:
results = await search_chain.async_last_search_results() or []
context_results = await search_chain.async_last_search_results() or []
serialized_results = _serialize_context_results(context_results)
return _SchemaResponse(
success=True,
data={
"params": params,
"results": _serialize_signed_subtitle_results(results)
if params.get("result_type") == "subtitle"
else [result.to_dict() for result in results],
"results": serialized_results,
},
)
@@ -467,7 +475,7 @@ async def search_by_id(
if not torrents:
return _SchemaResponse(success=False, message="未搜索到任何资源")
return _SchemaResponse(
success=True, data=[torrent.to_dict() for torrent in torrents]
success=True, data=_serialize_context_results(torrents)
)
@@ -496,7 +504,7 @@ async def search_by_title_stream(
"""
event_source = SearchChain().async_search_by_title_stream(
title=keyword,
title=keyword or "",
page=page,
sites=_parse_site_list(sites),
cache_local=True,
@@ -525,7 +533,7 @@ async def search_by_title(
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = await SearchChain().async_search_by_title(
title=keyword,
title=keyword or "",
page=page,
sites=_parse_site_list(sites),
cache_local=True,
@@ -534,7 +542,7 @@ async def search_by_title(
if not torrents:
return _SchemaResponse(success=False, message="未搜索到任何资源")
return _SchemaResponse(
success=True, data=[torrent.to_dict() for torrent in torrents]
success=True, data=_serialize_context_results(torrents)
)
@@ -562,7 +570,10 @@ async def search_subtitle_by_title_stream(
"""
event_source = SearchChain().async_search_subtitles_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
title=keyword or "",
page=page,
sites=_parse_site_list(sites),
cache_local=True,
)
return StreamingResponse(
_stream_search_events(
@@ -589,7 +600,10 @@ async def search_subtitle_by_title(
根据名称模糊搜索站点字幕资源,支持分页。
"""
subtitles = await SearchChain().async_search_subtitles_by_title(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
title=keyword or "",
page=page,
sites=_parse_site_list(sites),
cache_local=True,
)
if not subtitles:
return _SchemaResponse(success=False, message="未搜索到任何字幕")

View File

@@ -53,6 +53,7 @@ from app.application.site.sites import SitesHelper # pylint: disable=import-err
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.search.facade import SearchChain
from app.domain.context import TorrentInfo as _DomainTorrentInfo
from app.domain.metainfo import MetaInfo
from app.foundation.crypto import HashUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
@@ -82,7 +83,6 @@ from app.schemas.system import RuleTestData as _SchemaRuleTestData
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData
from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import EventType, SystemConfigKey
from app.startup.composition.context import HostRuntime
@@ -1171,9 +1171,9 @@ def ruletest(
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
"""
metainfo = MetaInfo(title=title, subtitle=subtitle)
torrent = _SchemaTorrentInfo(
torrent = _DomainTorrentInfo(
title=title,
description=subtitle,
description=subtitle or "",
)
# 查询规则组详情
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
@@ -1184,7 +1184,7 @@ def ruletest(
"rulegroup": rulegroup.model_dump() if rulegroup else None,
"meta_info": metainfo.to_dict(),
"media_info": None,
"torrent_info": torrent.model_dump(),
"torrent_info": torrent.to_dict(),
"priority": None,
"matched": False,
}
@@ -1210,7 +1210,9 @@ def ruletest(
# 过滤
result = SearchChain().filter_torrents(
rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info
rule_groups=[rulegroup.name or rulegroup_name],
torrent_list=[torrent],
mediainfo=media_info,
)
if not result:
return _SchemaResponse(
@@ -1222,7 +1224,7 @@ def ruletest(
{
"matched": True,
"priority": 100 - result[0].pri_order + 1,
"torrent_info": result[0].model_dump(),
"torrent_info": result[0].to_dict(),
}
)
return _SchemaResponse(

View File

@@ -106,7 +106,7 @@ def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], _json_snapshot({
"hash": payload.get("hash"),
"context": (
cast(Callable[[], dict[str, Any]], context.to_dict)()
context.to_dict()
if isinstance(context, Context)
else context
),

View File

@@ -1,6 +1,6 @@
"""搜索处理链稳定 Facade。"""
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from typing import Any, Optional, TypeVar, cast
from app.chain.base import ChainBase
@@ -15,10 +15,10 @@ from app.chain.search.result import SearchResultOwner
from app.chain.search.site import SearchSiteOwner
from app.chain.search.subtitle import SearchSubtitleOwner
from app.chain.search.title import SearchTitleOwner
from app.domain.context import Context, MediaInfo
from app.domain.context import Context, MediaInfo, SubtitleInfo
from app.runtime.events import Event, eventmanager
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import EventType, MediaType
from app.schemas.types import EventType, MediaSource, MediaType
_PUBLIC_MODULE = "app.chain.search"
_Handler = TypeVar("_Handler", bound=Callable[..., Any])
@@ -56,12 +56,41 @@ class SearchChain(ChainBase):
)
_should_continue_search_pages = SearchPaginationOwner._should_continue_search_pages
_should_continue_subtitle_search_pages = staticmethod(SearchPaginationOwner._should_continue_subtitle_search_pages)
is_ai_recommend_enabled = SearchRecommendOwner.is_ai_recommend_enabled
@property
def is_ai_recommend_enabled(self) -> bool:
"""经推荐 owner 的原始描述符读取启用状态,避免 Facade 属性递归。"""
descriptor = cast(
property,
SearchRecommendOwner.__dict__["is_ai_recommend_enabled"],
)
return cast(bool, descriptor.__get__(self, type(self)))
_calculate_recommend_request_hash = staticmethod(SearchRecommendOwner._calculate_recommend_request_hash)
_build_ai_recommend_status = SearchRecommendOwner._build_ai_recommend_status
get_current_recommend_status_only = SearchRecommendOwner.get_current_recommend_status_only
get_recommend_status = SearchRecommendOwner.get_recommend_status
cancel_ai_recommend = SearchRecommendOwner.cancel_ai_recommend
def get_current_recommend_status_only(self) -> dict[str, Any]:
"""返回当前推荐状态,不改变推荐请求代际。"""
return SearchRecommendOwner.get_current_recommend_status_only(
cast(SearchRecommendOwner, self)
)
def get_recommend_status(
self,
filtered_indices: Optional[list[int]],
search_results_count: int,
) -> dict[str, Any]:
"""按当前筛选条件返回推荐状态。"""
return SearchRecommendOwner.get_recommend_status(
cast(SearchRecommendOwner, self),
filtered_indices=filtered_indices,
search_results_count=search_results_count,
)
def cancel_ai_recommend(self) -> None:
"""委托推荐 owner 取消当前任务并清理推荐缓存。"""
SearchRecommendOwner.cancel_ai_recommend(
cast(SearchRecommendOwner, self)
)
_build_search_keyword = staticmethod(SearchPlanOwner._build_search_keyword)
_media_recognize_kwargs = staticmethod(SearchPlanOwner._media_recognize_kwargs)
_stringify_sites = staticmethod(SearchCacheOwner._stringify_sites)
@@ -72,14 +101,56 @@ class SearchChain(ChainBase):
_async_save_subtitles = SearchCacheOwner._async_save_subtitles
save_last_search_params = SearchCacheOwner.save_last_search_params
async_save_last_search_params = SearchCacheOwner.async_save_last_search_params
last_search_params = SearchCacheOwner.last_search_params
async_last_search_params = SearchCacheOwner.async_last_search_params
def last_search_params(self) -> Optional[dict[str, str]]:
"""返回最近一次搜索参数。"""
return SearchCacheOwner.last_search_params(cast(SearchCacheOwner, self))
async def async_last_search_params(self) -> Optional[dict[str, str]]:
"""异步返回最近一次搜索参数。"""
return await SearchCacheOwner.async_last_search_params(
cast(SearchCacheOwner, self)
)
_normalize_ai_indices = staticmethod(SearchRecommendOwner._normalize_ai_indices)
_extract_recommend_items = staticmethod(SearchRecommendOwner._extract_recommend_items)
_restore_original_indices = staticmethod(SearchRecommendOwner._restore_original_indices)
_invoke_recommend_llm = staticmethod(SearchRecommendOwner._invoke_recommend_llm)
start_recommend_task = SearchRecommendOwner.start_recommend_task
search_by_id = SearchMediaOwner.search_by_id
def start_recommend_task(
self,
filtered_indices: Optional[list[int]],
search_results_count: int,
results: list[Any],
) -> None:
"""委托推荐 owner 启动受代际保护的推荐任务。"""
SearchRecommendOwner.start_recommend_task(
cast(SearchRecommendOwner, self),
filtered_indices=filtered_indices,
search_results_count=search_results_count,
results=results,
)
def search_by_id(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
season: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
music_type: Optional[str] = None,
) -> list[Context]:
"""通过稳定 Facade 执行同步精确媒体搜索。"""
return SearchMediaOwner.search_by_id(
cast(SearchMediaOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
cache_local=cache_local,
music_type=music_type,
)
def search_by_title(
self,
@@ -101,19 +172,192 @@ class SearchChain(ChainBase):
rule_groups=rule_groups,
)
last_search_results = SearchCacheOwner.last_search_results
async_last_search_results = SearchCacheOwner.async_last_search_results
async_last_subtitle_search_results = SearchCacheOwner.async_last_subtitle_search_results
async_search_subtitles_by_title = SearchSubtitleOwner.async_search_subtitles_by_title
async_search_subtitles_by_title_stream = SearchSubtitleOwner.async_search_subtitles_by_title_stream
async_search_subtitles_by_id = SearchSubtitleOwner.async_search_subtitles_by_id
async_search_subtitles_by_id_stream = SearchSubtitleOwner.async_search_subtitles_by_id_stream
async_search_by_id = SearchMediaOwner.async_search_by_id
async_search_by_title = SearchTitleOwner.async_search_by_title
async_search_by_title_stream = SearchTitleOwner.async_search_by_title_stream
def last_search_results(self) -> Optional[list[Context]]:
"""返回最近一次资源搜索结果。"""
return SearchCacheOwner.last_search_results(cast(SearchCacheOwner, self))
async def async_last_search_results(self) -> Optional[list[Context]]:
"""异步返回最近一次资源搜索结果。"""
return await SearchCacheOwner.async_last_search_results(
cast(SearchCacheOwner, self)
)
async def async_last_subtitle_search_results(
self,
) -> Optional[list[SubtitleInfo]]:
"""异步返回最近一次字幕搜索结果。"""
return await SearchCacheOwner.async_last_subtitle_search_results(
cast(SearchCacheOwner, self)
)
async def async_search_subtitles_by_title(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
) -> list[SubtitleInfo]:
"""通过稳定 Facade 执行异步标题字幕搜索。"""
return await SearchSubtitleOwner.async_search_subtitles_by_title(
cast(SearchSubtitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
)
async def async_search_subtitles_by_title_stream(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
) -> AsyncIterator[dict[str, Any]]:
"""通过稳定 Facade 流式执行标题字幕搜索。"""
async for event in SearchSubtitleOwner.async_search_subtitles_by_title_stream(
cast(SearchSubtitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
):
yield event
async def async_search_subtitles_by_id(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
) -> list[SubtitleInfo]:
"""通过稳定 Facade 执行异步精确字幕搜索。"""
return await SearchSubtitleOwner.async_search_subtitles_by_id(
cast(SearchSubtitleOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
season=season,
episode=episode,
sites=sites,
cache_local=cache_local,
)
async def async_search_subtitles_by_id_stream(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
) -> AsyncIterator[dict[str, Any]]:
"""通过稳定 Facade 流式执行精确字幕搜索。"""
async for event in SearchSubtitleOwner.async_search_subtitles_by_id_stream(
cast(SearchSubtitleOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
season=season,
episode=episode,
sites=sites,
cache_local=cache_local,
):
yield event
async def async_search_by_id(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
season: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
music_type: Optional[str] = None,
) -> list[Context]:
"""通过稳定 Facade 执行异步精确媒体搜索。"""
return await SearchMediaOwner.async_search_by_id(
cast(SearchMediaOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
cache_local=cache_local,
music_type=music_type,
)
async def async_search_by_title(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
mtype: Optional[MediaType] = None,
rule_groups: Optional[list[str]] = None,
) -> list[Context]:
"""通过稳定 Facade 执行异步标题资源搜索。"""
return await SearchTitleOwner.async_search_by_title(
cast(SearchTitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
mtype=mtype,
rule_groups=rule_groups,
)
async def async_search_by_title_stream(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
mtype: Optional[MediaType] = None,
rule_groups: Optional[list[str]] = None,
) -> AsyncIterator[dict[str, Any]]:
"""通过稳定 Facade 流式执行标题资源搜索。"""
async for event in SearchTitleOwner.async_search_by_title_stream(
cast(SearchTitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
mtype=mtype,
rule_groups=rule_groups,
):
yield event
_build_title_search_meta = staticmethod(SearchTitleOwner._build_title_search_meta)
_filter_title_search_torrents = SearchTitleOwner._filter_title_search_torrents
async_search_by_id_stream = SearchMediaOwner.async_search_by_id_stream
async def async_search_by_id_stream(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
season: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
music_type: Optional[str] = None,
) -> AsyncIterator[dict[str, Any]]:
"""通过稳定 Facade 流式执行精确媒体搜索。"""
async for event in SearchMediaOwner.async_search_by_id_stream(
cast(SearchMediaOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
cache_local=cache_local,
music_type=music_type,
):
yield event
_prepare_params = staticmethod(SearchPlanOwner._prepare_params)
_copy_media_input = staticmethod(SearchPlanOwner._copy_media_input)
_parse_result = SearchResultOwner._parse_result
@@ -148,8 +392,54 @@ class SearchChain(ChainBase):
filter_params=filter_params,
)
async_process = SearchMediaOwner.async_process
async_process_stream = SearchMediaOwner.async_process_stream
async def async_process(
self,
mediainfo: MediaInfo,
keyword: Optional[str] = None,
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
sites: Optional[list[int]] = None,
rule_groups: Optional[list[str]] = None,
area: Optional[str] = "title",
custom_words: Optional[list[str]] = None,
filter_params: Optional[dict[str, str]] = None,
) -> list[Context]:
"""通过稳定 Facade 执行异步媒体搜索编排。"""
return await SearchMediaOwner.async_process(
cast(SearchMediaOwner, self),
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists,
sites=sites,
rule_groups=rule_groups,
area=area,
custom_words=custom_words,
filter_params=filter_params,
)
async def async_process_stream(
self,
mediainfo: MediaInfo,
keyword: Optional[str] = None,
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
sites: Optional[list[int]] = None,
rule_groups: Optional[list[str]] = None,
area: Optional[str] = "title",
custom_words: Optional[list[str]] = None,
filter_params: Optional[dict[str, str]] = None,
) -> AsyncIterator[dict[str, Any]]:
"""通过稳定 Facade 流式执行媒体搜索编排。"""
async for event in SearchMediaOwner.async_process_stream(
cast(SearchMediaOwner, self),
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists,
sites=sites,
rule_groups=rule_groups,
area=area,
custom_words=custom_words,
filter_params=filter_params,
):
yield event
_build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes)
_build_subtitle_torrent = staticmethod(SearchSubtitleOwner._build_subtitle_torrent)
_build_subtitle_names = staticmethod(SearchSubtitleOwner._build_subtitle_names)

View File

@@ -1088,7 +1088,7 @@ class TorrentInfo:
print(f"种子发布时间获取失败: {e}")
return 0
def to_dict(self):
def to_dict(self) -> dict[str, Any]:
"""
返回字典
"""
@@ -1179,7 +1179,7 @@ class SubtitleInfo:
for key, value in data.items():
setattr(self, key, value)
def to_dict(self):
def to_dict(self) -> dict[str, Any]:
"""
返回字典。
"""
@@ -1575,7 +1575,7 @@ class Context:
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源。
confirmed_full_coverage: bool = False
def to_dict(self):
def to_dict(self) -> dict[str, Any]:
"""
转换为字典
"""

View File

@@ -1,13 +1,15 @@
import random
import time
from typing import List, Optional
from typing import List, Optional, cast
from pydantic import Field
from app.chain.media import MediaChain
from app.chain.search.facade import SearchChain
from app.domain.context import Context as SearchContext
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.context import Context as WorkflowContext
from app.schemas.types import MediaType
from app.schemas.workflow import ActionContext, ActionParams
from app.workflow.actions import BaseAction
@@ -38,7 +40,7 @@ class FetchTorrentsAction(BaseAction):
def __init__(self, action_id: str):
super().__init__(action_id)
self._torrents = []
self._torrents: list[WorkflowContext] = []
name = "搜索站点资源"
description = "搜索站点种子资源列表"
@@ -56,31 +58,42 @@ class FetchTorrentsAction(BaseAction):
searchchain = SearchChain()
if params.search_type == "keyword":
# 按关键字搜索
torrents = searchchain.search_by_title(title=params.name, sites=params.sites)
torrents = searchchain.search_by_title(
title=params.name or "",
sites=params.sites,
)
for torrent in torrents:
if runtime_stop_state.is_workflow_stopped(workflow_id):
break
if params.year and torrent.meta_info.year != params.year:
meta_info = torrent.meta_info
if params.year and (not meta_info or meta_info.year != params.year):
continue
if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type):
continue
if params.season is not None and torrent.meta_info.begin_season != params.season:
if params.season is not None and (
not meta_info or meta_info.begin_season != params.season
):
continue
# 识别媒体信息
if params.match_media:
if not meta_info:
continue
torrent.media_info = MediaChain().recognize_by_meta(
torrent.meta_info,
meta_info,
obtain_images=False,
)
if not torrent.media_info:
logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息")
continue
self._torrents.append(torrent)
self._torrents.append(self._to_workflow_context(torrent))
else:
# 搜索媒体列表
for media in context.medias:
for media in context.medias or []:
if runtime_stop_state.is_workflow_stopped(workflow_id):
break
if not media.media_source or not media.media_id or not media.type:
logger.warning("媒体身份或类型不完整,跳过资源搜索")
continue
torrents = searchchain.search_by_id(
media_source=media.media_source,
media_id=media.media_id,
@@ -88,7 +101,7 @@ class FetchTorrentsAction(BaseAction):
sites=params.sites,
)
for torrent in torrents:
self._torrents.append(torrent)
self._torrents.append(self._to_workflow_context(torrent))
# 随机休眠 5-30秒
sleep_time = random.randint(5, 30)
@@ -96,8 +109,15 @@ class FetchTorrentsAction(BaseAction):
time.sleep(sleep_time)
if self._torrents:
if context.torrents is None:
context.torrents = []
context.torrents.extend(self._torrents)
logger.info(f"共搜索到 {len(self._torrents)} 条资源")
self.job_done(f"搜索到 {len(self._torrents)} 个资源")
return context
@staticmethod
def _to_workflow_context(context: SearchContext) -> WorkflowContext:
"""把领域搜索结果投影为工作流传输上下文。"""
return cast(WorkflowContext, WorkflowContext.model_validate(context.to_dict()))

View File

@@ -2,9 +2,10 @@
> 审计日期:2026-08-26
>
> 首次审计历史快照:`v3@9053db926d20`;当前交付状态以路线图和生成 fixture 为准
> 首次审计历史快照:`v3@9053db926d20`;历史问题描述用于追溯,当前事实与交付状态以
> 本文“当前架构画像”、路线图和生成 fixture 为准
>
> 文档性质:当前源码的差距清单与分阶段执行说明,不是历史重构结项账本
> 文档性质:原始差距、治理结果和剩余交付边界的持续校准账本
## 1. 结论摘要
@@ -13,20 +14,20 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
基本成立。旧物理兼容目录、Model/Oper 自建 Session/提交和直接跨层 DB 依赖等历史问题已有
硬门禁;门禁还能证明可识别的 TaskRegistry 调用已经声明稳定 owner。
当前主要问题不再是“目录没有分层”,而是以下四类更深层的债务:
截至当前校准,原审计识别的四类结构性债务已经按既定路线收口:
1. **边界有形、合同偏弱**:部分 Application Port 仍是 `Callable[[], Any]`,返回 ORM 或
动态代理;生产路径因此继续依赖无 Session Oper、全局 provider 和隐式构造。
2. **用例编排过度集中**:部分 Chain、Plugin、Agent、LLM 和 API 文件仍同时承担决策、I/O、
状态、生命周期与兼容职责;Scheduler 已完成生产代码拆包和综合验收,其他私有长方法仍可能
处于复杂度门禁盲区。
3. **可靠性声明强于实现**:整理 pending 目前只能做最小重放,尚不满足 ADR 中声明的 E3
状态机语义;部分 commit 后副作用仍存在“业务已提交但调用方收到失败”或进程退出后丢失的窗口。
4. **治理事实源不一致**:架构规则、总览、AST 基线和 CI 语义存在漂移;快照门禁能证明“没有变化”,
但不能自动证明依赖合理、没有新环或所有运行资源都有生命周期 owner。
1. **类型化边界**:Chain/Agent 正式数据入口已使用冻结 DTO 与 typed Port;raw Oper/ORM/`Any`
只留统一 Legacy/Compat,canonical 宿主不再依赖无 Session 写入口或全局数据 locator。
2. **职责拆分**:Transfer、Subscribe、Download、Search、Media、Scheduler、Plugin、Agent/LLM、
Domain 投影和 Startup composition 均已有单一 owner;Facade 只保留稳定 ABI 与显式委托。
3. **可靠性语义**:Transfer E3 状态机、业务 UoW、post-commit 结构化结果、Outbox claim/settlement
fencing 和人工确认边界均已落地,外部 sink 的 at-least-once 责任被明确记录。
4. **治理事实源**:依赖、SCC、Adapter、egress、Event、复杂度、类型、Ruff、生命周期和兼容基线
已进入机器门禁;`app/plugins/**` 始终排除,插件兼容只由 SDK/Compat 承接。
审计时唯一 P0 的主线 `mypy` ratchet 失败已由 `5df388719` 修复并推送。后续架构优化按可靠性、
边界类型化、生命周期、复杂度的顺序推进;不建议再做一次大规模目录搬迁。
当前唯一活动项是 S5-L6 的最终交付闭环:`c960a0184` 已推送,但 exact-head Unit Tests 暴露
canonical `SearchChain` 类型债务。本地修复和低水位更新已经通过,尚需提交推送并取得新精确 head
远端 CI 全绿,才能把 S5-L3 至 S5-L6 及对应总表项晋升为已交付并结束本轮任务。
## 2. 范围与证据边界
@@ -55,30 +56,30 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 层/区域 | 已形成的正确边界 | 当前主要缺口 |
|---|---|---|
| `foundation` | 无状态、无配置、无 I/O 的底层机制 | 当前未发现需要重做的结构性问题 |
| `domain` | 已与 DB、Application、Adapter 保持单向隔离 | `domain/context.py` 等核心对象过大,来源投影与领域模型仍集中 |
| `runtime` | 已有任务、事件、缓存、托管资源和停止合同 | 导入/构造时启动线程;`global_vars` 仍混合停止、主循环和 WebPush 状态 |
| `adapters` | 技术与命名外部生态已有明确目录 | Application/Chain 仍直接识别部分具体 Adapter;例外规则没有形式化 |
| `application` | 已有命令、Port、Outbox 和安全能力,具体 Adapter 直连已清零 | 部分 Port 是 `Any` 服务定位器,仍需继续收窄合同 |
| `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,包根 SCC 已清零 | God object、私有长方法、无参构造和资源物化过重 |
| `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 |
| `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 |
| `domain` | 已与 DB、Application、Adapter 保持单向隔离;四类媒体来源投影已拆入独立 owner | `domain/context.py` 仍承载核心领域对象和兼容 setter,继续由复杂度门禁约束 |
| `runtime` | 已有任务、事件、缓存、托管资源和停止合同;资源由 lifecycle 显式装配 | 原生并发原语的全宿主清单属于已取消 ARCH-111,不在本轮扩张 |
| `adapters` | 技术与命名外部生态已有明确目录;Application/Chain 具体 Adapter 直连已清零 | 现存 direct egress 均为精确书面化协议例外,需由 zero-growth 门禁持续守护 |
| `application` | 命令、typed Port、Outbox 和安全能力边界已落地,具体 Adapter 与 raw data locator 已清零 | 新增能力必须继续使用明确 Protocol/DTO,不能恢复 `Any` 服务定位器 |
| `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,热点 Chain 已拆为同名职责包 | Facade 仍需保持稳定 ABI,并由类型、复杂度和兼容门禁防止职责回流 |
| `db/oper` | Model/Oper 已不自建 Session、不自行提交,只在调用方 Session 内查询、stage 或 flush | 当前未发现需重做的结构性事务所有权问题 |
| `db/adapters` | Application Port、短 Session/UoW 与 DTO 投影已落地;Outbox stager/store 已分离 | 新用例需继续防止 ORM 越界和隐式事务回流 |
| `startup` | `composition/runtime.py` 已成为 HostRuntime、领域 Runtime 与旧 ApiData 投影唯一 owner;Chain/Agent 复用同一 RuntimeDependencies | `initializers/modules.py` 仍保留启动顺序与跨领域发布调用,高扇出需由生命周期验收继续约束 |
| API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 |
| API/Command/Scheduler | Agent/System/Plugin 用例已下沉 Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | endpoint 与 Facade 需继续由架构门禁防止业务编排回流 |
| `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 |
### 3.2 量化快照
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 973 / 8,350 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 973 / 8,355 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
| Module Contract | 217 specs / 215 methods / 266 calls | 动态方法名为 0;内部 planning 合同不进入插件调度,旧 transfer 只保留 provider ABI |
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 10,008 / 592 文件 | 识别链 typed plan/result、TMDB API、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
| 全量 mypy 历史债务 | 9,995 / 592 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 |
| Ruff 历史诊断 | 633 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 81.61%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
@@ -86,52 +87,56 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 文件 | 行数 | 主要职责混合 |
|---|---:|---|
| `app/chain/subscribe/`(已治理) | Facade 91 行 | 搜索、匹配、刷新、完成、规则引用与通知已拆至单一 owner;原 3,895 行单文件已删除 |
| `app/chain/subscribe/`(已治理) | Facade 98 行 | 搜索、匹配、刷新、完成、规则引用与通知已拆至单一 owner;原 3,895 行单文件已删除 |
| `app/agent/orchestrator.py` | 2,558 | 单 Agent 运行、流式输出、工具与中间件编排 |
| `app/agent/session.py` | 837 | 会话队列、worker、状态、取消与延迟清理 |
| `app/agent/tasks.py` | 208 | 后台 prompt、持久化定时任务与心跳 |
| `app/agent/lifecycle.py` | 166 | 接收门禁、启动、空闲回收与有界关闭 |
| `app/agent/manager.py` | 27 | 稳定 `AgentManager` 构造门面 |
| `app/agent/llm/provider.py` | 420 | 稳定 `LLMProviderManager` Facade 与兼容方法 |
| `app/agent/llm/provider.py` | 413 | 稳定 `LLMProviderManager` Facade 与兼容方法 |
| `app/agent/llm/catalog.py` | 1,571 | provider spec、预设和模型元数据 |
| `app/agent/llm/discovery.py` | 985 | 远端目录发现与 SDK 客户端 I/O |
| `app/agent/llm/auth.py` | 582 | 持久鉴权和外部授权协议 |
| `app/adapters/external/market.py`(已治理) | Facade 226 行 | `PluginHelper` 只保留精确旧 ABI、安装 Gateway 及 owner 委托 |
| `app/adapters/external/plugin/client.py` | 1,600 | 插件市场传输、索引、Release 与本地仓候选查询 |
| `app/adapters/system/plugin/package.py` | 2,324 | 插件安装、checkpoint、备份、恢复和物理删除事务 |
| `app/adapters/external/plugin/client.py` | 1,927 | 插件市场传输、索引、Release 与本地仓候选查询 |
| `app/adapters/system/plugin/package.py` | 2,391 | 插件安装、checkpoint、备份、恢复和物理删除事务 |
| `app/adapters/system/plugin/health.py` | 1,060 | 插件运行环境保护、安装前后检查与故障恢复 |
| `app/chain/transfer/`(已治理) | Facade 82 行 | 队列/恢复、规划、执行、结算、历史/通知已拆至单一 owner;旧 `transfer.py` 与 `_transfer.py` 已删除 |
| `app/chain/search.py` | 2,970 | 搜索计划、并发 fan-out、状态、分页和结果处理 |
| `app/api/endpoints/agent.py` | 2,346 | HTTP/SSE、文件/音频、Agent 会话和事件编排 |
| `app/chain/search/`(已治理) | Facade 473 行 | 搜索计划、并发 fan-out、状态、分页和结果处理已拆至单一 owner;旧 2,970 行单文件已删除 |
| `app/api/endpoints/agent.py`(已治理) | 577 | 只保留 HTTP/SSE、文件/音频传输映射;Agent 会话和事件编排已下沉 Application |
| `app/application/messaging/agent.py` | 2,195 | WebAgent 会话、文件/音频、事件与消息桥接用例;是后续复杂度治理热点,不回流 endpoint |
| `app/chain/download/`(已治理) | Facade 47 行 | 选择、提交、批量、历史、提交后处理、字幕和任务控制已拆至单一 owner;原 2,413 行单文件已删除 |
| `app/chain/media/`(已治理) | Facade 213 行 | 识别、来源投影、插件事件、音乐目录、路径证据与缓存已拆至单一 owner;旧 2,191 行单文件已删除 |
| `app/scheduler/` | Facade 128 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner |
| `app/scheduler/`(已治理) | Facade 135 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner |
热点不是按行数机械拆文件的依据。只有在提取出稳定合同、保留旧入口委托并有行为测试时,拆分才算
降低复杂度;把长方法原样移动到新目录不算完成。
## 4. 优化清单总表
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面;`已验证` 表示实现和本地合同已落地但未在本文中声明未知的提交或 CI;`渐进` 表示应按触碰路径逐步收敛。
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面;
`已验证` 表示实现和本地验收已通过,但本叶提交、推送或精确 head 远端门禁尚未全部闭环;
`已交付` 表示实现、提交、推送、远端一致性和该叶要求的 CI 证据均已闭环;`已取消` 表示维护者明确
取消且不再作为本轮完成依赖。
| ID | 优先级 | 状态 | 事项 | 目标结果 |
|---|---|---|---|---|
| ARCH-001 | P0 | 已交付 | 恢复 mypy ratchet | `5df388719` 已推送,主线既有 CI gate 通过 |
| ARCH-101 | P1 | 已交付 | 统一规则、总览、基线和语义门禁 | `113355784` 已推送,Unit Tests `33031697902`、Pylint `33031697785` 全绿,远端 `0/0` |
| ARCH-102 | P1 | 已交付 | 将 Transfer pending 升级为真实 E3 状态机 | `e9de149db`、`a2e249f20` 已推送;Unit Tests `33092427327`、Pylint `33092427348` 全绿,崩溃结果未知时进入人工确认 |
| ARCH-103 | P1 | 已交付 | 类型化 Chain/Agent 数据 Port 与 DTO | Chain/Agent 正式数据入口已切换到冻结 DTO 与 typed Port,raw Oper/ORM/`Any` 仅留统一 Legacy/Compat |
| ARCH-103 | P1 | 已验证 | 类型化 Chain/Agent 数据 Port 与 DTO | Chain/Agent 正式数据入口已切换到冻结 DTO 与 typed Port,raw Oper/ORM/`Any` 仅留统一 Legacy/Compat |
| ARCH-104 | P1 | 已交付 | 收口跨多次写入的业务事务 | 订阅、站点和规则引用变更由单一 UoW/CAS 拥有,失败整体回滚或幂等恢复 |
| ARCH-105 | P1 | 已交付 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 |
| ARCH-106 | P1 | 已交付 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志、消息、任务、模块与插件资源由 lifecycle 显式创建、逆序关闭和失败回滚 |
| ARCH-107 | P1 | 已交付 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
| ARCH-108 | P1 | 已交付 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application/Chain 具体 Adapter 与普通 direct HTTP 债务清零,剩余出口均为精确政策例外 |
| ARCH-109 | P1 | 已交付 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler、Download、Search、Media 与 Agent/System/Plugin API 均已按 owner 收敛 |
| ARCH-105 | P1 | 已验证 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 |
| ARCH-106 | P1 | 已验证 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志、消息、任务、模块与插件资源由 lifecycle 显式创建、逆序关闭和失败回滚 |
| ARCH-107 | P1 | 已验证 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
| ARCH-108 | P1 | 已验证 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application/Chain 具体 Adapter 与普通 direct HTTP 债务清零,剩余出口均为精确政策例外 |
| ARCH-109 | P1 | 已验证 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler、Download、Search、Media 与 Agent/System/Plugin API 均已按 owner 收敛;部分早期叶只保留阶段性验证记录 |
| ARCH-110 | P1 | 已取消 | Module/Event Contract 分可信级执行 | 随 S4 取消,不属于本轮完成依赖;现有宿主严格合同和插件兼容诊断继续保留 |
| ARCH-111 | P1 | 已取消 | 升级复杂度、类型、覆盖率和并发原语门禁 | 随 S4 取消,不扩张为全宿主质量债务清零;保留现有 ratchet |
| ARCH-201 | P2 | 已交付 | 收窄 PluginHelper/PluginManager 与 SDK 暴露面 | `fa40f29df` 已推送;ABI Facade 只委托,构造和具体服务归组合根 |
| ARCH-202 | P2 | 已交付 | 拆分 Agent/LLM provider 职责 | provider catalog、发现、认证、会话和运行时分离,Facade 只保留稳定 API |
| ARCH-203 | P2 | 已交付 | 拆分 Domain 投影与 Startup 高扇出目录 | Domain 四来源投影与 Startup 单词型 composition owner 均已完成 |
| ARCH-204 | P2 | 已交付 | 合并重复 sync/async 核心逻辑并转换存量测试风格 | 双 ABI 只保留 I/O 外壳,共享解析、校验、映射和状态决策 |
| ARCH-202 | P2 | 已验证 | 拆分 Agent/LLM provider 职责 | provider catalog、发现、认证、会话和运行时分离,Facade 只保留稳定 API;实现提交已在远端,待最终精确 head CI 统一闭环 |
| ARCH-203 | P2 | 已验证 | 拆分 Domain 投影与 Startup 高扇出目录 | Domain 四来源投影与 Startup 单词型 composition owner 均已完成,等待最终远端门禁闭环 |
| ARCH-204 | P2 | 已验证 | 合并重复 sync/async 核心逻辑并转换存量测试风格 | 双 ABI 只保留 I/O 外壳,共享解析、校验、映射和状态决策,等待最终远端门禁闭环 |
## 5. P0:先恢复主线
@@ -296,7 +301,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] Subscription 按领域定义 Query/Write/Staging/History Protocol,不再使用通用
`OperFactory = Callable[[], Any]` 或 raw Subscribe Oper factory。
- [ ] 写 Port 由 `db/adapters` 创建单操作 Session/UoW;Oper 的 canonical 写方法只 stage/flush,
- [x] 写 Port 由 `db/adapters` 创建单操作 Session/UoW;Oper 的 canonical 写方法只 stage/flush,
读取方法仍可在调用方 Session 中查询。
- [x] Workflow 查询 Port 在 adapter Session 内映射为冻结 DTO/Projection,API、Agent、Chain、Scheduler、
Workflow runtime 和中心服务分享均不接收 ORM。
@@ -401,7 +406,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] commit 后先取得 delivery lease;未取得时跳过请求线程直投,确保与 dispatcher 排他。
- [x] 每个 post-commit effect 使用独立 intent 隔离和结算,避免效果 A 失败导致效果 B 被一起重放。
单个外部效果仍是 at-least-once,必须有稳定幂等键和幂等 handler/消费者。
- [ ] 按完成承诺、可重建性、外部不可逆性和业务重要性划分 E0-E3;用户可见性只是因素之一。
- [x] 按完成承诺、可重建性、外部不可逆性和业务重要性划分 E0-E3;用户可见性只是因素之一。
完整分类、失败语义和恢复责任记录在 `docs/adr/0007-background-action-reliability.md`。
- [x] 拆分 `OutboxStager` 与 `OutboxDispatchStore`,避免业务 Session 调用自提交方法。
- [x] 在 commit、claim、publish、complete、通知和任务提交各断点注入异常/崩溃,验证声明等级与实际恢复一致。
- [x] 增加请求线程即时投递与 dispatcher 并发竞争测试,以及“外部调用成功、complete 前崩溃”的重放测试。
@@ -421,8 +427,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- 日志 writer 已退出 `app.runtime.config` 导入路径,由 lifespan 显式创建、关闭并在真实收敛后释放身份。
- `ChainBase` 只绑定共享消息队列的轻量客户端;队列线程由 lifespan 唯一启动,不再保存首个 Chain 回调。
- 停止状态、WebPush registry 和主循环 owner 已拆为独立 runtime 合同;`global_vars` 只保留旧插件 ABI 门面。
- 当前 TaskRegistry 门禁只检查 Registry 调用是否带 owner,不会扫描全部原生 `create_task`、
`Thread`、`Timer`、`Executor`。
- 当前 TaskRegistry 门禁检查 Registry 调用必须带 owner;全宿主原生 `create_task`、`Thread`、
`Timer`、`Executor` 清单原属于已取消 ARCH-111,不作为 ARCH-106 的完成依赖。
- service locator 门禁继续覆盖五类 concrete runtime;独立 AST 门禁现已拒绝 canonical 宿主导入
`global_vars`,只允许定义模块与 SDK 兼容出口。
- Workflow、Scheduler、Command、Agent provider 均由生命周期显式 configure/reset,冷导入不再改变注册状态。
@@ -437,8 +443,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] 保留 `global_vars` 作为兼容薄门面,但禁止 canonical 宿主新增依赖。
- [x] 将 initializer 的 provider 注册迁入显式装配阶段并提供测试 reset;冷导入回归测试覆盖
Workflow、Scheduler、Command、Agent、技能目录和 LLM provider。
- [ ] 建立原生并发原语清单:结构化局部等待、TaskRegistry、显式生命周期 owner 或受限上下文;
未分类的新原语由 ratchet 拒绝。
- [x] 对本叶迁移的日志、消息、Scheduler、Workflow、Agent、Plugin 资源建立 owner 与生命周期门禁;
全宿主原生并发原语清单不在本叶偷扩范围,保留为已取消 ARCH-111 的历史定义。
**验收**
@@ -462,9 +468,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**问题与证据**
- 当前新增 SCC 为 `app.chain -> app.chain._messaging/_recognition -> app.chain`。
- 当前宿主图只剩精确 containment 的 TMDB 移植包环;Chain 包内 SCC 已清零。
- `ChainBase` 已迁到 `app/chain/base.py`;物理包根仅保留包说明,旧符号由 Compat/SDK 惰性解析。
- `baseline.py --check-host` 会确认该 SCC 与 fixture 一致,但现有语义测试没有拒绝 Chain 包内环。
- `baseline.py --check-host` 与独立 SCC 语义测试共同拒绝 Chain 包内环、allowlist 成员增长和
包外模块通过反向边加入 TMDB containment。
**目标与步骤**
@@ -498,14 +505,14 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- `app/application` 原有 8 个文件、13 条直接 Adapter 导入已全部迁移为 Application-owned Port,
startup 统一注入具体实现;Passkey、Backup 与 DNS 纵切面也已退出临时 policy。
- `app/chain` 有 8 个文件、13 条直接 Adapter 导入,使用 `RequestUtils`、Browser、Cloudflare、
CookieCloud、ServerHelper 等具体能力。
- `app/chain` 原有 8 个文件、13 条直接 Adapter 导入和 11 条普通 HTTP/Session bridge 已全部
迁移至注入 Port 或统一技术边界,当前具体 Adapter 与普通 direct HTTP 债务均为零。
- Passkey Application 已改为消费启动注入的 `PasskeyChallengeCache`,不再判断 Redis 或导入
具体 cache adapter;Memory/Redis 均实现严格 `AtomicCacheBackend.store/consume`。
- 审计时 LLM streaming、第三方 SDK、移植库和本地控制面没有精确例外表;S0-L2.4b 已建立
66 条完整 egress identity 与 zero-growth policy,其中 11 条普通 HTTP/Session bridge 和 1 条
Application DNS I/O 是清零债务;每条初始边另有独立指纹上界,不能靠同时刷新 baseline/policy
掩盖同一边的调用面增长,债务删除后也不得恢复。
- S0-L2.4b 已为 LLM streaming、第三方 SDK、移植库和本地控制面建立完整 egress identity 与
zero-growth policy;11 条普通 HTTP/Session bridge 和 1 条 Application DNS I/O 债务已清零。
当前 53 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新
baseline/policy 掩盖调用面增长,已删除债务也不得恢复。
**目标与步骤**
@@ -513,11 +520,12 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] 建立全宿主 direct egress 事实;SDK/stream/vendor/local-control 例外精确到 bindings/uses 指纹。
- [x] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。
- [x] 为 Backup 定义 Application-owned artifact store Port,由 startup 注入文件系统实现。
- [ ] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。
- [x] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。
- [x] 为 Application SSRF 校验注入 DNS 解析 Port,清除 `socket.getaddrinfo` 直接 I/O。
- [x] Application 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。
- [ ] Chain 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。
- [ ] 最终把基线收缩到零或少量书面化例外,而不是一次性禁止后再大量豁免。
- [x] Chain 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。
- [x] Adapter/direct HTTP 债务基线已收缩为零;其余 canonical transport、SDK、stream、vendor、
diagnostic 和 control-plane 出口均按用途精确书面化,不使用宽泛豁免。
```bash
.venv/bin/python -m pytest \
@@ -702,8 +710,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线
1. ARCH-102 设计并迁移 Transfer E3 状态机。
2. 以站点/规则引用清理为 ARCH-103/104 的第一个 typed Port + UoW 纵切面。
3. ARCH-105 已完成 post-commit 结构化结果、Outbox 角色拆分和 claim fencing;
后续只继续清理独立 intent 与更完整的崩溃矩阵。
3. ARCH-105 已完成 post-commit 结构化结果、独立 intent、Outbox 角色拆分、claim fencing 和
commit/claim/publish/complete/通知/任务提交崩溃矩阵。
**退出条件**:故障注入覆盖清单列出的崩溃窗口;schema 有 migration;主路径不再 fail-open;调用方能区分
业务提交与后置效果 pending。
@@ -712,7 +720,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线
1. ARCH-106 将日志、消息队列和主循环 gateway 纳入 lifecycle。
2. ARCH-107 拆出 `chain/base.py` 并消除新增 SCC。
3. ARCH-108 的 Passkey 缓存与 Backup artifact 边界已验证;后续按风险迁移 DNS 和其余外部调用。
3. ARCH-108 的 Passkey、Backup、DNS、Application/Chain Adapter 与普通 HTTP 纵切面均已完成;
现存协议出口只保留精确书面化例外并由 zero-growth 门禁守护。
**退出条件**:冷导入不启动线程;SCC 只剩 TMDB 精确豁免;Application/Chain 到 Adapter 的债务只降不增。
@@ -726,7 +735,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线
### 阶段 4:取消记录与 S5 收口
1. ARCH-110/111 随 S4 于 2026-08-28 取消,不再作为本轮完成依赖。
2. ARCH-201 至 ARCH-204 已由 S5 完成 Facade、Provider、Domain/Startup 与 sync/async 重复治理。
2. ARCH-201/202 已完成 Facade 与 Provider 实现,ARCH-203/204 的 Domain/Startup 与 sync/async
重复治理已通过本地验证;最终交付状态由 S5-L6 的精确 head 远端门禁统一闭环。
3. 保留现有 strict frontier、复杂度、mypy、Ruff、覆盖率和并发 ratchet,不借取消阶段接受回退。
**退出条件**:S5 全部叶子通过现有门禁与官方插件兼容验证,canonical 无旧实现和重复导出。

View File

@@ -7,7 +7,7 @@
> [`docs/rules/04-design-patterns.md`](rules/04-design-patterns.md) 为准,本文与其保持一致;
> 如出现差异,以规则文档为准。
>
> *Last Updated: 2026-08-29*
> *Last Updated: 2026-08-30*
---
@@ -747,15 +747,15 @@ flowchart LR
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
- 延迟导入不被接受为隐藏循环依赖的手段。
### 10.1 2026-08-29 当前收口状态与后续边界
### 10.1 2026-08-30 当前收口状态与后续边界
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 972 |
| 内部导入边 | 8,350 |
| Python 模块 | 973 |
| 内部导入边 | 8,355 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment) |

View File

@@ -27,7 +27,7 @@
| `PLANNED` | 已定义边界,依赖满足后可激活 |
| `BLOCKED` | 有明确阻塞证据,且没有可推进的替代叶子 |
| `VERIFIED` | 本地验收完成,尚未推送或尚未确认远端 |
| `DELIVERED` | 已提交、推送,远端 SHA 与本地交付一致 |
| `DELIVERED` | 已提交、推送,远端 SHA 与本地交付一致,且本叶要求的远端 CI 已闭环 |
| `COMPLETE` | 叶子验收和后续依赖均已结清 |
| `CANCELLED` | 经维护者明确取消,不进入执行、验收或完成计数 |
@@ -54,15 +54,15 @@
G-ARCH 只有在 S3、S5 及以下条件全部满足后才可完成;S4 不属于本轮完成范围:
- [x] `ARCH-001`、`ARCH-101` 至 `ARCH-109`、`ARCH-201` 至 `ARCH-204` 全部完成;`ARCH-110/111` 随 S4 取消且不作为完成依赖。
- [ ] `ARCH-001`、`ARCH-101` 至 `ARCH-109`、`ARCH-201` 至 `ARCH-204` 全部完成;`ARCH-110/111` 随 S4 取消且不作为完成依赖。
- [x] 宿主依赖图没有未解释 SCC;只允许精确 containment 的 TMDB 移植包例外,成员不能增长。
- [x] Application/Chain 到具体 Adapter、direct egress、raw concurrency 等所有例外均精确、可解释、不可增长。
- [x] Application/Chain 到具体 Adapter、direct egress,以及本轮迁移资源的并发 owner 例外均精确、可解释、不可增长。
- [x] Chain/Agent 正式数据入口不再注入无 Session Oper,不再以 `Any`/ORM 作为跨层合同。
- [x] 正式业务写入均有单一事务 owner;E2/E3 动作完成语义、幂等、恢复与人工决策路径一致。
- [x] import、普通对象构造不启动进程资源;资源由 bootstrap/lifecycle 显式创建、发布、关闭和重试收口。
- [x] S3、S5 各叶子定义的类型、复杂度、覆盖率和并发债务清零,现有全局 ratchet 不发生回退。
- [x] canonical 主程序无旧实现、重复导出和 legacy import;插件兼容检查基于同步后的官方插件仓 SHA 通过。
- [x] 锁定全量测试、Pylint、依赖一致性和最终远端一致性全部通过;本轮未修改依赖。
- [ ] 锁定全量测试、Pylint、依赖一致性和最终远端一致性全部通过;本轮未修改依赖。
## 3. 阶段与叶子
@@ -86,9 +86,9 @@ G-ARCH 只有在 S3、S5 及以下条件全部满足后才可完成;S4 不属
退出条件:Transfer 达到 E3;正式数据 Port 类型化;跨表业务操作有单一 UoW;post-commit/Outbox
竞争、失败呈现、at-least-once 和幂等语义全部闭环。
`S1-L1` 是 ARCH-102 的 Transfer E3 父项,当前状态为 **DELIVERED**。`S1-L1.1` 至
`S1-L1.5` 已全部交付,真实调用链已完成迁移,旧 fail-open、重复状态和兼容层外旧入口已退出
canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
`S1-L1` 是 ARCH-102 的 Transfer E3 父项,当前状态为 **DELIVERED**。`S1-L1.5` 聚合交付
`S1-L1.1` 至 `S1-L1.4` 的阶段性 `VERIFIED` 结果;真实调用链已完成迁移,旧 fail-open、重复状态和
兼容层外旧入口已退出 canonical 主程序,兼容只经统一 Compat/SDK 门面提供。
| Leaf | 状态 | 依赖 | 完成定义 |
|---|---|---|---|
@@ -125,12 +125,12 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S2-L4 Passkey 缓存边界 | `VERIFIED` | S0-L2.4 | `PasskeyChallengeCache` 由 startup 注入,Application 不识别 Redis;严格 `AtomicCacheBackend.store/consume` 由 Memory/Redis backend 分别实现,challenge 仅能被原子领取一次 |
| S2-L5 Backup artifact Port | `VERIFIED` | S0-L2.4 | Application-owned `BackupArtifactStore`/factory 覆盖创建、发布、清理、列举、解析、删除和元数据读取;startup 注入唯一 `BackupFiles` 实现,Application 具体 Adapter 直连由 14 降至 13,完整备份调用链 62 项通过 |
| S2-L6 Application Adapter/DNS 债务清零 | `VERIFIED` | S2-L4,S2-L5 | Application 自有 DNS、图片、消息回环、RSS、站点登录、种子、系统加速 Port,startup 统一注入;13 条具体 Adapter 边及 Application DNS I/O 均归零,未装配和失败回滚语义已验证 |
| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `VERIFIED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 均归零;当前 55 条出口事实全部属于精确 SDK/stream/vendor/canonical containment,policy 无 unreviewed、stale 或指纹漂移 |
| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `VERIFIED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 均归零;当前 53 条出口事实全部属于精确 SDK/stream/vendor/canonical containment,policy 无 unreviewed、stale 或指纹漂移 |
### S3:大型编排器职责清零
退出条件:每个热点 Facade 只保留稳定公开入口;决策、I/O、状态和生命周期各有单一 owner;
旧实现从 canonical 文件删除,complexity-v2 对该所有权面无债务。
旧实现从 canonical 文件删除,现有复杂度 ratchet 与叶子结构门禁对该所有权面无新增债务。
| Leaf | 状态 | 依赖 | 完成定义 |
|---|---|---|---|
@@ -166,11 +166,11 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| Leaf | 状态 | 依赖 | 完成定义 |
|---|---|---|---|
| S5-L1 PluginHelper/PluginManager | `DELIVERED` | S2,S3 | `fa40f29df`:市场/包/依赖/备份/健康服务各归 owner;Facade 只转发稳定 ABI,构造归 typed PluginRuntime |
| S5-L2 Agent/LLM provider | `DELIVERED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058`:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,旧包根符号仅由精确 Compat 承接 |
| S5-L3 Domain projection | `DELIVERED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 |
| S5-L4 Startup composition | `DELIVERED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 |
| S5-L5 Sync/async 重复清零 | `DELIVERED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 |
| S5-L6 最终兼容与交付 | `DELIVERED` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 |
| S5-L2 Agent/LLM provider | `VERIFIED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058` 已在 `origin/v3` 祖先链:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,待最终精确 head CI 统一闭环 |
| S5-L3 Domain projection | `VERIFIED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 |
| S5-L4 Startup composition | `VERIFIED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 |
| S5-L5 Sync/async 重复清零 | `VERIFIED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 |
| S5-L6 最终兼容与交付 | `ACTIVE` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 |
## 4. 叶子合同与当前活动项
@@ -361,7 +361,7 @@ TMDB、豆瓣、Bangumi、AniList 详情到统一字段的规则一次迁入 `ap
### S5-L6 最终兼容与交付
**Status:** `DELIVERED`
**Status:** `ACTIVE`
- 22 个不符合规范的 canonical 文件名已归并为单词文件或同名职责包;旧实现文件和主程序包根重复导出
已删除,宿主调用方直接导入真实 owner,兼容只由精确 SDK/Compat manifest 提供。
@@ -371,8 +371,13 @@ TMDB、豆瓣、Bangumi、AniList 详情到统一字段的规则一次迁入 `ap
- 锁定串行覆盖率全量 `7659 passed, 9 skipped`;Application/Domain 低水位提升并固化为
`81.61%` / `81.03%`。Host 架构、Event/Egress policy、复杂度、async 阻塞、TaskRegistry、
service locator、mypy/Ruff ratchet、启动性能、Pylint `10.00/10` 与依赖一致性均通过。
- 本叶随最终收口提交推送到 `v3`;以 exact head 的 GitHub Unit Tests/Pylint 终态和远端 `0/0`
作为 G-ARCH 结束证据。
- exact-head 失败后已将 canonical `SearchChain` Facade 改为显式类型转发,并修正 API/Workflow 的
Optional、Domain/Schema DTO 边界;2026-08-30 最终本地锁定全量 `7659 passed, 9 skipped`,
CI 同构架构测试 `197 passed`,mypy 低水位降至 `9,995`,Ruff、Pylint `10.00/10`、复杂度、
async、TaskRegistry、service locator、启动性能与依赖快照门禁均通过。
- 收口提交 `c960a0184` 已推送,但 exact-head Unit Tests `33260357239` 的 mypy ratchet 暴露
canonical `SearchChain` 直达 owner 后的类型债务,因此本叶仍为 `ACTIVE`;不得以本地全量或 Pylint
成功替代远端失败终态。
### S1-L1.1 Durable admission

View File

@@ -1089,8 +1089,8 @@
"runtime_only": true
}
},
"edge_count": 8350,
"edge_sha256": "cc67750171cce278b34e966da0c00faa5fef41f843a37fba472c657a13f8fb24",
"edge_count": 8355,
"edge_sha256": "83d2fd65af87bfe90562c115b235afd2446e1e3576393398687a6e76e0c34105",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -3338,6 +3338,7 @@
"app.api.endpoints.search -> app.chain.search",
"app.api.endpoints.search -> app.chain.search.facade",
"app.api.endpoints.search -> app.domain",
"app.api.endpoints.search -> app.domain.context",
"app.api.endpoints.search -> app.domain.media",
"app.api.endpoints.search -> app.runtime",
"app.api.endpoints.search -> app.runtime.localization",
@@ -3491,6 +3492,7 @@
"app.api.endpoints.system -> app.chain.search",
"app.api.endpoints.system -> app.chain.search.facade",
"app.api.endpoints.system -> app.domain",
"app.api.endpoints.system -> app.domain.context",
"app.api.endpoints.system -> app.domain.metainfo",
"app.api.endpoints.system -> app.foundation",
"app.api.endpoints.system -> app.foundation.crypto",
@@ -9357,10 +9359,13 @@
"app.workflow.actions.fetch_torrents -> app.chain.media",
"app.workflow.actions.fetch_torrents -> app.chain.search",
"app.workflow.actions.fetch_torrents -> app.chain.search.facade",
"app.workflow.actions.fetch_torrents -> app.domain",
"app.workflow.actions.fetch_torrents -> app.domain.context",
"app.workflow.actions.fetch_torrents -> app.runtime",
"app.workflow.actions.fetch_torrents -> app.runtime.log",
"app.workflow.actions.fetch_torrents -> app.runtime.stop",
"app.workflow.actions.fetch_torrents -> app.schemas",
"app.workflow.actions.fetch_torrents -> app.schemas.context",
"app.workflow.actions.fetch_torrents -> app.schemas.types",
"app.workflow.actions.fetch_torrents -> app.schemas.workflow",
"app.workflow.actions.fetch_torrents -> app.workflow",

View File

@@ -578,7 +578,6 @@
"arg-type": 1,
"assignment": 1,
"misc": 1,
"no-untyped-call": 1,
"no-untyped-def": 2
},
"app/agent/tools/impl/reload_plugin.py": {
@@ -867,7 +866,7 @@
"assignment": 8,
"attr-defined": 3,
"misc": 14,
"no-untyped-call": 7,
"no-untyped-call": 5,
"no-untyped-def": 3,
"type-arg": 1
},
@@ -942,7 +941,6 @@
"assignment": 2,
"attr-defined": 3,
"misc": 26,
"no-untyped-call": 1,
"no-untyped-def": 2,
"return-value": 3,
"type-arg": 6,
@@ -966,7 +964,7 @@
},
"app/api/endpoints/system.py": {
"arg-type": 2,
"attr-defined": 5,
"attr-defined": 4,
"import-untyped": 1,
"misc": 34,
"no-untyped-call": 7,
@@ -1251,7 +1249,7 @@
"arg-type": 1,
"attr-defined": 26,
"no-any-return": 6,
"no-untyped-call": 2,
"no-untyped-call": 1,
"no-untyped-def": 2,
"type-arg": 4
},
@@ -1679,8 +1677,8 @@
"app/domain/context.py": {
"assignment": 86,
"no-any-return": 1,
"no-untyped-call": 5,
"no-untyped-def": 27,
"no-untyped-call": 4,
"no-untyped-def": 24,
"operator": 1,
"type-arg": 36
},
@@ -3345,9 +3343,7 @@
"var-annotated": 1
},
"app/workflow/actions/fetch_torrents.py": {
"type-arg": 1,
"union-attr": 2,
"var-annotated": 1
"type-arg": 1
},
"app/workflow/actions/filter_medias.py": {
"operator": 1,

View File

@@ -27,8 +27,8 @@ from app.chain.transfer import TransferChain
SearchChain,
{
"save_last_search_params": "_search_state",
"last_search_params": "_search_state",
"last_search_results": "_search_state",
"last_search_params": "SearchCacheOwner",
"last_search_results": "SearchCacheOwner",
},
),
(

View File

@@ -1,5 +1,7 @@
from types import SimpleNamespace
from app.domain.context import Context, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas.download import DownloadTask
from app.schemas.file import FileItem
from app.schemas.workflow import ActionContext, ActionResult
@@ -53,15 +55,13 @@ def test_fetch_torrents_filters_special_season_zero(monkeypatch):
def search_by_title(self, **_kwargs):
return [
SimpleNamespace(
meta_info=SimpleNamespace(year=None, begin_season=0),
media_info=None,
torrent_info=SimpleNamespace(title="Test S00"),
Context(
meta_info=MetaInfo("Test S00"),
torrent_info=TorrentInfo(title="Test S00"),
),
SimpleNamespace(
meta_info=SimpleNamespace(year=None, begin_season=1),
media_info=None,
torrent_info=SimpleNamespace(title="Test S01"),
Context(
meta_info=MetaInfo("Test S01"),
torrent_info=TorrentInfo(title="Test S01"),
),
]