refactor: 收口 V3 分层架构与插件兼容边界

This commit is contained in:
jxxghp
2026-08-18 13:22:02 +08:00
parent cca99bd421
commit 8472bcff43
274 changed files with 10730 additions and 6130 deletions
+245
View File
@@ -0,0 +1,245 @@
"""Agent 会话历史的查询、授权与删除应用服务。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
class AgentChatPrincipal(Protocol):
"""会话访问控制所需的最小用户身份。"""
id: Any
name: Optional[str]
is_superuser: bool
class AsyncAgentChatRepository(Protocol):
"""Agent 会话用例需要的最小异步持久化端口。"""
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> list[Any]:
"""分页读取用户可见的会话。"""
...
async def async_get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[Any]:
"""按服务端会话 ID 读取记录。"""
...
async def async_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""删除指定服务端会话。"""
...
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
"""同步读取服务端会话。"""
...
def save_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""同步保存用户可见会话消息。"""
...
@dataclass(frozen=True, slots=True)
class AgentChatRecord:
"""脱离 ORM 会话的 Agent 会话持久化投影。"""
id: Optional[int]
session_id: str
client_session_id: Optional[str]
title: Optional[str]
channel: Optional[str]
source: Optional[str]
user_id: Optional[str]
username: Optional[str]
original_chat_id: Optional[str]
message_count: int
created_at: Any
updated_at: Any
messages: list[dict]
class AgentChatService:
"""统一执行 Agent 会话查询、访问控制和删除。"""
def __init__(self, repository: AsyncAgentChatRepository) -> None:
"""保存异步会话持久化端口。"""
self._repository = repository
async def list(
self,
principal: AgentChatPrincipal,
*,
page: int = 1,
count: int = 30,
) -> list[AgentChatSessionSummary]:
"""分页返回当前用户可见的会话摘要。"""
user_id = None if principal.is_superuser else str(principal.id)
username = None if principal.is_superuser else principal.name
records = await self._repository.async_list_by_page(
page=page,
count=count,
user_id=user_id,
username=username,
)
return [self.to_summary(self._project(record)) for record in records]
async def get_accessible(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> Optional[AgentChatRecord]:
"""读取会话并在应用边界执行访问控制。"""
projected = await self.get(session_id)
if projected is None:
return None
if not self.can_access(projected, principal):
return None
return projected
async def get(self, session_id: str) -> Optional[AgentChatRecord]:
"""读取不附带授权判断的会话投影。"""
record = await self._repository.async_get(session_id=session_id)
if record is None:
return None
return self._project(record)
async def delete(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> bool:
"""仅在当前用户可访问时删除会话。"""
record = await self.get_accessible(session_id, principal)
if record is None:
return False
return await self._repository.async_delete(session_id=session_id)
def get_sync(self, session_id: str) -> Optional[AgentChatRecord]:
"""同步读取会话投影,供同步 Agent 编排路径使用。"""
record = self._repository.get(session_id=session_id)
return self._project(record) if record is not None else None
def save_display_sync(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""同步保存用户可见消息并返回最新投影。"""
record = self._repository.save_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
return self._project(record) if record is not None else None
@staticmethod
def can_access(
record: AgentChatRecord,
principal: AgentChatPrincipal,
) -> bool:
"""判断用户是否拥有会话访问权。"""
if principal.is_superuser:
return True
user_id = str(principal.id)
username = str(principal.name or "")
return record.user_id == user_id or (
bool(username) and record.username == username
)
@staticmethod
def to_summary(record: AgentChatRecord) -> AgentChatSessionSummary:
"""把持久化投影转换为会话摘要 DTO。"""
return AgentChatSessionSummary(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count,
created_at=record.created_at,
updated_at=record.updated_at,
)
@classmethod
def to_detail(cls, record: AgentChatRecord) -> AgentChatSessionDetail:
"""把持久化投影转换为会话详情 DTO。"""
return AgentChatSessionDetail(
**cls.to_summary(record).model_dump(),
messages=record.messages,
)
@staticmethod
def _project(record: Any) -> AgentChatRecord:
"""立即复制 ORM 字段,避免对象越过请求级会话边界。"""
return AgentChatRecord(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count or 0,
created_at=record.created_at,
updated_at=record.updated_at,
messages=list(record.display_messages or []),
)
_configured_agent_chat_service: AgentChatService | None = None
def configure_agent_chat_service(service: AgentChatService) -> None:
"""由启动组合根登记同步 Agent 会话服务。"""
global _configured_agent_chat_service
_configured_agent_chat_service = service
def get_configured_agent_chat_service() -> AgentChatService:
"""返回启动阶段登记的 Agent 会话服务。"""
if _configured_agent_chat_service is None:
raise RuntimeError("Agent 会话服务尚未配置")
return _configured_agent_chat_service
+62 -4
View File
@@ -8,7 +8,7 @@ import re
import threading
import time
from datetime import datetime
from typing import Any, Literal, Optional, List, Dict, Union
from typing import Any, Literal, Optional, List, Dict, Protocol, Union
from typing import Callable
from jinja2 import Template
@@ -18,7 +18,7 @@ from app.runtime.config import global_vars
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.db.oper.systemconfig import SystemConfigOper
from app.application.configuration import get_configured_system_config
from app.runtime.log import logger
from app.schemas.message import Message
from app.schemas.tmdb import TmdbEpisode
@@ -29,6 +29,64 @@ from app.foundation import size as size_tools
from app.foundation.crypto import HashUtils
class AsyncMessageQueryRepository(Protocol):
"""消息查询用例依赖的异步持久化端口。"""
async def async_list_by_page(
self, page: int = 1, count: int = 30
) -> list[Any]:
"""分页读取 Web 消息。"""
...
async def async_list_sent_by_page(
self,
page: int = 1,
count: int = 30,
all_clear_before: Optional[str] = None,
system_clear_before: Optional[str] = None,
media_clear_before: Optional[str] = None,
) -> list[Any]:
"""分页读取清理水位之后的通知消息。"""
...
class MessageQueryService:
"""封装消息历史读取与持久化对象投影。"""
def __init__(self, repository: AsyncMessageQueryRepository):
"""使用显式消息查询端口初始化服务。"""
self._repository = repository
async def list_web(self, page: int = 1, count: int = 20) -> list[dict[str, Any]]:
"""分页返回可由 API schema 消费的 Web 消息字典。"""
messages = await self._repository.async_list_by_page(page=page, count=count)
result: list[dict[str, Any]] = []
for message in messages:
try:
result.append(message.to_dict())
except Exception as error:
logger.error(f"获取WEB消息列表失败: {str(error)}")
return result
async def list_notifications(
self,
page: int = 1,
count: int = 20,
all_clear_before: Optional[str] = None,
system_clear_before: Optional[str] = None,
media_clear_before: Optional[str] = None,
) -> list[dict[str, Any]]:
"""分页返回清理水位之后的通知消息字典。"""
messages = await self._repository.async_list_sent_by_page(
page=page,
count=count,
all_clear_before=all_clear_before,
system_clear_before=system_clear_before,
media_clear_before=media_clear_before,
)
return [message.to_dict() for message in messages]
class TemplateContextBuilder:
"""
模板上下文构建器。
@@ -722,7 +780,7 @@ class MessageTemplateHelper:
获取消息模板
"""
try:
template_dict = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) or {}
template_dict = get_configured_system_config().get(SystemConfigKey.NotificationTemplates) or {}
if isinstance(template_dict, dict):
configured = template_dict.get(message.ctype.value)
if str(configured or "").strip() not in {"", "{}", "{ }"}:
@@ -766,7 +824,7 @@ class MessageQueueManager(metaclass=SingletonClass):
初始化配置
"""
self.schedule_periods = self._parse_schedule(
SystemConfigOper().get(SystemConfigKey.NotificationSendTime)
get_configured_system_config().get(SystemConfigKey.NotificationSendTime)
)
@staticmethod
+21 -9
View File
@@ -1,8 +1,6 @@
import re
from typing import Callable, List, Optional, Tuple, Union
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
from app.db.models.site import Site
from app.db.oper.site import SiteOper
from app.domain import site as site_rules
from app.application.messaging.interaction import (
MessageGateway,
@@ -22,6 +20,19 @@ from app.schemas.types import NotificationChannel
site_interaction_manager = SlashInteractionManager()
class SiteInteractionRepository(Protocol):
"""站点消息交互所需的同步数据端口。"""
def list(self) -> List[Any]:
"""返回站点列表。"""
def get(self, site_id: int) -> Optional[Any]:
"""按 ID 返回站点。"""
def update(self, site_id: int, payload: dict) -> Optional[Any]:
"""更新站点。"""
class SiteInteractionHandler:
"""
管理 /sites 交互会话、输入解析和站点列表渲染。
@@ -34,12 +45,14 @@ class SiteInteractionHandler:
self,
messenger: MessageGateway,
cookie_updater: Callable[..., Tuple[bool, str]],
repository: SiteInteractionRepository,
):
"""
注入消息投递接口和站点 Cookie 更新动作。
"""
self._messenger = messenger
self._cookie_updater = cookie_updater
self._repository = repository
def remote_list(
self,
@@ -400,7 +413,7 @@ class SiteInteractionHandler:
"""
渲染 /sites 当前页面。
"""
site_list = SiteOper().list()
site_list = self._repository.list()
page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size
page_sites, page, total_pages = page_items(site_list, request.page, page_size)
request.page = page
@@ -463,7 +476,7 @@ class SiteInteractionHandler:
@staticmethod
def _format_site_list(
site_list: List[Site], channel: Optional[NotificationChannel]
site_list: List[Any], channel: Optional[NotificationChannel]
) -> str:
"""
根据渠道能力格式化站点列表。
@@ -537,15 +550,14 @@ class SiteInteractionHandler:
if not site_ids:
return False, "请输入至少一个有效的站点 ID"
siteoper = SiteOper()
changed = []
missing = []
for site_id in site_ids:
site = siteoper.get(site_id)
site = self._repository.get(site_id)
if not site:
missing.append(str(site_id))
continue
siteoper.update(site_id, {"is_active": enabled})
self._repository.update(site_id, {"is_active": enabled})
changed.append(site.name)
action = "启用" if enabled else "禁用"
@@ -571,7 +583,7 @@ class SiteInteractionHandler:
)
site_id = int(args[0])
site_info = SiteOper().get(site_id)
site_info = self._repository.get(site_id)
if not site_info:
return False, f"站点编号 {site_id} 不存在"
+56 -8
View File
@@ -3,9 +3,8 @@ import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union
from app.agent.skills.registry import SkillHelper, SkillInfo
from app.application.messaging.interaction import (
MessageGateway,
build_navigation_buttons,
@@ -17,6 +16,54 @@ from app.schemas.message import Message
from app.schemas.types import NotificationChannel
class SkillCatalogPort(Protocol):
"""消息层使用的技能目录能力端口,由启动组合根注入具体实现。"""
def add_custom_market_source(self, source: str) -> Tuple[bool, str]:
"""添加一个自定义技能市场来源。"""
def remove_custom_market_source(self, source: str) -> Tuple[bool, str]:
"""移除一个自定义技能市场来源。"""
def install_market_skill(self, skill: Any) -> Tuple[bool, str]:
"""安装指定的市场技能。"""
def list_local_skills(self) -> List[Any]:
"""列出本地技能。"""
def remove_local_skill(self, skill_id: str) -> Tuple[bool, str]:
"""移除指定的本地技能。"""
def list_market_source_entries(self) -> List[Any]:
"""列出技能市场来源。"""
def list_market_skills(self, force: bool = False) -> List[Any]:
"""列出市场技能。"""
def filter_market_skills(self, skills: List[Any], query: str) -> List[Any]:
"""按查询词过滤市场技能。"""
SkillCatalogProvider = Callable[[], SkillCatalogPort]
_skill_catalog_provider: Optional[SkillCatalogProvider] = None
def register_skill_catalog_provider(provider: SkillCatalogProvider) -> None:
"""由启动组合根注册 Agent 技能目录实现,避免消息层依赖 Agent 具体模块。"""
global _skill_catalog_provider
_skill_catalog_provider = provider
def _resolve_skill_catalog() -> SkillCatalogPort:
"""解析已注入的技能目录;缺少组合根装配时给出明确错误。"""
if _skill_catalog_provider is None:
raise RuntimeError(
"技能目录服务未注册:请先导入 app.startup.agent_initializer "
"完成组合根装配"
)
return _skill_catalog_provider()
@dataclass
class PendingSkillInteraction:
"""
@@ -152,11 +199,12 @@ class SkillInteractionHandler:
def __init__(
self,
messenger: MessageGateway,
skill_helper: Optional[SkillHelper] = None,
skill_catalog: Optional[SkillCatalogPort] = None,
skill_helper: Optional[SkillCatalogPort] = None,
):
"""注入消息接口和技能管理能力"""
"""注入消息接口和技能目录端口,保留旧 ``skill_helper`` 关键字"""
self._messenger = messenger
self.skillhelper = skill_helper or SkillHelper()
self.skillhelper = skill_catalog or skill_helper or _resolve_skill_catalog()
def remote_manage(
self,
@@ -1059,10 +1107,10 @@ class SkillInteractionHandler:
@staticmethod
def _page_items(
items: List[SkillInfo],
items: List[Any],
page: int,
page_size: int,
) -> Tuple[List[SkillInfo], int, int]:
) -> Tuple[List[Any], int, int]:
"""
返回当前页的数据,并把页码钳制到有效范围内。
"""
@@ -1146,7 +1194,7 @@ class SkillInteractionHandler:
self,
request: PendingSkillInteraction,
force_market_refresh: bool = False,
) -> List[SkillInfo]:
) -> List[Any]:
"""
获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。
"""
+25 -13
View File
@@ -1,7 +1,6 @@
import re
from typing import List, Optional, Protocol, Tuple, Union
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
from app.adapters.external.server import MoviePilotServerHelper
from app.application.messaging.interaction import (
MessageGateway,
SlashInteractionManager,
@@ -12,8 +11,6 @@ from app.application.messaging.interaction import (
supports_markdown,
update_or_post_message,
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MediaType
@@ -30,6 +27,19 @@ class SubscribeInteractionActions(Protocol):
"""执行订阅刷新。"""
...
class SubscribeInteractionRepository(Protocol):
"""订阅消息交互所需的同步数据端口。"""
def list(self) -> List[Any]:
"""返回订阅列表。"""
def get(self, subscribe_id: int) -> Optional[Any]:
"""按 ID 返回订阅。"""
def delete(self, subscribe_id: int) -> Any:
"""删除订阅。"""
def check(self):
"""执行订阅元数据检查。"""
...
@@ -51,12 +61,16 @@ class SubscribeInteractionHandler:
self,
messenger: MessageGateway,
actions: SubscribeInteractionActions,
repository: SubscribeInteractionRepository,
report_deleted: Callable[[dict], Any],
):
"""
注入消息投递接口和订阅业务动作。
"""
self._messenger = messenger
self._actions = actions
self._repository = repository
self._report_deleted = report_deleted
def remote_list(
self,
@@ -401,7 +415,7 @@ class SubscribeInteractionHandler:
"""
渲染 /subscribes 当前页面。
"""
subscribes = SubscribeOper().list()
subscribes = self._repository.list()
page_size = (
self._button_page_size
if supports_interaction_buttons(channel)
@@ -475,7 +489,7 @@ class SubscribeInteractionHandler:
)
def _format_subscribe_list(
self, subscribes: List[Subscribe], channel: Optional[NotificationChannel]
self, subscribes: List[Any], channel: Optional[NotificationChannel]
) -> str:
"""
根据渠道能力格式化订阅列表。
@@ -521,7 +535,7 @@ class SubscribeInteractionHandler:
return mapping.get(state or "", state or "-")
@staticmethod
def _format_subscribe_progress(subscribe: Subscribe) -> str:
def _format_subscribe_progress(subscribe: Any) -> str:
"""
构造订阅的季和进度说明。
"""
@@ -658,11 +672,10 @@ class SubscribeInteractionHandler:
if not subscribe_ids:
return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all"
subscribeoper = SubscribeOper()
missing = []
searched = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
subscribe = self._repository.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
@@ -696,17 +709,16 @@ class SubscribeInteractionHandler:
if not subscribe_ids:
return False, "请输入至少一个有效的订阅 ID"
subscribeoper = SubscribeOper()
deleted = []
missing = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
subscribe = self._repository.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
deleted.append(subscribe.name)
subscribeoper.delete(subscribe_id)
MoviePilotServerHelper.sub_done_async(
self._repository.delete(subscribe_id)
self._report_deleted(
{
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,