refactor(chain): unify notification routing decisions

This commit is contained in:
jxxghp
2026-08-29 19:25:30 +08:00
parent d448867104
commit caee0ea374
8 changed files with 429 additions and 223 deletions

View File

@@ -5,6 +5,8 @@
eventmanager、messageoper、messagequeue 等协作对象。
"""
import copy
from collections.abc import Generator, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Union, cast
@@ -23,10 +25,137 @@ from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType, NotificationChannel
@dataclass(frozen=True, slots=True)
class _NotificationRouteLookup:
"""通知路由状态机请求读取指定用户的渠道设置。"""
username: str
log_message: str
@dataclass(frozen=True, slots=True)
class _NotificationRouteDelivery:
"""通知路由状态机产出的单次投递决策。"""
message: Message
immediately: bool
@dataclass(frozen=True, slots=True)
class _NotificationRouteLog:
"""通知路由状态机产出的无投递日志决策。"""
message: str
_NotificationRouteStep = Union[
_NotificationRouteLookup,
_NotificationRouteDelivery,
_NotificationRouteLog,
]
def _notification_route_steps(
message: Message,
*,
action: Optional[str],
superuser: str,
) -> Generator[
_NotificationRouteStep,
Optional[Mapping[str, object]],
None,
]:
"""
生成同步和异步通知共用的路由状态转换。
生成器只表达用户设置查询、日志和投递决策,调用方分别执行真实同步或
异步 I/O,避免两套外壳各自维护管理员回退和原消息投递规则。
"""
admin_sent = False
send_original = not action
for route_action in action.split(",") if action else ():
routed_message = copy.deepcopy(message)
if route_action == "admin" and not admin_sent:
settings = yield _NotificationRouteLookup(
username=superuser,
log_message=f"{routed_message.mtype} 的消息已设置发送给管理员",
)
routed_message.targets = cast(dict[str, Any], settings)
admin_sent = True
elif route_action == "user" and routed_message.username:
username = cast(str, routed_message.username)
settings = yield _NotificationRouteLookup(
username=username,
log_message=(
f"{routed_message.mtype} 的消息已设置发送给用户 {username}"
),
)
routed_message.targets = cast(dict[str, Any], settings)
if settings is None:
if not admin_sent:
settings = yield _NotificationRouteLookup(
username=superuser,
log_message=(
f"用户 {username} 不存在,消息将发送给管理员"
),
)
routed_message.targets = cast(dict[str, Any], settings)
admin_sent = True
else:
yield _NotificationRouteLog(
message=f"用户 {username} 不存在,消息无法发送到对应用户"
)
continue
elif username == superuser:
admin_sent = True
else:
send_original = not admin_sent
break
yield _NotificationRouteDelivery(
message=routed_message,
immediately=False,
)
if send_original:
yield _NotificationRouteDelivery(
message=message,
immediately=bool(message.userid),
)
def _render_notification_message(
*,
message: Optional[Message],
meta: Optional[MetaBase],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
torrentinfo: Optional[TorrentInfo],
transferinfo: Optional[TransferInfo],
kwargs: dict[str, Any],
) -> Optional[Message]:
"""使用同步和异步入口共用的上下文规范化并渲染通知。"""
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
return MessageTemplateHelper.render(
message=message,
meta=meta,
mediainfo=mediainfo,
torrentinfo=torrentinfo,
transferinfo=transferinfo,
**kwargs,
)
def _notification_route_action(message: Message) -> Optional[str]:
"""仅为未绑定真实用户的业务通知读取隔离路由配置。"""
if message.userid or not message.mtype:
return None
return get_notification_switch(message.mtype)
class MessageProcessingMixin:
__mixin_host_protocol__ = ChainRuntimeMixinHost
"""消息输入/处理状态机与通知派发规范化。"""
__mixin_host_protocol__ = ChainRuntimeMixinHost
def start_message_processing_status(
self,
channel: NotificationChannel,
@@ -119,9 +248,10 @@ class MessageProcessingMixin:
class NotificationMixin:
"""通知消息发送域:渲染、隔离路由、队列发送与消息编辑。"""
__mixin_host_protocol__ = ChainRuntimeMixinHost
user_repository: ChainUserRepository
"""通知消息发送域:渲染、隔离路由、队列发送与消息编辑。"""
def post_message(
self,
@@ -144,18 +274,14 @@ class NotificationMixin:
"""
strict_delivery = bool(kwargs.pop("_strict_delivery", False))
strict_source = kwargs.pop("_strict_source", None)
# 添加格式化的时间参数
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 渲染消息
message = MessageTemplateHelper.render(
message = _render_notification_message(
message=message,
meta=meta,
mediainfo=mediainfo,
torrentinfo=torrentinfo,
transferinfo=transferinfo,
**kwargs,
kwargs=kwargs,
)
# 检查消息是否有效
if not message:
logger.warning("消息为空,跳过发送")
return
@@ -167,87 +293,9 @@ class NotificationMixin:
dispatch_message = self._normalize_notification_for_dispatch(message)
if strict_source and dispatch_message.source == strict_source:
dispatch_message.source = None
# 发送消息按设置隔离
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
# 是否已发送管理员标志
admin_sended = False
send_orignal = False
useroper = self.user_repository
for action in actions:
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = useroper.get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
elif action == "user" and send_message.username:
# 发送对应用户
logger.info(
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = useroper.get_notification_settings(
send_message.username
)
if send_message.targets is None:
# 没有找到用户
if not admin_sended:
# 回滚发送管理员
logger.info(
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = useroper.get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
else:
# 管理员发过了,此消息不发了
logger.info(
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
)
continue
elif send_message.username == self.runtime_config.superuser:
# 管理员同名已发送
admin_sended = True
else:
# 按原消息发送全体
if not admin_sended:
send_orignal = True
break
# 按设定发送
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(send_message),
)
self._deliver_notification(
send_message,
strict_delivery=strict_delivery,
immediately=False,
kwargs=kwargs,
)
if not send_orignal:
return
# 发送消息事件
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(dispatch_message),
)
# 按原消息发送
self._deliver_notification(
self._dispatch_notification_steps(
dispatch_message,
strict_delivery=strict_delivery,
immediately=bool(dispatch_message.userid),
kwargs=kwargs,
)
@@ -288,6 +336,49 @@ class NotificationMixin:
**kwargs,
)
def _dispatch_notification_steps(
self,
message: Message,
*,
strict_delivery: bool,
kwargs: dict[str, object],
) -> None:
"""执行通知路由状态机,并在同步边界完成查询、事件和投递。"""
steps = _notification_route_steps(
message,
action=_notification_route_action(message),
superuser=self.runtime_config.superuser,
)
lookup_result: Optional[Mapping[str, object]] = None
try:
step = next(steps)
except StopIteration:
return
while True:
lookup_result = None
if isinstance(step, _NotificationRouteLookup):
logger.info(step.log_message)
lookup_result = self.user_repository.get_notification_settings(
step.username
)
elif isinstance(step, _NotificationRouteLog):
logger.info(step.message)
else:
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(step.message),
)
self._deliver_notification(
step.message,
strict_delivery=strict_delivery,
immediately=step.immediately,
kwargs=kwargs,
)
try:
step = steps.send(lookup_result)
except StopIteration:
return
async def async_post_message(
self,
message: Optional[Message] = None,
@@ -307,104 +398,68 @@ class NotificationMixin:
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 添加格式化的时间参数
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 渲染消息
message = MessageTemplateHelper.render(
message = _render_notification_message(
message=message,
meta=meta,
mediainfo=mediainfo,
torrentinfo=torrentinfo,
transferinfo=transferinfo,
**kwargs,
kwargs=kwargs,
)
# 检查消息是否有效
if not message:
logger.warning("消息为空,跳过发送")
return
if message.save_history:
await self.messageoper.async_add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
# 发送消息按设置隔离
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
# 是否已发送管理员标志
admin_sended = False
send_orignal = False
useroper = self.user_repository
for action in actions:
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = await useroper.async_get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
elif action == "user" and send_message.username:
# 发送对应用户
logger.info(
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = await useroper.async_get_notification_settings(
send_message.username
)
if send_message.targets is None:
# 没有找到用户
if not admin_sended:
# 回滚发送管理员
logger.info(
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = await useroper.async_get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
else:
# 管理员发过了,此消息不发了
logger.info(
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
)
continue
elif send_message.username == self.runtime_config.superuser:
# 管理员同名已发送
admin_sended = True
else:
# 按原消息发送全体
if not admin_sended:
send_orignal = True
break
# 按设定发送
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(send_message),
)
await self.messagequeue.async_send_message(
"post_message", message=send_message, **kwargs
)
if not send_orignal:
return
# 发送消息事件
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(dispatch_message),
await self._async_dispatch_notification_steps(
dispatch_message,
kwargs=kwargs,
)
# 按原消息发送
await self.messagequeue.async_send_message(
"post_message",
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
**kwargs,
async def _async_dispatch_notification_steps(
self,
message: Message,
*,
kwargs: dict[str, object],
) -> None:
"""执行通知路由状态机,并在异步边界完成查询、事件和投递。"""
steps = _notification_route_steps(
message,
action=_notification_route_action(message),
superuser=self.runtime_config.superuser,
)
lookup_result: Optional[Mapping[str, object]] = None
try:
step = next(steps)
except StopIteration:
return
while True:
lookup_result = None
if isinstance(step, _NotificationRouteLookup):
logger.info(step.log_message)
lookup_result = (
await self.user_repository.async_get_notification_settings(
step.username
)
)
elif isinstance(step, _NotificationRouteLog):
logger.info(step.message)
else:
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(step.message),
)
await self.messagequeue.async_send_message(
"post_message",
message=step.message,
immediately=step.immediately,
**kwargs,
)
try:
step = steps.send(lookup_result)
except StopIteration:
return
def post_medias_message(
self, message: Message, medias: List[MediaInfo]

View File

@@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 10,255 / 592 文件 | 识别链 typed plan/result 与 TMDB 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
| 全量 mypy 历史债务 | 10,245 / 592 文件 | 识别链 typed plan/result、TMDB 与消息双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
| Ruff 历史诊断 | 642 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
@@ -171,7 +171,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- Event consumer 扫描曾把任意同名 `.register()` 调用当成事件注册;S0-L2.5 已改为证明
canonical EventManager receiver,10 个动态误报归零并保留唯一 workflow 动态注册。
- S0-L2.6 已将 producer/consumer 合并为逐调用事实源;本轮统一 Transfer 事件发送点后为
95 个 producer(94 静态、1 动态)与
93 个 producer(92 静态、1 动态)与
17 个 consumer(16 静态、1 动态);consumer 由不可自动写入的精确人工 policy 管理。
**目标与步骤**

View File

@@ -400,8 +400,8 @@ collector 只接受 canonical `eventmanager`、`EventManager()` 及其有限别
`register`/`add_event_listener`;当前宿主有 16 个静态注册点,另保留 1 个由工作流配置驱动的
真实动态注册。`app/plugins/**` 插件副本不进入宿主事实。
生产者与消费者共用 `scripts/architecture/event_facts.py` 这一份逐调用事实源。当前宿主有 95 个
生产调用,其中 94 个静态解析为 96 个事件引用,只有 `Command.send_plugin_event` 的插件事件类型
生产者与消费者共用 `scripts/architecture/event_facts.py` 这一份逐调用事实源。当前宿主有 93 个
生产调用,其中 92 个静态解析为 94 个事件引用,只有 `Command.send_plugin_event` 的插件事件类型
保持动态;17 个消费注册中 16 个静态、1 个动态。生成的
`runtime-contract-baseline.json` 保存 line-free 事实、数量和枚举索引;人工维护的
`runtime-contract-policy.json` 只批准 consumer 的精确 fingerprint、owner 和理由,任何新增、替换、
@@ -740,7 +740,7 @@ flowchart LR
stream/vendor/diagnostic/control-plane 事实是精确 containment。每条初始边的指纹由测试独立冻结,
bindings/uses 变化、分类互换、通配导入和初始边增长都会失败;债务删除时同步删除冻结项以禁止恢复,
`--write-host` 不会改写人工 policy 或冻结上界。
- `event_facts` 是生产者/消费者唯一收集源;运行快照记录 95 个生产调用和 17 个消费注册,
- `event_facts` 是生产者/消费者唯一收集源;运行快照记录 93 个生产调用和 17 个消费注册,
consumer 的 17 个唯一 fingerprint 另由只读人工 policy 精确准入。CI 将语义 policy 与生成快照
分成独立步骤,前者不能通过刷新后者绕过。
- 任何所有权迁移必须同步更新:canonical 导入、`app/runtime/compat/manifest.py`、
@@ -761,7 +761,7 @@ flowchart LR
| Direct egress | 53(债务已清零,53 条精确 containment) |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
| Event Contract | 53 |
| Event producer / consumer | 95(94 静态、1 动态)/ 17(16 静态、1 动态) |
| Event producer / consumer | 93(92 静态、1 动态)/ 17(16 静态、1 动态) |
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |

View File

@@ -1248,8 +1248,8 @@
"no-untyped-def": 2
},
"app/chain/_messaging.py": {
"arg-type": 2,
"attr-defined": 35,
"arg-type": 1,
"attr-defined": 26,
"no-any-return": 6,
"no-untyped-call": 2,
"no-untyped-def": 2,

View File

@@ -1829,10 +1829,8 @@
"043b1451d0995070b782b4c0e78186cbb1c019c9cfd23500cd9c84c0d1992218"
],
"producer_fingerprints": [
"233eaefcad5d7635f25d05f7962774aa30890cce9bb2850186d9ac951b20e9be",
"233eaefcad5d7635f25d05f7962774aa30890cce9bb2850186d9ac951b20e9be",
"f69f4e169daad9a6e1793edf090aa773cc69459043810e1e0d739c8de144c866",
"f69f4e169daad9a6e1793edf090aa773cc69459043810e1e0d739c8de144c866"
"268702e19e01537b7e5c13981506cd3d75696d5da749ca0bd4c32eea1d7905a8",
"42f78d867c3555fe7ce92874c84226a6cd420535cb8addaf7d9f6c97d8b58a1d"
]
},
"EventType.PluginAction": {
@@ -1968,11 +1966,11 @@
]
}
},
"fact_count": 112,
"fact_count": 110,
"invalid_consumer_count": 0,
"invalid_producer_count": 0,
"producer_call_count": 95,
"producer_event_reference_count": 96,
"producer_call_count": 93,
"producer_event_reference_count": 94,
"producers": [
{
"caller": "app.agent.orchestrator",
@@ -2232,10 +2230,10 @@
"events": [
"EventType.NoticeMessage"
],
"fingerprint": "233eaefcad5d7635f25d05f7962774aa30890cce9bb2850186d9ac951b20e9be",
"fingerprint": "42f78d867c3555fe7ce92874c84226a6cd420535cb8addaf7d9f6c97d8b58a1d",
"invalid": false,
"method": "async_send_event",
"qualname": "NotificationMixin.async_post_message",
"qualname": "NotificationMixin._async_dispatch_notification_steps",
"receiver_kind": "injected_event_manager"
},
{
@@ -2244,34 +2242,10 @@
"events": [
"EventType.NoticeMessage"
],
"fingerprint": "233eaefcad5d7635f25d05f7962774aa30890cce9bb2850186d9ac951b20e9be",
"invalid": false,
"method": "async_send_event",
"qualname": "NotificationMixin.async_post_message",
"receiver_kind": "injected_event_manager"
},
{
"caller": "app.chain._messaging",
"dynamic": false,
"events": [
"EventType.NoticeMessage"
],
"fingerprint": "f69f4e169daad9a6e1793edf090aa773cc69459043810e1e0d739c8de144c866",
"fingerprint": "268702e19e01537b7e5c13981506cd3d75696d5da749ca0bd4c32eea1d7905a8",
"invalid": false,
"method": "send_event",
"qualname": "NotificationMixin.post_message",
"receiver_kind": "injected_event_manager"
},
{
"caller": "app.chain._messaging",
"dynamic": false,
"events": [
"EventType.NoticeMessage"
],
"fingerprint": "f69f4e169daad9a6e1793edf090aa773cc69459043810e1e0d739c8de144c866",
"invalid": false,
"method": "send_event",
"qualname": "NotificationMixin.post_message",
"qualname": "NotificationMixin._dispatch_notification_steps",
"receiver_kind": "injected_event_manager"
},
{
@@ -3116,7 +3090,7 @@
}
],
"static_consumer_count": 16,
"static_producer_call_count": 94
"static_producer_call_count": 92
},
"event_specs": {
"ChainEventType.AgentLLMProvider": {

View File

@@ -463,17 +463,17 @@ def test_event_contract_baseline_covers_every_public_event_enum() -> None:
assert set(events["event_index"]) == expected
assert events["event_count"] == len(expected)
assert events["producer_call_count"] == 95
assert events["static_producer_call_count"] == 94
assert events["producer_call_count"] == 93
assert events["static_producer_call_count"] == 92
assert events["dynamic_producer_count"] == 1
assert events["invalid_producer_count"] == 0
assert events["producer_event_reference_count"] == 96
assert events["producer_event_reference_count"] == 94
assert events["consumer_registration_count"] == 17
assert events["static_consumer_count"] == 16
assert events["dynamic_consumer_count"] == 1
assert events["invalid_consumer_count"] == 0
assert events["consumer_event_reference_count"] == 16
assert events["fact_count"] == 112
assert events["fact_count"] == 110
assert len({fact["fingerprint"] for fact in events["consumers"]}) == 17
assert all(
not fact["caller"].startswith("app.plugins")

View File

@@ -1142,7 +1142,7 @@ def handler(event):
def test_collect_event_facts_matches_current_host_inventory() -> None:
"""统一事实覆盖当前 95 个 producer 调用与 17 个 consumer 调用。"""
"""统一事实覆盖当前 93 个 producer 调用与 17 个 consumer 调用。"""
from scripts.architecture.baseline import (
_event_enum_members,
discover_modules,
@@ -1158,11 +1158,11 @@ def test_collect_event_facts_matches_current_host_inventory() -> None:
producers = facts["producers"]
consumers = facts["consumers"]
assert len(producers) == 95
assert sum(not fact["dynamic"] and not fact["invalid"] for fact in producers) == 94
assert len(producers) == 93
assert sum(not fact["dynamic"] and not fact["invalid"] for fact in producers) == 92
assert sum(fact["dynamic"] for fact in producers) == 1
assert sum(fact["invalid"] for fact in producers) == 0
assert sum(len(fact["events"]) for fact in producers) == 96
assert sum(len(fact["events"]) for fact in producers) == 94
assert len(consumers) == 17
assert sum(not fact["dynamic"] and not fact["invalid"] for fact in consumers) == 16
assert sum(fact["dynamic"] for fact in consumers) == 1

View File

@@ -0,0 +1,177 @@
import asyncio
from types import SimpleNamespace
from typing import Optional, Union
from unittest.mock import AsyncMock, Mock
import pytest
from app.chain._messaging import MessageProcessingMixin, NotificationMixin
from app.schemas.message import Message
from app.schemas.types import MessageType
class _NotificationSettingsRepository:
"""记录同步和异步通知设置查询,并返回同一份测试快照。"""
def __init__(
self,
settings: dict[str, Optional[dict[str, object]]],
*,
failure: Optional[str] = None,
) -> None:
"""保存测试设置、失败用户名和两种查询调用记录。"""
self.settings = settings
self.failure = failure
self.sync_calls: list[str] = []
self.async_calls: list[str] = []
def get_notification_settings(
self, username: str
) -> Optional[dict[str, object]]:
"""同步返回指定用户的通知设置。"""
self.sync_calls.append(username)
if username == self.failure:
raise RuntimeError(f"无法读取 {username} 的通知设置")
return self.settings.get(username)
async def async_get_notification_settings(
self, username: str
) -> Optional[dict[str, object]]:
"""异步返回指定用户的通知设置。"""
self.async_calls.append(username)
if username == self.failure:
raise RuntimeError(f"无法读取 {username} 的通知设置")
return self.settings.get(username)
class _NotificationHarness(MessageProcessingMixin, NotificationMixin):
"""提供通知 mixin 所需的最小可观测同步和异步边界。"""
def __init__(self, repository: _NotificationSettingsRepository) -> None:
"""注入用户设置仓库及可断言的历史、事件和队列替身。"""
self.user_repository = repository
self.runtime_config = SimpleNamespace(superuser="admin")
self.messageoper = SimpleNamespace(
add=Mock(),
async_add=AsyncMock(),
exists_by_source=Mock(return_value=False),
)
self.eventmanager = SimpleNamespace(
send_event=Mock(),
async_send_event=AsyncMock(),
)
self.messagequeue = SimpleNamespace(
send_message=Mock(),
async_send_message=AsyncMock(),
)
def _delivery_snapshot(mock: Union[Mock, AsyncMock]) -> list[tuple[dict, bool]]:
"""把队列调用投影为与同步方式无关的消息和立即投递标志。"""
return [
(
call.kwargs["message"].model_dump(mode="json"),
bool(call.kwargs.get("immediately", False)),
)
for call in mock.call_args_list
]
def _event_snapshot(mock: Union[Mock, AsyncMock]) -> list[dict]:
"""返回通知事件调用中的稳定数据载荷。"""
return [call.kwargs["data"] for call in mock.call_args_list]
@pytest.mark.parametrize(
("action", "settings", "expected_users", "expected_targets"),
[
(
"user,all",
{"alice": {"telegram": "1001"}},
["alice"],
[{"telegram": "1001"}, None],
),
(
"user,admin",
{"alice": None, "admin": {"telegram": "9001"}},
["alice", "admin"],
[{"telegram": "9001"}],
),
(
"admin,user",
{"alice": None, "admin": {"telegram": "9001"}},
["admin", "alice"],
[{"telegram": "9001"}],
),
],
)
def test_notification_routing_sync_async_parity(
monkeypatch,
action: str,
settings: dict[str, Optional[dict[str, object]]],
expected_users: list[str],
expected_targets: list[Optional[dict[str, object]]],
) -> None:
"""同步与异步入口应共享用户路由、管理员回退和原消息投递决策。"""
monkeypatch.setattr(
"app.chain._messaging.get_notification_switch",
lambda _mtype: action,
)
sync_repository = _NotificationSettingsRepository(settings)
async_repository = _NotificationSettingsRepository(settings)
sync_chain = _NotificationHarness(sync_repository)
async_chain = _NotificationHarness(async_repository)
message = Message(
mtype=MessageType.Download,
title="下载完成",
text="影片已加入下载器",
username="alice",
)
sync_chain.post_message(message.model_copy(deep=True))
asyncio.run(async_chain.async_post_message(message.model_copy(deep=True)))
sync_deliveries = _delivery_snapshot(sync_chain.messagequeue.send_message)
async_deliveries = _delivery_snapshot(
async_chain.messagequeue.async_send_message
)
assert sync_repository.sync_calls == expected_users
assert async_repository.async_calls == expected_users
assert [item[0]["targets"] for item in sync_deliveries] == expected_targets
assert sync_deliveries == async_deliveries
assert _event_snapshot(sync_chain.eventmanager.send_event) == _event_snapshot(
async_chain.eventmanager.async_send_event
)
assert sync_chain.messageoper.add.call_count == 1
assert async_chain.messageoper.async_add.await_count == 1
def test_notification_routing_lookup_failure_sync_async_parity(monkeypatch) -> None:
"""用户设置查询失败时两种入口都应传播错误且不得产生错误路由投递。"""
monkeypatch.setattr(
"app.chain._messaging.get_notification_switch",
lambda _mtype: "user",
)
sync_chain = _NotificationHarness(
_NotificationSettingsRepository({}, failure="alice")
)
async_chain = _NotificationHarness(
_NotificationSettingsRepository({}, failure="alice")
)
message = Message(
mtype=MessageType.Download,
title="下载完成",
username="alice",
)
with pytest.raises(RuntimeError, match="无法读取 alice"):
sync_chain.post_message(message.model_copy(deep=True))
with pytest.raises(RuntimeError, match="无法读取 alice"):
asyncio.run(async_chain.async_post_message(message.model_copy(deep=True)))
sync_chain.eventmanager.send_event.assert_not_called()
async_chain.eventmanager.async_send_event.assert_not_awaited()
sync_chain.messagequeue.send_message.assert_not_called()
async_chain.messagequeue.async_send_message.assert_not_awaited()
assert sync_chain.messageoper.add.call_count == 1
assert async_chain.messageoper.async_add.await_count == 1