refactor(subscription): enforce atomic mutation boundaries

This commit is contained in:
jxxghp
2026-08-28 17:39:20 +08:00
parent eb45ed6b77
commit d9e4a973c8
63 changed files with 3349 additions and 986 deletions

View File

@@ -2,14 +2,13 @@
import copy
import re
from typing import Any, Callable, Dict, Iterable, Optional
from typing import Any, Dict, Iterable, Optional
from app.application.configuration import get_configured_system_config
from app.application.rules import (
BUILTIN_RULE_SET,
RuleHelper,
RuleParser,
replace_group_name_in_list,
)
from app.application.subscription.contract import SubscriptionRepository
from app.runtime.events import eventmanager
@@ -437,11 +436,11 @@ async def save_system_config(
success = await get_configured_system_config().async_set(key, normalized_value)
if success:
await _publish_rule_config_changed(key, normalized_value)
await publish_rule_config_changed(key, normalized_value)
return success
async def _publish_rule_config_changed(
async def publish_rule_config_changed(
key: SystemConfigKey,
value: Any,
) -> None:
@@ -456,85 +455,6 @@ async def _publish_rule_config_changed(
)
async def _rewrite_rule_group_references(
repository: SubscriptionRepository,
map_names: Callable[[Iterable[str]], list[str]],
) -> dict:
"""按名称映射器更新全局、默认订阅配置和已有订阅引用。"""
changed = {
"global_settings": {},
"subscribes": [],
}
system_config = get_configured_system_config()
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = system_config.get(config_key) or []
updated = map_names(original)
if updated != original:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
for config_key in (
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
):
original = system_config.get(config_key) or {}
original_groups = original.get("filter_groups") or []
updated_groups = map_names(original_groups)
if updated_groups == original_groups:
continue
updated = copy.deepcopy(original)
updated["filter_groups"] = updated_groups
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
subscribes = await repository.async_list()
for subscribe in subscribes:
original = [str(name) for name in subscribe.filter_groups] \
if isinstance(subscribe.filter_groups, list) else []
updated = map_names(original)
if updated == original:
continue
await repository.async_update_filter_groups(subscribe.id, updated)
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
return changed
async def rename_rule_group_references(
repository: SubscriptionRepository,
old_name: str,
new_name: str,
) -> dict:
"""规则组改名后,联动更新全部配置和已有订阅引用。"""
return await _rewrite_rule_group_references(
repository,
lambda values: replace_group_name_in_list(values, old_name, new_name)
)
async def remove_rule_group_references(
repository: SubscriptionRepository,
group_name: str,
) -> dict:
"""删除规则组后,清理全部配置和已有订阅中的悬空引用。"""
return await _rewrite_rule_group_references(
repository,
lambda values: [value for value in values or [] if value != group_name]
)
def replace_rule_id_in_rule_string(
rule_string: str, old_rule_id: str, new_rule_id: str
) -> str:

View File

@@ -13,7 +13,7 @@ from app.agent.tools.impl._filter_rule_utils import (
get_custom_rules,
get_rule_groups,
normalize_rule_group,
save_system_config,
publish_rule_config_changed,
serialize_rule_group,
)
from app.agent.tools.tags import ToolTag
@@ -78,6 +78,9 @@ class AddRuleGroupTool(MoviePilotTool):
build_custom_rule_map(custom_rules).keys()
)
rule_groups = get_rule_groups()
expected_definitions = [
group.model_dump(exclude_none=True) for group in rule_groups
]
new_group, _ = normalize_rule_group(
name=name,
rule_string=rule_string,
@@ -88,9 +91,17 @@ class AddRuleGroupTool(MoviePilotTool):
)
rule_groups.append(new_group)
await save_system_config(
definitions = [
group.model_dump(exclude_none=True) for group in rule_groups
]
async with self.data.async_rule_group_mutation_scope() as mutation:
await mutation.apply(
definitions,
expected_rule_groups=expected_definitions,
)
await publish_rule_config_changed(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in rule_groups],
definitions,
)
usage = await collect_rule_group_usages(
self.data.subscriptions,

View File

@@ -8,8 +8,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
get_rule_groups,
remove_rule_group_references,
save_system_config,
publish_rule_config_changed,
)
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
@@ -44,6 +43,9 @@ class DeleteRuleGroupTool(MoviePilotTool):
try:
rule_groups = get_rule_groups()
expected_definitions = [
group.model_dump(exclude_none=True) for group in rule_groups
]
if not any(group.name == name for group in rule_groups):
return json.dumps(
{
@@ -56,14 +58,20 @@ class DeleteRuleGroupTool(MoviePilotTool):
remaining_groups = [
group for group in rule_groups if group.name != name
]
await save_system_config(
definitions = [
group.model_dump(exclude_none=True) for group in remaining_groups
]
async with self.data.async_rule_group_mutation_scope() as mutation:
result = await mutation.apply(
definitions,
expected_rule_groups=expected_definitions,
previous_name=name,
)
await publish_rule_config_changed(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in remaining_groups],
)
reference_changes = await remove_rule_group_references(
self.data.subscriptions,
name,
definitions,
)
reference_changes = result.to_dict()
return json.dumps(
{

View File

@@ -8,12 +8,8 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.application.subscription.delete import (
SubscribeDeletionActor,
get_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.application.subscription.mutation import SubscriptionActor
from app.runtime.log import logger
@@ -49,7 +45,7 @@ class DeleteSubscribeTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
try:
async with get_subscription_mutation_scope() as mutation:
async with self.data.subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(
subscribe_id,
SubscriptionActor(name="agent", is_superuser=True),
@@ -57,7 +53,7 @@ class DeleteSubscribeTool(MoviePilotTool):
if not subscribe:
return f"订阅 ID {subscribe_id} 不存在"
async with get_delete_subscribe_scope() as command:
async with self.data.subscription_delete_scope() as command:
deleted = await command.execute(
subscribe_id,
SubscribeDeletionActor(username="agent", is_superuser=True),

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.application.subscription.contract import SubscriptionPatch
from app.application.subscription.mutation import SubscriptionActor
from app.chain.subscribe import SubscribeChain
from app.runtime.log import logger
from app.schemas.types import media_type_to_agent
@@ -93,10 +93,14 @@ class SearchSubscribeTool(MoviePilotTool):
# 如果提供了 filter_groups 参数,先更新订阅的规则组
if filter_groups is not None:
await repository.async_update(
subscribe_id,
SubscriptionPatch({"filter_groups": filter_groups}),
)
async with self.data.subscription_mutation_scope() as mutation:
await mutation.update(
subscribe_id,
{"filter_groups": filter_groups},
SubscriptionActor(name="agent", is_superuser=True),
existing=subscribe,
scene="agent_search",
)
logger.info(f"更新订阅 #{subscribe_id} 的规则组为: {filter_groups}")
# 订阅搜索会触发大量同步站点访问,统一走 subscribe 线程池。

View File

@@ -6,16 +6,17 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
get_rule_groups,
normalize_custom_rule,
publish_rule_config_changed,
replace_rule_id_in_rule_string,
save_system_config,
serialize_custom_rule,
)
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
@@ -121,7 +122,21 @@ class UpdateCustomFilterRuleTool(MoviePilotTool):
original_rule_id=current_rule.id,
)
expected_custom_rules = [
rule.model_dump(exclude_none=True) for rule in custom_rules
]
final_rules = [
updated_rule if rule.id == current_rule.id else rule
for rule in custom_rules
]
custom_rule_definitions = [
rule.model_dump(exclude_none=True) for rule in final_rules
]
rule_groups = get_rule_groups()
expected_rule_groups = [
group.model_dump(exclude_none=True) for group in rule_groups
]
updated_rule_groups = rule_groups
renamed_group_refs = []
if updated_rule.id != current_rule.id:
@@ -143,26 +158,30 @@ class UpdateCustomFilterRuleTool(MoviePilotTool):
group.model_copy(update={"rule_string": new_rule_string})
)
# 先保存规则组引用,再保存规则自身,避免在过滤模块重载时出现新规则 ID 尚未同步的问题。
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[
group.model_dump(exclude_none=True)
for group in updated_rule_groups
],
rule_group_definitions = [
group.model_dump(exclude_none=True)
for group in updated_rule_groups
]
async with self.data.async_rule_group_mutation_scope() as mutation:
await mutation.apply(
rule_group_definitions,
expected_rule_groups=expected_rule_groups,
custom_rules=custom_rule_definitions,
expected_custom_rules=expected_custom_rules,
)
await publish_rule_config_changed(
SystemConfigKey.CustomFilterRules,
custom_rule_definitions,
)
await publish_rule_config_changed(
SystemConfigKey.UserFilterRuleGroups,
rule_group_definitions,
)
else:
await save_system_config(
SystemConfigKey.CustomFilterRules,
custom_rule_definitions,
)
final_rules = []
for rule in custom_rules:
if rule.id == current_rule.id:
final_rules.append(updated_rule)
else:
final_rules.append(rule)
await save_system_config(
SystemConfigKey.CustomFilterRules,
[rule.model_dump(exclude_none=True) for rule in final_rules],
)
updated_refs = collect_custom_rule_group_refs(
updated_rule_groups,

View File

@@ -13,8 +13,7 @@ from app.agent.tools.impl._filter_rule_utils import (
get_custom_rules,
get_rule_groups,
normalize_rule_group,
rename_rule_group_references,
save_system_config,
publish_rule_config_changed,
serialize_rule_group,
)
from app.agent.tools.tags import ToolTag
@@ -82,6 +81,9 @@ class UpdateRuleGroupTool(MoviePilotTool):
try:
rule_groups = get_rule_groups()
expected_definitions = [
group.model_dump(exclude_none=True) for group in rule_groups
]
group_map = {group.name: group for group in rule_groups if group.name}
current_group = group_map.get(current_name)
if not current_group:
@@ -123,18 +125,21 @@ class UpdateRuleGroupTool(MoviePilotTool):
else:
final_groups.append(group)
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in final_groups],
)
reference_changes = {}
if updated_group.name != current_group.name:
reference_changes = await rename_rule_group_references(
self.data.subscriptions,
current_group.name,
updated_group.name,
definitions = [
group.model_dump(exclude_none=True) for group in final_groups
]
async with self.data.async_rule_group_mutation_scope() as mutation:
result = await mutation.apply(
definitions,
expected_rule_groups=expected_definitions,
previous_name=current_group.name,
current_name=updated_group.name,
)
await publish_rule_config_changed(
SystemConfigKey.UserFilterRuleGroups,
definitions,
)
reference_changes = result.to_dict()
usage = await collect_rule_group_usages(
self.data.subscriptions,

View File

@@ -7,10 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.application.subscription.mutation import SubscriptionActor
from app.runtime.log import logger
from app.schemas.common import JsonData
from app.schemas.types import media_type_to_agent
@@ -175,7 +172,7 @@ class UpdateSubscribeTool(MoviePilotTool):
try:
actor = SubscriptionActor(name="agent", is_superuser=True)
async with get_subscription_mutation_scope() as mutation:
async with self.data.subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(subscribe_id, actor)
if not subscribe:
return json.dumps(
@@ -308,7 +305,7 @@ class UpdateSubscribeTool(MoviePilotTool):
# Agent 工具没有 FastAPI 请求会话,由组合根提供一次独占事务作用域;
# 更新和 durable intent 必须共享同一 AsyncSession。
async with get_subscription_mutation_scope() as mutation:
async with self.data.subscription_mutation_scope() as mutation:
change = await mutation.update(
subscribe_id,
subscribe_dict,

View File

@@ -24,6 +24,7 @@ from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.scheduling import start_scheduler_job
from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.contract import (
SessionSubscriptionPort,
SubscriptionHistoryStagingPort,
SubscriptionStagingPort,
)
@@ -41,6 +42,9 @@ from app.application.subscription.mutation import (
)
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.search import SearchSubscriptionsCommand
from app.application.subscription.write import (
SubscriptionBatchWritePort,
)
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.runtime.tasks import TaskRegistry
@@ -173,7 +177,7 @@ def get_subscription_query_service(
def get_subscription_mutation_service(
repository_port: SubscriptionStagingPort = Depends(get_subscription_repository),
repository_port: SessionSubscriptionPort = Depends(get_subscription_repository),
history_repository: SubscriptionHistoryStagingPort = Depends(
get_subscription_history_repository
),
@@ -194,13 +198,21 @@ def get_subscription_mutation_service(
)
def get_subscription_sync_mutation_service(
db: Session = Depends(get_sync_session),
def get_servarr_subscription_batch_writer(
repository_port: SubscriptionStagingPort = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(
get_subscription_outbox_store
),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionMutationService:
"""组装同步订阅查询服务,供文件信息接口使用"""
return SubscriptionMutationService(
repository=runtime.subscription.repository(db)
) -> SubscriptionBatchWritePort:
"""组装 Servarr 多季订阅使用的请求级原子批量写端口"""
return runtime.subscription.batch_writer(
repository=repository_port,
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
outbox=outbox,
dispatch_store=dispatch_store,
)

View File

@@ -42,7 +42,6 @@ from app.api.dependencies.subscription import (
get_servarr_subscription_service,
get_subscription_mutation_service,
get_subscription_query_service,
get_subscription_sync_mutation_service,
)
from app.api.dependencies.workflow import (
get_workflow_definition_command,
@@ -78,7 +77,6 @@ __all__ = [
"get_site_sync_query_service",
"get_subscription_mutation_service",
"get_subscription_query_service",
"get_subscription_sync_mutation_service",
"get_transfer_history_lookup_service",
"get_transfer_history_mutation_command",
"get_user_service",

View File

@@ -7,6 +7,7 @@ from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.web.security.access import verify_apitoken, verify_token
from app.api.context import (
get_background_task_registry,
get_subscription_repository,
resolve_background_task_registry,
)
from app.api.dependencies.auth import (
@@ -19,7 +20,6 @@ from app.api.dependencies.subscription import (
get_search_subscriptions_command,
get_subscription_mutation_service,
get_subscription_query_service,
get_subscription_sync_mutation_service,
)
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
@@ -28,6 +28,7 @@ from app.application.configuration import (
get_configured_system_config,
)
from app.application.scheduling import get_scheduler
from app.application.subscription.contract import SubscriptionQueryPort
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SubscribeDeletionActor,
@@ -47,10 +48,8 @@ from app.application.subscription.search import (
from app.chain.subscribe import SubscribeChain
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.runtime.events import eventmanager
from app.runtime.tasks import TaskRegistry
from app.schemas.common import IdData as _SchemaIdData
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.response import Response as _SchemaResponse
from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo
@@ -60,7 +59,6 @@ from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
EventType,
MediaSource,
MediaType,
SystemConfigKey,
@@ -250,7 +248,6 @@ async def update_subscribe(
subscribe = await mutation.get_accessible(subscribe_in.id, actor)
if not subscribe:
return _SchemaResponse(success=False, message="订阅不存在")
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True)
identity_fields = {"media_source", "media_id"}.intersection(
subscribe_in.model_fields_set
@@ -301,16 +298,6 @@ async def update_subscribe(
)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_in.id,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="update",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -334,16 +321,6 @@ async def update_subscribe_status(
change = await mutation.update_status(subid, state, actor)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="status",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -395,16 +372,6 @@ async def reset_subscribes(
)
change = await mutation.reset(subid, actor)
if change:
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="reset",
).to_dict(),
)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False, message="订阅不存在")
@@ -686,7 +653,7 @@ async def user_subscribes(
)
def subscribe_files(
subscribe_id: int,
mutation: SubscriptionMutationService = Depends(get_subscription_sync_mutation_service),
repository: SubscriptionQueryPort = Depends(get_subscription_repository),
current_user: ApiPrincipal = Depends(get_current_active_user),
) -> Any:
"""
@@ -696,8 +663,8 @@ def subscribe_files(
name=current_user.name,
is_superuser=current_user.is_superuser,
)
subscribe = mutation.get_accessible_sync(subscribe_id, actor)
if subscribe:
subscribe = repository.get(subscribe_id)
if subscribe is not None and SubscriptionMutationService.can_access(subscribe, actor):
return SubscribeChain().subscribe_files_info(subscribe)
return _SchemaSubscrbieInfo()

View File

@@ -27,6 +27,7 @@ from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.adapters.system import rust as rust_accel
from app.adapters.system.update import system_update_manager
from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token
from app.api.context import get_host_runtime
from app.api.dependencies.auth import (
get_current_active_superuser,
get_current_active_superuser_async,
@@ -64,8 +65,8 @@ from app.runtime.progress import AsyncProgressHelper
from app.runtime.scheduling import TimerUtils
from app.runtime.state import SystemHelper
from app.runtime.stop import runtime_stop_state
from app.runtime.config import global_vars
from app.runtime.version import get_app_version, get_frontend_version
from app.schemas.common import JsonData
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.common import JsonObjectList as _SchemaJsonObjectList
from app.schemas.common import TimeData as _SchemaTimeData
@@ -85,6 +86,7 @@ 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
router = ResponseAPIRouter()
@@ -1169,6 +1171,7 @@ async def set_setting(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
runtime: HostRuntime = Depends(get_host_runtime),
):
"""
更新系统设置(仅管理员)
@@ -1191,17 +1194,38 @@ async def set_setting(
value = value if value else None
try:
with plugin_system_config_mutation(key):
success = await get_configured_system_config().async_set(key, value)
if success or (
success is None
and key == SystemConfigKey.UserFilterRuleGroups.value
):
event_value: JsonData
if key == SystemConfigKey.UserFilterRuleGroups.value:
current_value = get_configured_system_config().get(
SystemConfigKey.UserFilterRuleGroups
)
expected_definitions = [
dict(item)
for item in current_value or []
if isinstance(item, dict)
] if isinstance(current_value, list) else []
definitions = [
dict(item) for item in value or [] if isinstance(item, dict)
] if isinstance(value, list) else []
async with (
runtime.subscription.async_rule_group_mutation_scope()
) as mutation:
await mutation.apply(
definitions,
expected_rule_groups=expected_definitions,
)
event_value = definitions
success = True
else:
success = await get_configured_system_config().async_set(key, value)
event_value = value
if success:
# 发送配置变更事件
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=value,
value=event_value,
change_type="update",
),
)

View File

@@ -3,9 +3,16 @@ from typing import Annotated, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from app.adapters.web.security.access import verify_apikey
from app.api.dependencies.subscription import get_servarr_subscription_service
from app.api.dependencies.subscription import (
get_servarr_subscription_batch_writer,
get_servarr_subscription_service,
)
from app.api.response import ERROR_RESPONSES
from app.application.servarr import ServarrSubscription, ServarrSubscriptionService
from app.application.subscription.write import (
SubscriptionBatchWriteError,
SubscriptionBatchWritePort,
)
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.chain.tvdb import TvdbChain
@@ -780,6 +787,10 @@ async def arr_add_series(
ServarrSubscriptionService,
Depends(get_servarr_subscription_service),
],
batch_writer: Annotated[
SubscriptionBatchWritePort,
Depends(get_servarr_subscription_batch_writer),
],
) -> _SchemaServarrIdResponse:
"""
新增Sonarr剧集订阅
@@ -830,24 +841,27 @@ async def arr_add_series(
# 全部已存在订阅
if not left_seasons:
return _SchemaServarrIdResponse(id=1)
# 剩下的添加订阅
sid = 0
message = ""
for season in left_seasons:
sid, message = await SubscribeChain().async_add(
try:
sid, message = await SubscribeChain().async_add_batch(
title=tv.title,
year=tv.year,
season=season,
seasons=left_seasons,
batch_writer=batch_writer,
media_source=MediaSource.TMDB,
media_id=str(tv.tmdbId),
mtype=MediaType.TV,
username="Seerr",
)
except SubscriptionBatchWriteError as error:
raise HTTPException(
status_code=500,
detail=f"添加订阅失败:{error}",
) from error
if sid:
return _SchemaServarrIdResponse(id=sid)
else:
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
@arr_router.put(
@@ -860,11 +874,20 @@ async def arr_update_series(
ServarrSubscriptionService,
Depends(get_servarr_subscription_service),
],
batch_writer: Annotated[
SubscriptionBatchWritePort,
Depends(get_servarr_subscription_batch_writer),
],
) -> _SchemaServarrIdResponse:
"""
更新Sonarr剧集订阅
"""
return await arr_add_series(tv=tv, _=_, subscriptions=subscriptions)
return await arr_add_series(
tv=tv,
_=_,
subscriptions=subscriptions,
batch_writer=batch_writer,
)
@arr_router.delete(

View File

@@ -9,8 +9,11 @@ chain 层需要触发 Agent 后台任务、渲染提示词、查询模型能力
本模块禁止静态或函数内导入 app.agent否则会重新形成跨层循环依赖。
"""
from __future__ import annotations
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any, Callable, Optional
from typing import TYPE_CHECKING, Any, Callable, Optional
from app.application.agenttask import AgentTaskRepository
from app.application.history import DownloadHistoryRepository, TransferHistoryRepository
@@ -27,6 +30,11 @@ from app.application.subscription.contract import (
)
from app.application.transfer.execution import TransferExecutionRepository
if TYPE_CHECKING:
from app.application.rules import AsyncRuleGroupMutationService
from app.application.subscription.delete import DeleteSubscribeScope
from app.application.subscription.mutation import SubscriptionMutationScope
@dataclass(frozen=True, slots=True)
class AgentDataContext:
@@ -38,6 +46,11 @@ class AgentDataContext:
users: ChainUserRepository
sites: SiteRepository
subscriptions: SubscriptionRepository
subscription_mutation_scope: SubscriptionMutationScope
subscription_delete_scope: DeleteSubscribeScope
async_rule_group_mutation_scope: Callable[
[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]
]
subscription_history: SubscriptionHistoryQueryPort
transfer_history: TransferHistoryRepository
transfer_execution: TransferExecutionRepository

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Optional
@@ -17,9 +18,20 @@ if TYPE_CHECKING:
TransferHistoryRepository,
)
from app.application.mediaserver import MediaServerRepository
from app.application.rules import SyncRuleGroupMutationService
from app.application.security.user import ChainUserRepository
from app.application.site.contract import SiteRepository
from app.application.site.mutation import SyncSiteReferenceMutationService
from app.application.subscription.complete import CompletionScope
from app.application.subscription.contract import SubscriptionRepository
from app.application.subscription.delete import (
DeleteSubscribeScope,
SyncDeleteSubscribeScope,
)
from app.application.subscription.mutation import (
SubscriptionMutationScope,
SyncSubscriptionMutationScope,
)
from app.application.transfer.execution import TransferExecutionRepository
from app.application.transfer.workflow import TransferAdmissionRepository
@@ -44,6 +56,17 @@ class ChainRuntimeContext:
module_dispatcher_factory: ModuleDispatcherFactory
site_repository: SiteRepository
subscription_repository: SubscriptionRepository
subscription_mutation_scope: SubscriptionMutationScope
sync_subscription_mutation_scope: SyncSubscriptionMutationScope
subscription_delete_scope: DeleteSubscribeScope
sync_subscription_delete_scope: SyncDeleteSubscribeScope
subscription_completion_scope: CompletionScope
rule_group_mutation_scope: Callable[
[], AbstractContextManager[SyncRuleGroupMutationService]
]
site_reference_mutation_scope: Callable[
[], AbstractContextManager[SyncSiteReferenceMutationService]
]
download_history_repository: DownloadHistoryRepository
transfer_history_repository: TransferHistoryRepository
transfer_admission_repository: TransferAdmissionRepository

View File

@@ -9,7 +9,8 @@ from pathlib import Path
from typing import Any, Optional, Protocol, cast
from app.application.database import AsyncDatabaseExecutor
from app.schemas.types import MediaType
from app.schemas.common import JsonData
from app.schemas.types import MediaType, SystemConfigKey
class SystemConfigReader(Protocol):
@@ -33,6 +34,22 @@ class ConfigurationRepository(SystemConfigReader, SystemConfigWriter, Protocol):
"""兼容同时提供读写能力的旧配置仓储。"""
class SystemConfigStagingPort(Protocol):
"""跨表应用服务在调用方 Session 内读取并暂存系统配置。"""
def get_for_update(self, key: SystemConfigKey) -> JsonData:
"""同步锁定并读取一项配置。"""
def stage_set(self, key: SystemConfigKey, value: JsonData) -> None:
"""同步暂存一项配置,不提交事务。"""
async def async_get_for_update(self, key: SystemConfigKey) -> JsonData:
"""异步锁定并读取一项配置。"""
async def async_stage_set(self, key: SystemConfigKey, value: JsonData) -> None:
"""异步暂存一项配置,不提交事务。"""
class MutableRuntimeSettings(Protocol):
"""部署设置对象对管理 API 暴露的最小可变合同。"""

View File

@@ -3,9 +3,11 @@
过滤模块与 Agent 工具共享同一事实来源。
"""
import copy
import threading
from collections.abc import Iterable
from typing import Dict, List, Optional
from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
from pyparsing import (
Combine,
@@ -21,12 +23,398 @@ from pyparsing import (
)
from app.adapters.system import rust as rust_accel
from app.application.configuration import get_configured_system_config
from app.application.configuration import (
SystemConfigStagingPort,
get_configured_system_config,
)
from app.application.outbox import SyncUnitOfWork
from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionReferenceStagingPort,
SubscriptionSnapshot,
)
from app.domain.context import MediaInfo
from app.schemas.common import JsonData
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.schemas.types import SystemConfigKey
_RULE_GROUP_LIST_CONFIG_KEYS = (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
)
_RULE_GROUP_DEFAULT_CONFIG_KEYS = (
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
)
class AsyncRuleGroupUnitOfWork(Protocol):
"""异步规则组修改事务的最小提交与回滚端口。"""
async def commit(self) -> None:
"""提交规则定义及全部引用更新。"""
async def rollback(self) -> None:
"""回滚规则定义及全部引用更新。"""
class RuleGroupMutationConflictError(RuntimeError):
"""规则定义已被并发修改,拒绝用过期快照覆盖新值。"""
@dataclass(frozen=True, slots=True)
class RuleGroupSubscriptionChange:
"""一条已提交的订阅规则组引用变更。"""
subscribe_id: int
name: str
season: Optional[int]
filter_groups: tuple[str, ...]
def to_dict(self) -> dict[str, JsonData]:
"""返回 Agent 响应可直接序列化的兼容字典。"""
return {
"subscribe_id": self.subscribe_id,
"name": self.name,
"season": self.season,
"filter_groups": list(self.filter_groups),
}
@dataclass(frozen=True, slots=True)
class RuleGroupMutation:
"""规则定义和全部引用在同一事务提交后的稳定结果。"""
configurations: Mapping[SystemConfigKey, JsonData]
subscriptions: tuple[RuleGroupSubscriptionChange, ...]
def to_dict(self) -> dict[str, JsonData]:
"""返回现有 Agent 工具使用的引用更新响应结构。"""
return {
"global_settings": {
key.value: copy.deepcopy(value)
for key, value in self.configurations.items()
if key != SystemConfigKey.UserFilterRuleGroups
},
"subscribes": [change.to_dict() for change in self.subscriptions],
}
def _map_rule_group_names(
values: object,
previous_name: Optional[str],
current_name: Optional[str],
valid_names: set[str],
) -> list[str]:
"""按删除或改名意图映射引用,并按最终定义清理悬空名称。"""
result: list[str] = []
for raw_name in values if isinstance(values, list) else []:
name = str(raw_name)
if previous_name is not None and name == previous_name:
if current_name is None:
continue
name = current_name
if name in valid_names and name not in result:
result.append(name)
return result
def _rewrite_default_config(
value: object,
previous_name: Optional[str],
current_name: Optional[str],
valid_names: set[str],
) -> dict[str, JsonData]:
"""复制默认订阅配置并只重写其中的规则组引用。"""
original = copy.deepcopy(value) if isinstance(value, dict) else {}
original_groups = original.get("filter_groups")
updated_groups = _map_rule_group_names(
original_groups, previous_name, current_name, valid_names
)
if updated_groups == (original_groups or []):
return original
original["filter_groups"] = updated_groups
return original
def _subscription_change(
subscription: SubscriptionSnapshot,
filter_groups: list[str],
) -> RuleGroupSubscriptionChange:
"""把事务内订阅快照投影为提交结果。"""
return RuleGroupSubscriptionChange(
subscribe_id=subscription.id,
name=subscription.name,
season=subscription.season,
filter_groups=tuple(filter_groups),
)
class SyncRuleGroupMutationService:
"""在一个同步 UoW 内原子提交规则定义、配置引用和订阅引用。"""
def __init__(
self,
configuration: SystemConfigStagingPort,
subscriptions: SubscriptionReferenceStagingPort,
unit_of_work: SyncUnitOfWork,
publish: Callable[[Mapping[SystemConfigKey, JsonData]], None],
) -> None:
"""注入共享 Session 的端口、UoW 和必填提交后快照发布器。"""
self._configuration = configuration
self._subscriptions = subscriptions
self._unit_of_work = unit_of_work
self._publish = publish
def apply(
self,
rule_groups: list[dict[str, JsonData]],
*,
expected_rule_groups: list[dict[str, JsonData]],
custom_rules: Optional[list[dict[str, JsonData]]] = None,
expected_custom_rules: Optional[list[dict[str, JsonData]]] = None,
previous_name: Optional[str] = None,
current_name: Optional[str] = None,
) -> RuleGroupMutation:
"""原子保存规则组及可选自定义规则,并重写全部规则组引用。"""
if (custom_rules is None) != (expected_custom_rules is None):
raise ValueError("自定义规则写入必须同时提供当前快照和目标值")
try:
result = self._stage(
rule_groups,
expected_rule_groups,
custom_rules,
expected_custom_rules,
previous_name,
current_name,
)
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
self._publish(copy.deepcopy(dict(result.configurations)))
return result
def _stage(
self,
rule_groups: list[dict[str, JsonData]],
expected_rule_groups: list[dict[str, JsonData]],
custom_rules: Optional[list[dict[str, JsonData]]],
expected_custom_rules: Optional[list[dict[str, JsonData]]],
previous_name: Optional[str],
current_name: Optional[str],
) -> RuleGroupMutation:
"""在同步 Session 中锁定事实源并暂存全部变化。"""
changes: dict[SystemConfigKey, JsonData] = {}
definitions = copy.deepcopy(rule_groups)
valid_names = {
str(group["name"])
for group in definitions
if isinstance(group.get("name"), str) and group["name"]
}
original_definitions = self._configuration.get_for_update(
SystemConfigKey.UserFilterRuleGroups
)
if (
(original_definitions or []) != expected_rule_groups
and original_definitions != definitions
):
raise RuleGroupMutationConflictError("规则组已被其他请求修改,请重新读取后再试")
if original_definitions != definitions:
self._configuration.stage_set(
SystemConfigKey.UserFilterRuleGroups, definitions
)
changes[SystemConfigKey.UserFilterRuleGroups] = definitions
if custom_rules is not None:
assert expected_custom_rules is not None
custom_definitions = copy.deepcopy(custom_rules)
original_custom_rules = self._configuration.get_for_update(
SystemConfigKey.CustomFilterRules
)
if (
(original_custom_rules or []) != expected_custom_rules
and original_custom_rules != custom_definitions
):
raise RuleGroupMutationConflictError(
"自定义规则已被其他请求修改,请重新读取后再试"
)
if original_custom_rules != custom_definitions:
self._configuration.stage_set(
SystemConfigKey.CustomFilterRules,
custom_definitions,
)
changes[SystemConfigKey.CustomFilterRules] = custom_definitions
for key in _RULE_GROUP_LIST_CONFIG_KEYS:
original = self._configuration.get_for_update(key)
list_updated = _map_rule_group_names(
original, previous_name, current_name, valid_names
)
if list_updated != (original or []):
self._configuration.stage_set(key, list_updated)
changes[key] = list_updated
for key in _RULE_GROUP_DEFAULT_CONFIG_KEYS:
original = self._configuration.get_for_update(key)
default_updated = _rewrite_default_config(
original, previous_name, current_name, valid_names
)
if default_updated != (original or {}):
self._configuration.stage_set(key, default_updated)
changes[key] = default_updated
subscription_changes = []
for subscription in self._subscriptions.list_for_reference_rewrite():
original = list(subscription.filter_groups or [])
updated = _map_rule_group_names(
original, previous_name, current_name, valid_names
)
if updated == original:
continue
self._subscriptions.stage_update(
subscription.id,
SubscriptionPatch({"filter_groups": updated}),
)
subscription_changes.append(_subscription_change(subscription, updated))
return RuleGroupMutation(changes, tuple(subscription_changes))
class AsyncRuleGroupMutationService:
"""在一个异步 UoW 内原子提交规则定义、配置引用和订阅引用。"""
def __init__(
self,
configuration: SystemConfigStagingPort,
subscriptions: SubscriptionReferenceStagingPort,
unit_of_work: AsyncRuleGroupUnitOfWork,
publish: Callable[[Mapping[SystemConfigKey, JsonData]], Awaitable[None]],
) -> None:
"""注入共享 AsyncSession 的端口、UoW 和必填提交后快照发布器。"""
self._configuration = configuration
self._subscriptions = subscriptions
self._unit_of_work = unit_of_work
self._publish = publish
async def apply(
self,
rule_groups: list[dict[str, JsonData]],
*,
expected_rule_groups: list[dict[str, JsonData]],
custom_rules: Optional[list[dict[str, JsonData]]] = None,
expected_custom_rules: Optional[list[dict[str, JsonData]]] = None,
previous_name: Optional[str] = None,
current_name: Optional[str] = None,
) -> RuleGroupMutation:
"""异步原子保存规则组及可选自定义规则,并重写全部引用。"""
if (custom_rules is None) != (expected_custom_rules is None):
raise ValueError("自定义规则写入必须同时提供当前快照和目标值")
try:
result = await self._stage(
rule_groups,
expected_rule_groups,
custom_rules,
expected_custom_rules,
previous_name,
current_name,
)
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
await self._publish(copy.deepcopy(dict(result.configurations)))
return result
async def _stage(
self,
rule_groups: list[dict[str, JsonData]],
expected_rule_groups: list[dict[str, JsonData]],
custom_rules: Optional[list[dict[str, JsonData]]],
expected_custom_rules: Optional[list[dict[str, JsonData]]],
previous_name: Optional[str],
current_name: Optional[str],
) -> RuleGroupMutation:
"""在 AsyncSession 中锁定事实源并暂存全部变化。"""
changes: dict[SystemConfigKey, JsonData] = {}
definitions = copy.deepcopy(rule_groups)
valid_names = {
str(group["name"])
for group in definitions
if isinstance(group.get("name"), str) and group["name"]
}
original_definitions = await self._configuration.async_get_for_update(
SystemConfigKey.UserFilterRuleGroups
)
if (
(original_definitions or []) != expected_rule_groups
and original_definitions != definitions
):
raise RuleGroupMutationConflictError("规则组已被其他请求修改,请重新读取后再试")
if original_definitions != definitions:
await self._configuration.async_stage_set(
SystemConfigKey.UserFilterRuleGroups, definitions
)
changes[SystemConfigKey.UserFilterRuleGroups] = definitions
if custom_rules is not None:
assert expected_custom_rules is not None
custom_definitions = copy.deepcopy(custom_rules)
original_custom_rules = (
await self._configuration.async_get_for_update(
SystemConfigKey.CustomFilterRules
)
)
if (
(original_custom_rules or []) != expected_custom_rules
and original_custom_rules != custom_definitions
):
raise RuleGroupMutationConflictError(
"自定义规则已被其他请求修改,请重新读取后再试"
)
if original_custom_rules != custom_definitions:
await self._configuration.async_stage_set(
SystemConfigKey.CustomFilterRules,
custom_definitions,
)
changes[SystemConfigKey.CustomFilterRules] = custom_definitions
for key in _RULE_GROUP_LIST_CONFIG_KEYS:
original = await self._configuration.async_get_for_update(key)
list_updated = _map_rule_group_names(
original, previous_name, current_name, valid_names
)
if list_updated != (original or []):
await self._configuration.async_stage_set(key, list_updated)
changes[key] = list_updated
for key in _RULE_GROUP_DEFAULT_CONFIG_KEYS:
original = await self._configuration.async_get_for_update(key)
default_updated = _rewrite_default_config(
original, previous_name, current_name, valid_names
)
if default_updated != (original or {}):
await self._configuration.async_stage_set(key, default_updated)
changes[key] = default_updated
subscription_changes = []
subscriptions = await self._subscriptions.async_list_for_reference_rewrite()
for subscription in subscriptions:
original = list(subscription.filter_groups or [])
updated = _map_rule_group_names(
original, previous_name, current_name, valid_names
)
if updated == original:
continue
await self._subscriptions.async_stage_update(
subscription.id,
SubscriptionPatch({"filter_groups": updated}),
)
subscription_changes.append(_subscription_change(subscription, updated))
return RuleGroupMutation(changes, tuple(subscription_changes))
class RuleHelper:
"""读取用户过滤规则配置,并按媒体上下文选择适用规则组。"""
@@ -34,7 +422,7 @@ class RuleHelper:
@staticmethod
def get_rule_groups() -> List[FilterRuleGroup]:
"""返回用户配置的全部过滤规则组。"""
rule_groups: List[dict] = get_configured_system_config().get(
rule_groups: List[dict[str, Any]] = get_configured_system_config().get(
SystemConfigKey.UserFilterRuleGroups
)
if not rule_groups:
@@ -51,7 +439,7 @@ class RuleHelper:
def get_rule_group_by_media(
self,
media: Optional[MediaInfo] = None,
group_names: Optional[list] = None,
group_names: Optional[list[str]] = None,
) -> List[FilterRuleGroup]:
"""按媒体类型、分类和候选名称筛选适用规则组。"""
rule_groups = self.get_rule_groups()
@@ -73,7 +461,9 @@ class RuleHelper:
@staticmethod
def get_custom_rules() -> List[CustomRule]:
"""返回用户配置的全部自定义过滤规则。"""
rules: List[dict] = get_configured_system_config().get(SystemConfigKey.CustomFilterRules)
rules: List[dict[str, Any]] = get_configured_system_config().get(
SystemConfigKey.CustomFilterRules
)
if not rules:
return []
return [CustomRule(**rule) for rule in rules]
@@ -101,7 +491,7 @@ def replace_group_name_in_list(
# 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。
BUILTIN_RULE_SET: Dict[str, dict] = {
BUILTIN_RULE_SET: Dict[str, dict[str, Any]] = {
# 蓝光原盘
"BLU": {
"include": [
@@ -256,7 +646,7 @@ class RuleParser:
_lock = threading.Lock()
_thread_local = threading.local()
def __init__(self):
def __init__(self) -> None:
"""
定义语法规则
"""
@@ -302,18 +692,18 @@ class RuleParser:
return self.expr.parse_string(expression)
class _RustParseResults(list):
class _RustParseResults(list[Any]):
"""
包装 Rust 解析结果,提供本模块调用方使用的 as_list/asList 接口。
"""
def as_list(self) -> list:
def as_list(self) -> list[Any]:
"""
返回兼容 pyparsing.ParseResults.as_list 的列表结构。
"""
return list(self)
def asList(self) -> list: # noqa: N802
def asList(self) -> list[Any]: # noqa: N802
"""
返回兼容 pyparsing.ParseResults.asList 的列表结构。
"""

View File

@@ -1,16 +1,25 @@
"""站点写操作应用用例。"""
import copy
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Optional, TypeAlias
from app.application.configuration import SystemConfigStagingPort
from app.application.outbox import SyncUnitOfWork
from app.application.site.contract import (
SiteMutation,
SitePriorityMutation,
SiteStagingPort,
SiteWriteResult,
)
from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionReferenceStagingPort,
)
from app.application.subscription.delete import AsyncUnitOfWork
from app.schemas.common import JsonData
from app.schemas.types import SystemConfigKey
SiteMutationResult: TypeAlias = SiteWriteResult
SiteIndexerLoader = Callable[
@@ -22,6 +31,80 @@ DomainExtractor = Callable[[str], str]
UrlNormalizer = Callable[[str], str]
@dataclass(frozen=True, slots=True)
class SiteReferenceMutation:
"""站点引用在 SystemConfig 和订阅表中原子提交后的结果。"""
rss_sites: tuple[int, ...]
subscription_ids: tuple[int, ...]
class SyncSiteReferenceMutationService:
"""在一个同步 UoW 内原子清理 RSS 和订阅站点引用。"""
def __init__(
self,
configuration: SystemConfigStagingPort,
subscriptions: SubscriptionReferenceStagingPort,
unit_of_work: SyncUnitOfWork,
publish: Callable[[Mapping[SystemConfigKey, JsonData]], None],
) -> None:
"""注入共享 Session 的配置、订阅、UoW 与提交后快照发布器。"""
self._configuration = configuration
self._subscriptions = subscriptions
self._unit_of_work = unit_of_work
self._publish = publish
def apply(self, site_id: int | str) -> SiteReferenceMutation:
"""清空通配站点或移除指定站点,并在任一步失败时整体回滚。"""
if site_id != "*" and not isinstance(site_id, int):
raise ValueError("站点引用清理只接受整数 ID 或通配符 *")
try:
result, changes = self._stage(site_id)
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
self._publish(copy.deepcopy(changes))
return result
def _stage(
self,
site_id: int | str,
) -> tuple[SiteReferenceMutation, dict[SystemConfigKey, JsonData]]:
"""在锁定的同步事实源中暂存 RSS 和订阅站点变化。"""
changes: dict[SystemConfigKey, JsonData] = {}
original_rss = self._configuration.get_for_update(SystemConfigKey.RssSites)
rss_sites = [
int(value)
for value in original_rss
if isinstance(value, int) and site_id != "*" and value != site_id
] if isinstance(original_rss, list) else []
if rss_sites != (original_rss or []):
self._configuration.stage_set(SystemConfigKey.RssSites, rss_sites)
changes[SystemConfigKey.RssSites] = rss_sites
subscription_ids = []
for subscription in self._subscriptions.list_for_reference_rewrite():
original_sites = list(subscription.sites or [])
sites = [
value
for value in original_sites
if site_id != "*" and value != site_id
]
if sites == original_sites:
continue
self._subscriptions.stage_update(
subscription.id,
SubscriptionPatch({"sites": sites}),
)
subscription_ids.append(subscription.id)
return (
SiteReferenceMutation(tuple(rss_sites), tuple(subscription_ids)),
changes,
)
class SiteMutationCommand:
"""统一执行站点新增、更新、优先级和删除事务。"""

View File

@@ -184,17 +184,3 @@ def _completion_report_payload(
CompletionScope = Callable[[], AbstractContextManager[CompleteSubscriptionCommand]]
_configured_completion_scope: CompletionScope | None = None
def configure_subscription_completion_scope(provider: CompletionScope) -> None:
"""由启动组合根登记订阅完成独占事务作用域。"""
global _configured_completion_scope
_configured_completion_scope = provider
def get_subscription_completion_scope() -> AbstractContextManager[CompleteSubscriptionCommand]:
"""返回一次独占同步订阅完成事务作用域。"""
if _configured_completion_scope is None:
raise RuntimeError("订阅完成事务作用域尚未配置")
return _configured_completion_scope()

View File

@@ -470,31 +470,6 @@ class SubscriptionWritePort(Protocol):
"""在独立异步事务中新增订阅。"""
...
def update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""在独立同步事务中更新并返回订阅快照。"""
...
async def async_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""在独立异步事务中更新并返回订阅快照。"""
...
async def async_update_filter_groups(
self,
subscribe_id: int,
filter_groups: builtins.list[str],
) -> Optional[SubscriptionSnapshot]:
"""在独立异步事务中更新过滤规则组并返回快照。"""
...
class SubscriptionStagingPort(Protocol):
"""复用调用方 Session 且不自行提交的订阅写端口。"""
@@ -516,14 +491,6 @@ class SubscriptionStagingPort(Protocol):
"""异步暂存新增订阅。"""
...
async def async_stage_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""异步暂存更新并返回事务内快照。"""
...
async def get_candidate(self, subscribe_id: int) -> Optional[SubscribeDeletionCandidate]:
"""异步读取删除候选快照。"""
...
@@ -565,22 +532,14 @@ class SubscriptionHistoryStagingPort(SubscriptionHistoryQueryPort, Protocol):
class SubscriptionMutationPort(Protocol):
"""订阅修改服务需要的查询、独立写和事务暂存能力"""
"""订阅修改服务在调用方 Session 内暂存更新的最小端口"""
def get(self, subscribe_id: int) -> Optional[SubscriptionSnapshot]:
"""同步按主键读取订阅快照。"""
...
async def async_get(self, subscribe_id: int) -> Optional[SubscriptionSnapshot]:
"""异步按主键读取订阅快照。"""
...
async def async_update(
def stage_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""在独立事务中更新并返回订阅快照。"""
"""同步暂存更新并返回事务内快照。"""
...
async def async_stage_update(
@@ -592,6 +551,20 @@ class SubscriptionMutationPort(Protocol):
...
class SubscriptionReferenceStagingPort(SubscriptionMutationPort, Protocol):
"""跨表引用重写所需的订阅锁定与暂存端口。"""
def list_for_reference_rewrite(self) -> builtins.list[SubscriptionSnapshot]:
"""同步锁定并返回全部订阅快照。"""
...
async def async_list_for_reference_rewrite(
self,
) -> builtins.list[SubscriptionSnapshot]:
"""异步锁定并返回全部订阅快照。"""
...
class SubscriptionRepository(
SubscriptionQueryPort,
SubscriptionWritePort,

View File

@@ -284,31 +284,3 @@ def build_subscribe_deleted_payload(
DeleteSubscribeScope = Callable[[], AbstractAsyncContextManager[DeleteSubscribeCommand]]
SyncDeleteSubscribeScope = Callable[[], AbstractContextManager[SyncDeleteSubscribeCommand]]
_configured_delete_scope: DeleteSubscribeScope | None = None
_configured_sync_delete_scope: SyncDeleteSubscribeScope | None = None
def configure_delete_subscribe_scope(provider: DeleteSubscribeScope) -> None:
"""由启动组合根登记非 HTTP 入口使用的订阅删除事务作用域。"""
global _configured_delete_scope
_configured_delete_scope = provider
def get_delete_subscribe_scope() -> AbstractAsyncContextManager[DeleteSubscribeCommand]:
"""返回一次独占会话的订阅删除命令作用域。"""
if _configured_delete_scope is None:
raise RuntimeError("订阅删除事务作用域尚未配置")
return _configured_delete_scope()
def configure_sync_delete_subscribe_scope(provider: SyncDeleteSubscribeScope) -> None:
"""由启动组合根登记同步消息入口使用的订阅删除事务作用域。"""
global _configured_sync_delete_scope
_configured_sync_delete_scope = provider
def get_sync_delete_subscribe_scope() -> AbstractContextManager[SyncDeleteSubscribeCommand]:
"""返回一次独占同步会话的订阅删除命令作用域。"""
if _configured_sync_delete_scope is None:
raise RuntimeError("同步订阅删除事务作用域尚未配置")
return _configured_sync_delete_scope()

View File

@@ -1,23 +1,26 @@
"""订阅写操作用例及其数据端口。"""
from collections.abc import Awaitable, Callable
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from app.application.outbox import (
SUBSCRIBE_MODIFIED_TOPIC,
AsyncOutboxDispatchStore,
AsyncOutboxStager,
OutboxDispatchStore,
OutboxIntent,
OutboxStager,
SyncUnitOfWork,
deliver_async_outbox_effect,
deliver_outbox_effect,
)
from app.application.subscription.contract import (
SessionSubscriptionPort,
SubscriptionHistorySnapshot,
SubscriptionHistoryStagingPort,
SubscriptionMutationPort,
SubscriptionPatch,
SubscriptionSnapshot,
)
@@ -26,6 +29,7 @@ from app.schemas.common import JsonData
from app.schemas.event import SubscribeModifiedEventData
SubscribeModifiedPublisher = Callable[[dict[str, JsonData]], Awaitable[None]]
SyncSubscribeModifiedPublisher = Callable[[dict[str, JsonData]], None]
@dataclass(frozen=True, slots=True)
@@ -40,6 +44,7 @@ class SubscriptionActor:
class SubscriptionMutation:
"""一次订阅变更前后的稳定快照。"""
snapshot: SubscriptionSnapshot
old: dict[str, JsonData]
new: dict[str, JsonData]
event_published: bool = False
@@ -47,17 +52,166 @@ class SubscriptionMutation:
pending_effects: tuple[str, ...] = ()
def _modified_event(
subscribe_id: int,
scene: str,
old: dict[str, JsonData],
updated: SubscriptionSnapshot,
) -> tuple[str, dict[str, JsonData]]:
"""构造订阅修改 intent 共用的稳定键和事件快照。"""
event_payload = SubscribeModifiedEventData(
subscribe_id=subscribe_id,
old_subscribe_info=old,
subscribe_info=updated.to_dict(),
scene=scene,
).to_dict()
event_key = _modified_event_key(subscribe_id, scene)
event_payload["idempotency_key"] = event_key
return event_key, event_payload
def _reset_payload(subscribe: SubscriptionSnapshot) -> dict[str, JsonData]:
"""构造同步与异步重置共享的订阅字段补丁。"""
return {
"note": [],
"lack_episode": subscribe.total_episode,
"current_priority": None,
"current_audio_format": None,
"current_bitrate": None,
"current_bit_depth": None,
"current_sample_rate": None,
"episode_priority": {},
"manual_total_episode": 0,
"state": "R",
}
class SyncSubscriptionMutationService:
"""用一个同步 UoW 原子提交订阅修改和 durable 事件 intent。"""
def __init__(
self,
repository: SessionSubscriptionPort,
unit_of_work: SyncUnitOfWork,
outbox: OutboxStager,
dispatch_store: OutboxDispatchStore,
publish_modified: SyncSubscribeModifiedPublisher,
) -> None:
"""注入同一 Session 的仓储、事务、outbox 与提交后发布端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._outbox = outbox
self._dispatch_store = dispatch_store
self._publish_modified = publish_modified
def get_accessible(
self,
subscribe_id: int,
actor: SubscriptionActor,
) -> SubscriptionSnapshot | None:
"""同步读取当前主体可访问的订阅。"""
subscribe = self._repository.get(subscribe_id)
return subscribe if SubscriptionMutationService.can_access(subscribe, actor) else None
def update(
self,
subscribe_id: int,
payload: dict[str, JsonData],
actor: SubscriptionActor,
existing: SubscriptionSnapshot | None = None,
scene: str = "update",
) -> SubscriptionMutation | None:
"""同步更新订阅,并在同一事务暂存可恢复的修改事件。"""
subscribe = existing or self.get_accessible(subscribe_id, actor)
if subscribe and not SubscriptionMutationService.can_access(subscribe, actor):
return None
if not subscribe:
return None
old = subscribe.to_dict()
try:
updated = self._repository.stage_update(
subscribe_id,
SubscriptionPatch(payload),
)
if not updated:
self._unit_of_work.rollback()
return None
event_key, event_payload = _modified_event(
subscribe_id,
scene,
old,
updated,
)
self._outbox.stage(
OutboxIntent(
event_key=event_key,
topic=SUBSCRIBE_MODIFIED_TOPIC,
payload=event_payload,
),
datetime.now(timezone.utc),
)
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
delivered = deliver_outbox_effect(
self._dispatch_store,
event_key,
lambda: self._publish_modified(event_payload),
)
return SubscriptionMutation(
snapshot=updated,
old=old,
new=updated.to_dict(),
event_published=delivered,
business_committed=True,
pending_effects=() if delivered else (event_key,),
)
def update_status(
self,
subscribe_id: int,
state: str,
actor: SubscriptionActor,
) -> SubscriptionMutation | None:
"""同步更新订阅状态并返回前后快照。"""
return self.update(
subscribe_id,
{"state": state},
actor,
scene="status",
)
def reset(
self,
subscribe_id: int,
actor: SubscriptionActor,
) -> SubscriptionMutation | None:
"""同步重置订阅进度和手工集数标记。"""
subscribe = self.get_accessible(subscribe_id, actor)
if not subscribe:
return None
return self.update(
subscribe_id,
_reset_payload(subscribe),
actor,
existing=subscribe,
scene="reset",
)
class SubscriptionMutationService:
"""编排订阅访问控制、更新和历史删除。"""
def __init__(
self,
repository: SubscriptionMutationPort,
repository: SessionSubscriptionPort,
unit_of_work: AsyncUnitOfWork,
outbox: AsyncOutboxStager,
dispatch_store: AsyncOutboxDispatchStore,
publish_modified: SubscribeModifiedPublisher,
history_repository: SubscriptionHistoryStagingPort | None = None,
unit_of_work: AsyncUnitOfWork | None = None,
outbox: Optional[AsyncOutboxStager] = None,
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
publish_modified: SubscribeModifiedPublisher | None = None,
) -> None:
"""注入订阅数据、事务与 durable 事件端口。"""
self._repository = repository
@@ -76,15 +230,6 @@ class SubscriptionMutationService:
subscribe = await self._repository.async_get(subscribe_id)
return subscribe if self.can_access(subscribe, actor) else None
def get_accessible_sync(
self,
subscribe_id: int,
actor: SubscriptionActor,
) -> SubscriptionSnapshot | None:
"""同步读取当前主体可访问的订阅。"""
subscribe = self._repository.get(subscribe_id)
return subscribe if self.can_access(subscribe, actor) else None
async def update(
self,
subscribe_id: int,
@@ -100,31 +245,20 @@ class SubscriptionMutationService:
if not subscribe:
return None
old = subscribe.to_dict()
if not self._unit_of_work:
updated = await self._repository.async_update(
subscribe_id,
SubscriptionPatch(payload),
)
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
publish_modified = self._publish_modified
if not self._outbox or not self._dispatch_store or not publish_modified:
raise RuntimeError("订阅修改事务缺少 outbox stager、store 或事件发布端口")
try:
updated = await self._repository.async_stage_update(
subscribe_id,
SubscriptionPatch(payload),
)
if not updated:
await self._unit_of_work.rollback()
return None
event_payload = SubscribeModifiedEventData(
subscribe_id=subscribe_id,
old_subscribe_info=old,
subscribe_info=updated.to_dict(),
scene=scene,
).to_dict()
event_key = _modified_event_key(subscribe_id, scene)
event_payload["idempotency_key"] = event_key
event_key, event_payload = _modified_event(
subscribe_id,
scene,
old,
updated,
)
await self._outbox.stage(
OutboxIntent(
event_key=event_key,
@@ -140,7 +274,7 @@ class SubscriptionMutationService:
async def publish_event() -> None:
"""发布本次事务已持久化的订阅修改事件。"""
await publish_modified(event_payload)
await self._publish_modified(event_payload)
delivered = await deliver_async_outbox_effect(
self._dispatch_store,
@@ -148,8 +282,9 @@ class SubscriptionMutationService:
publish_event,
)
return SubscriptionMutation(
snapshot=updated,
old=old,
new=event_payload["subscribe_info"],
new=updated.to_dict(),
event_published=delivered,
business_committed=True,
pending_effects=() if delivered else (event_key,),
@@ -178,21 +313,9 @@ class SubscriptionMutationService:
subscribe = await self.get_accessible(subscribe_id, actor)
if not subscribe:
return None
payload: dict[str, JsonData] = {
"note": [],
"lack_episode": subscribe.total_episode,
"current_priority": None,
"current_audio_format": None,
"current_bitrate": None,
"current_bit_depth": None,
"current_sample_rate": None,
"episode_priority": {},
"manual_total_episode": 0,
"state": "R",
}
return await self.update(
subscribe_id,
payload,
_reset_payload(subscribe),
actor,
existing=subscribe,
scene="reset",
@@ -209,8 +332,6 @@ class SubscriptionMutationService:
history = await self._history_repository.async_get(history_id)
if not self.can_access(history, actor):
return False
if self._unit_of_work is None:
raise RuntimeError("订阅历史删除缺少事务端口")
try:
await self._history_repository.stage_delete(history_id)
await self._unit_of_work.commit()
@@ -242,19 +363,7 @@ SubscriptionMutationScope = Callable[
[],
AbstractAsyncContextManager[SubscriptionMutationService],
]
_configured_mutation_scope: SubscriptionMutationScope | None = None
def configure_subscription_mutation_scope(
provider: SubscriptionMutationScope,
) -> None:
"""由启动组合根登记 Agent 等非 HTTP 入口使用的事务作用域。"""
global _configured_mutation_scope
_configured_mutation_scope = provider
def get_subscription_mutation_scope() -> AbstractAsyncContextManager[SubscriptionMutationService]:
"""返回一次独占会话的订阅修改服务作用域。"""
if _configured_mutation_scope is None:
raise RuntimeError("订阅修改事务作用域尚未配置")
return _configured_mutation_scope()
SyncSubscriptionMutationScope = Callable[
[],
AbstractContextManager[SyncSubscriptionMutationService],
]

View File

@@ -15,9 +15,10 @@ app/application/history.py 里整理历史的写入路径同构。
下方 _translate 单点承担,两条链路只在「怎么查、怎么写」上分叉。
"""
from collections.abc import Callable
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Mapping, Optional, Protocol, Tuple
from typing import Optional, Protocol, Tuple
from app.application.outbox import SUBSCRIBE_ADDED_TOPIC, OutboxIntent
from app.application.subscription.contract import (
@@ -27,6 +28,7 @@ from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionStagingPort,
SubscriptionWritePort,
SubscriptionWriteResult,
)
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.common import JsonData
@@ -168,6 +170,95 @@ class AsyncCreateSubscriptionCommand:
return staged.subscribe_id, staged.message
@dataclass(frozen=True, slots=True)
class SubscriptionCreateRequest:
"""批量新增中的一条订阅写入请求。"""
identity: SubscriptionIdentity
payload: SubscriptionPatch
username: Optional[str] = None
notification: Mapping[str, JsonData] | None = None
after_commit: Optional[AsyncAfterCommitEffect] = None
SubscriptionBatchAfterCommitEffect = Callable[
[int, SubscriptionCreateRequest],
Awaitable[None],
]
class SubscriptionBatchWritePort(Protocol):
"""Servarr 等批量入口使用的原子异步订阅写端口。"""
async def async_add(
self,
requests: Sequence[SubscriptionCreateRequest],
) -> tuple[SubscriptionWriteResult, ...]:
"""在一个事务内新增全部订阅并返回逐条结果。"""
...
class SubscriptionBatchWriteError(RuntimeError):
"""批量中的任一订阅无法落库时触发整批回滚。"""
class AsyncCreateSubscriptionBatchCommand:
"""在一个异步 UoW 内暂存多条订阅及各自 durable intents。"""
def __init__(
self,
repository: SubscriptionStagingPort,
unit_of_work: AsyncUnitOfWork,
outbox: AsyncSubscriptionOutboxStager,
) -> None:
"""注入共享 Session 的 staging port、事务所有者和 outbox。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._outbox = outbox
async def execute(
self,
requests: Sequence[SubscriptionCreateRequest],
after_commit: Optional[SubscriptionBatchAfterCommitEffect] = None,
) -> tuple[SubscriptionWriteResult, ...]:
"""全部暂存成功后提交一次,失败则回滚且不执行外部副作用。"""
if not requests:
return ()
staged_results: list[SubscriptionWriteResult] = []
created_requests: list[tuple[SubscriptionCreateRequest, SubscriptionWriteResult]] = []
now = datetime.now(timezone.utc)
try:
for request in requests:
staged = await self._repository.async_stage_add(
request.identity,
request.payload,
request.username,
)
if not staged.subscribe_id:
raise SubscriptionBatchWriteError(staged.message)
staged_results.append(staged)
if not staged.created:
continue
created_requests.append((request, staged))
for intent in _subscribe_added_intents(
staged.subscribe_id,
request.payload.to_payload(),
request.username,
request.notification,
):
await self._outbox.stage(intent, now)
if created_requests:
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
if after_commit:
for request, staged in created_requests:
await after_commit(staged.subscribe_id, request)
return tuple(staged_results)
def _subscribe_added_intents(
subscribe_id: int,
payload: Mapping[str, JsonData],
@@ -249,26 +340,6 @@ def subscription_added_notification_key(
return f"{subscription_added_event_key(subscribe_id, payload)}:notification"
_configured_subscribe_writer: Callable[[], SubscriptionWritePort] | None = None
def configure_subscribe_writer(provider: Callable[[], SubscriptionWritePort]) -> None:
"""由启动组合根登记订阅写入端口提供器。"""
global _configured_subscribe_writer
_configured_subscribe_writer = provider
def _get_subscribe_writer(
writer: Optional[SubscriptionWritePort],
) -> SubscriptionWritePort:
"""获取显式传入或启动组合根登记的订阅写入端口。"""
if writer is not None:
return writer
if _configured_subscribe_writer is None:
raise RuntimeError("订阅写入端口尚未配置")
return _configured_subscribe_writer()
def _music_entity(mediainfo: MediaInfo | MusicInfo) -> Optional[str]:
"""
取音乐实体类型;非音乐媒体一律为空。
@@ -332,9 +403,29 @@ def _translate(
return identity, SubscriptionPatch(payload), username if isinstance(username, str) else None
def build_subscription_create_request(
mediainfo: MediaInfo | MusicInfo,
notification: Mapping[str, JsonData] | None = None,
after_commit: Optional[AsyncAfterCommitEffect] = None,
**kwargs: JsonData,
) -> Optional[SubscriptionCreateRequest]:
"""把统一订阅字段映射冻结为一条可参与原子批量写入的请求。"""
translated = _translate(mediainfo, kwargs)
if translated is None:
return None
identity, payload, username = translated
return SubscriptionCreateRequest(
identity=identity,
payload=payload,
username=username,
notification=notification,
after_commit=after_commit,
)
def add_subscribe(
mediainfo: MediaInfo | MusicInfo,
subscribe_oper: Optional[SubscriptionWritePort] = None,
subscribe_oper: SubscriptionWritePort,
after_commit: Optional[AfterCommitEffect] = None,
notification: Mapping[str, JsonData] | None = None,
**kwargs: JsonData,
@@ -343,8 +434,8 @@ def add_subscribe(
新增订阅。
:param mediainfo: 识别结果
:param subscribe_oper: 复用的订阅操作对象,未传时由启动组合根提供
:param after_commit: 提交后副作用编排;返回 False 表示统计 intent 等待重试
:param subscribe_oper: 调用方显式注入的订阅写入端口
:param after_commit: 提交后副作用编排;返回 False 表示统计 intent 等待重试
:param kwargs: 订阅设置owner_scope 为真时按用户名限定查重范围
:return: (订阅 ID, 结果说明)ID 为 0 表示未新增
"""
@@ -352,15 +443,14 @@ def add_subscribe(
if translated is None:
return INCOMPLETE_IDENTITY
identity, payload, username = translated
oper = _get_subscribe_writer(subscribe_oper)
if after_commit is None:
return oper.add(
return subscribe_oper.add(
identity=identity,
payload=payload,
username=username,
notification=notification,
)
return oper.add(
return subscribe_oper.add(
identity=identity,
payload=payload,
username=username,
@@ -371,7 +461,7 @@ def add_subscribe(
async def async_add_subscribe(
mediainfo: MediaInfo | MusicInfo,
subscribe_oper: Optional[SubscriptionWritePort] = None,
subscribe_oper: SubscriptionWritePort,
after_commit: Optional[AsyncAfterCommitEffect] = None,
notification: Mapping[str, JsonData] | None = None,
**kwargs: JsonData,
@@ -380,8 +470,8 @@ async def async_add_subscribe(
异步新增订阅。
:param mediainfo: 识别结果
:param subscribe_oper: 复用的订阅操作对象,未传时由启动组合根提供
:param after_commit: 异步提交后副作用编排;返回 False 表示统计 intent 等待重试
:param subscribe_oper: 调用方显式注入的订阅写入端口
:param after_commit: 异步提交后副作用编排;返回 False 表示统计 intent 等待重试
:param kwargs: 订阅设置owner_scope 为真时按用户名限定查重范围
:return: (订阅 ID, 结果说明)ID 为 0 表示未新增
"""
@@ -389,15 +479,14 @@ async def async_add_subscribe(
if translated is None:
return INCOMPLETE_IDENTITY
identity, payload, username = translated
oper = _get_subscribe_writer(subscribe_oper)
if after_commit is None:
return await oper.async_add(
return await subscribe_oper.async_add(
identity=identity,
payload=payload,
username=username,
notification=notification,
)
return await oper.async_add(
return await subscribe_oper.async_add(
identity=identity,
payload=payload,
username=username,
@@ -408,13 +497,17 @@ async def async_add_subscribe(
__all__ = [
"AfterCommitEffect",
"AsyncCreateSubscriptionBatchCommand",
"AsyncCreateSubscriptionCommand",
"AsyncAfterCommitEffect",
"AsyncUnitOfWork",
"CreateSubscriptionCommand",
"INCOMPLETE_IDENTITY",
"SubscriptionBatchWriteError",
"SubscriptionBatchWritePort",
"SubscriptionCreateRequest",
"UnitOfWork",
"add_subscribe",
"async_add_subscribe",
"configure_subscribe_writer",
"build_subscription_create_request",
]

View File

@@ -58,6 +58,13 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
self.async_filecache = context.async_file_cache
self.site_repository = context.site_repository
self.subscription_repository = context.subscription_repository
self.subscription_mutation_scope = context.subscription_mutation_scope
self.sync_subscription_mutation_scope = context.sync_subscription_mutation_scope
self.subscription_delete_scope = context.subscription_delete_scope
self.sync_subscription_delete_scope = context.sync_subscription_delete_scope
self.subscription_completion_scope = context.subscription_completion_scope
self.rule_group_mutation_scope = context.rule_group_mutation_scope
self.site_reference_mutation_scope = context.site_reference_mutation_scope
self.download_history_repository = context.download_history_repository
self.transfer_history_repository = context.transfer_history_repository
self.transfer_admission_repository = context.transfer_admission_repository

View File

@@ -1,14 +1,15 @@
import copy
from typing import Any, List, Optional, Tuple
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
from app.application.configuration import get_configured_system_config
from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionRepository,
SubscriptionSnapshot,
build_subscribe_meta,
subscribe_media_key,
)
from app.application.subscription.mutation import SubscriptionActor
from app.application.torrent import TorrentHelper
from app.chain._contracts import MusicSubscribeMixinHost
from app.chain.download import DownloadChain
@@ -18,6 +19,7 @@ from app.domain.context import Context, MediaInfo, MusicInfo
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
from app.domain.meta.metamusic import MetaMusic
from app.runtime.log import logger
from app.schemas.common import JsonData
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
@@ -25,6 +27,9 @@ from app.schemas.types import (
SystemConfigKey,
)
if TYPE_CHECKING:
from app.application.subscription.mutation import SyncSubscriptionMutationScope
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
"""将专辑曲目总数归一为正整数,无效或未知值返回 None。"""
@@ -38,6 +43,7 @@ def _normalize_music_total_tracks(value: Any) -> Optional[int]:
class MusicSubscribeMixin:
__mixin_host_protocol__ = MusicSubscribeMixinHost
subscription_repository: SubscriptionRepository
sync_subscription_mutation_scope: "SyncSubscriptionMutationScope"
"""
音乐订阅功能域 mixin单曲/专辑目标识别、实体快照同步、候选筛选、
择优下载与完成推进。
@@ -203,10 +209,29 @@ class MusicSubscribeMixin:
update_data["total_tracks"] = total_tracks
if not update_data:
return subscribe
return self.subscription_repository.update(
subscribe.id,
SubscriptionPatch(update_data),
) or subscribe
return self._update_music_subscription(
subscribe,
update_data,
scene="music_target",
)
def _update_music_subscription(
self,
subscribe: SubscriptionSnapshot,
payload: Mapping[str, JsonData],
*,
scene: str,
) -> SubscriptionSnapshot:
"""经显式同步事务作用域更新音乐订阅并返回提交后的快照。"""
with self.sync_subscription_mutation_scope() as mutation:
change = mutation.update(
subscribe.id,
dict(payload),
SubscriptionActor(name="chain", is_superuser=True),
existing=subscribe,
scene=scene,
)
return change.snapshot if change else subscribe
@staticmethod
def _is_music_download_complete(
@@ -357,9 +382,10 @@ class MusicSubscribeMixin:
"current_bit_depth": best_meta.bit_depth,
"current_sample_rate": best_meta.sample_rate,
}
current_subscribe = repository.update(
subscribe.id,
SubscriptionPatch(quality_data),
current_subscribe = self._update_music_subscription(
subscribe,
quality_data,
scene="music_download",
)
if current_subscribe is None:
current_subscribe = repository.get(subscribe.id)

View File

@@ -3,6 +3,7 @@ import json
import random
import threading
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -16,10 +17,8 @@ from app.application.mediaserver import MediaServerHelper
from app.application.messaging.message import MessageTemplateHelper
from app.application.messaging.subscribe import SubscribeInteractionHandler
from app.application.subscription import priority as _priority
from app.application.subscription.complete import get_subscription_completion_scope
from app.application.subscription.contract import (
SubscriptionIdentity,
SubscriptionPatch,
SubscriptionRepository,
SubscriptionSnapshot,
subscribe_media_key,
@@ -30,10 +29,16 @@ from app.application.subscription.contract import (
)
from app.application.subscription.delete import (
SubscribeDeletionActor,
get_sync_delete_subscribe_scope,
)
from app.application.subscription.mutation import SubscriptionActor
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.write import add_subscribe, async_add_subscribe
from app.application.subscription.write import (
SubscriptionBatchWritePort,
SubscriptionCreateRequest,
add_subscribe,
async_add_subscribe,
build_subscription_create_request,
)
from app.application.torrent import TorrentHelper
from app.chain import ChainBase
from app.chain._interaction import InteractionChainMixin
@@ -56,6 +61,7 @@ from app.domain.metainfo import MetaInfo
from app.runtime.events import Event, eventmanager
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.common import JsonData
from app.schemas.event import SubscribeCompletionCheckEventData, SubscribeEpisodesRefreshEventData
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo
@@ -78,21 +84,6 @@ from app.schemas.types import (
)
def _rule_group_names(rule_groups: Optional[List[Any]]) -> set[str]:
"""从规则组持久化字典中提取非空名称。"""
return {
str(group.get("name")).strip() for group in rule_groups or [] if isinstance(group, dict) and group.get("name")
}
def _retain_rule_group_names(
values: Optional[List[str]],
valid_names: set[str],
) -> List[str]:
"""按当前规则组定义保留有效引用,并维持原有顺序。"""
return [value for value in values or [] if value in valid_names]
@dataclass(frozen=True, slots=True)
class _SubscribePostCommitContext:
"""订阅提交后副作用所需的不可变业务快照。"""
@@ -990,6 +981,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
sid, err_msg = add_subscribe(
mediainfo=context.mediainfo,
subscribe_oper=self.subscription_repository,
season=context.season,
username=context.username,
after_commit=_after_commit,
@@ -1020,6 +1012,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
sid, err_msg = await async_add_subscribe(
mediainfo=context.mediainfo,
subscribe_oper=self.subscription_repository,
season=context.season,
username=context.username,
after_commit=_after_commit,
@@ -1073,10 +1066,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
self,
title: str,
year: str,
mtype: MediaType = None,
mtype: Optional[MediaType] = None,
episode_group: Optional[str] = None,
season: Optional[int] = None,
channel: NotificationChannel = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
@@ -1100,8 +1093,8 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
source,
userid,
username,
message,
exist_ok,
bool(message),
bool(exist_ok),
media_source,
media_id,
kwargs,
@@ -1121,10 +1114,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
self,
title: str,
year: str,
mtype: MediaType = None,
mtype: Optional[MediaType] = None,
episode_group: Optional[str] = None,
season: Optional[int] = None,
channel: NotificationChannel = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
@@ -1138,6 +1131,44 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
异步识别媒体信息并添加订阅
"""
logger.info(f"开始添加订阅,标题:{title} ...")
context, error = await self.__async_prepare_subscribe_create(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
bool(message),
bool(exist_ok),
media_source,
media_id,
kwargs,
)
if error or context is None:
return None, error or "订阅准备失败"
return await self.__async_persist_subscribe_create(context)
async def __async_prepare_subscribe_create(
self,
title: str,
year: str,
mtype: Optional[MediaType],
episode_group: Optional[str],
season: Optional[int],
channel: Optional[NotificationChannel],
source: Optional[str],
userid: Optional[str],
username: Optional[str],
message: bool,
exist_ok: bool,
media_source: Optional[MediaSource],
media_id: Optional[str],
options: Dict[str, Any],
) -> Tuple[Optional[_SubscribeCreateContext], Optional[str]]:
"""执行异步新增的识别、季集补齐和默认配置准备,但不触发持久化。"""
context, error = self.__build_subscribe_create_context(
title,
year,
@@ -1152,9 +1183,9 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
exist_ok,
media_source,
media_id,
kwargs,
options,
)
if error:
if error or context is None:
return None, error
error = await self.__async_recognize_subscribe_media(context)
if error:
@@ -1163,7 +1194,82 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if error:
return None, error
await self.__async_finalize_subscribe_create_context(context)
return await self.__async_persist_subscribe_create(context)
return context, None
async def async_add_batch(
self,
*,
title: str,
year: str,
seasons: Sequence[int],
batch_writer: SubscriptionBatchWritePort,
mtype: MediaType = None,
episode_group: Optional[str] = None,
channel: NotificationChannel = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: JsonData,
) -> Tuple[Optional[int], str]:
"""先完整准备各季订阅,再交给共享事务批量写端口原子提交。"""
requests: list[SubscriptionCreateRequest] = []
for season in seasons:
context, error = await self.__async_prepare_subscribe_create(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
bool(message),
bool(exist_ok),
media_source,
media_id,
dict(kwargs),
)
if error or context is None:
return None, error or "订阅准备失败"
assert context.mediainfo is not None
notification = self.__build_subscribe_notification(context)
post_commit_context = self.__subscribe_post_commit_context(
context,
notification,
)
async def after_commit(
subscribe_id: int,
frozen: _SubscribePostCommitContext = post_commit_context,
) -> bool:
"""按各季准备阶段冻结的上下文复用单条新增副作用。"""
return await self.__async_post_subscribe_added(
subscribe_id,
frozen,
)
request = build_subscription_create_request(
context.mediainfo,
notification=notification,
after_commit=after_commit,
season=context.season,
username=context.username,
**context.options,
)
if request is None:
return None, "媒体身份不完整"
requests.append(request)
results = await batch_writer.async_add(requests)
if not results:
return None, "未提供订阅季"
result = results[-1]
return result.subscribe_id or None, result.message
def _subscription_query(self) -> SubscriptionQueryService:
"""构造绑定订阅 Oper 的查询应用服务。"""
@@ -1258,11 +1364,6 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
subscribes = [subscribe for current_id in sids if (subscribe := repository.get(current_id)) is not None]
else:
subscribes = repository.list(self.get_states_for_search(state))
self._reconcile_rule_group_references(
valid_names=_rule_group_names(_system_config().get(SystemConfigKey.UserFilterRuleGroups)),
repository=repository,
subscribes=subscribes,
)
total_num = len(subscribes)
processed_subscribes = []
# 搜索链在整个订阅循环内复用,避免每轮订阅重复执行链初始化
@@ -1464,9 +1565,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
finally:
# 如果状态为N则更新为R
if search_attempted and subscribe and subscribe.state == "N":
repository.update(
subscribe.id,
SubscriptionPatch({"state": "R"}),
self.__apply_subscribe_update(
subscribe,
{"state": "R"},
scene="search_reset",
)
if progress_callback:
progress_callback(
@@ -1519,8 +1621,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if subscribe.type != MediaType.MOVIE.value:
return subscribe
updated = self.subscription_repository.update(
subscribe.id, SubscriptionPatch({"current_priority": priority, "last_update": now})
updated = self.__apply_subscribe_update(
subscribe,
{"current_priority": priority, "last_update": now},
scene="movie_download",
)
if subscribe.best_version and priority != 100:
# 正在洗版,更新资源优先级
@@ -2263,12 +2367,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
}
)
update_data.update(progress_update)
subscribe = (
repository.update(
subscribe.id,
SubscriptionPatch(update_data),
)
or subscribe
subscribe = self.__apply_subscribe_update(
subscribe,
update_data,
scene="metadata_refresh",
)
logger.info(f"{subscribe.name} 订阅元数据更新完成")
if progress_callback:
@@ -2517,7 +2619,11 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
note = list(set(note).union(set(items)))
# 更新订阅
if note:
self.subscription_repository.update(subscribe.id, SubscriptionPatch({"note": note}))
self.__apply_subscribe_update(
subscribe,
{"note": note},
scene="download_note",
)
@staticmethod
def __get_downloaded(subscribe: SubscriptionSnapshot) -> List[int]:
@@ -2565,20 +2671,24 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def __apply_subscribe_update(
self,
subscribe: SubscriptionSnapshot,
update_data: Dict[str, Any],
update_data: Mapping[str, JsonData],
*,
scene: str = "progress",
) -> SubscriptionSnapshot:
"""
写入订阅字段并同步当前内存对象,保证后续事件和判断读取最终快照。
"""
if not update_data:
return subscribe
return (
self.subscription_repository.update(
with self.sync_subscription_mutation_scope() as mutation:
change = mutation.update(
subscribe.id,
SubscriptionPatch(update_data),
dict(update_data),
SubscriptionActor(name="chain", is_superuser=True),
existing=subscribe,
scene=scene,
)
or subscribe
)
return change.snapshot if change else subscribe
def __refresh_subscribe_progress_with_no_exists(
self,
@@ -2905,7 +3015,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
or _completion_message
)
with get_subscription_completion_scope() as command:
with self.subscription_completion_scope() as command:
command.execute(
subscribe_id=subscribe.id,
subscribe_info=subscribe.to_dict(),
@@ -2924,10 +3034,9 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
delete_subscription=self._delete_subscription,
)
@staticmethod
def _delete_subscription(subscribe_id: int) -> bool:
def _delete_subscription(self, subscribe_id: int) -> bool:
"""通过统一同步命令删除订阅,保留消息入口原有的全局管理权限。"""
with get_sync_delete_subscribe_scope() as command:
with self.sync_subscription_delete_scope() as command:
return command.execute(
subscribe_id,
SubscribeDeletionActor(username="", is_superuser=True),
@@ -3102,29 +3211,8 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
site_id = event_data.get("site_id")
if not site_id:
return
repository = self.subscription_repository
if site_id == "*":
# 站点被重置
_system_config().set(SystemConfigKey.RssSites, [])
for subscribe in repository.list():
if not subscribe.sites:
continue
repository.update(subscribe.id, SubscriptionPatch({"sites": []}))
return
# 从选中的rss站点中移除
selected_sites = _system_config().get(SystemConfigKey.RssSites) or []
if site_id in selected_sites:
selected_sites.remove(site_id)
_system_config().set(SystemConfigKey.RssSites, selected_sites)
# 查询所有订阅
for subscribe in repository.list():
if not subscribe.sites:
continue
sites = list(subscribe.sites or [])
if site_id not in sites:
continue
sites.remove(site_id)
repository.update(subscribe.id, SubscriptionPatch({"sites": sites}))
with self.site_reference_mutation_scope() as mutation:
mutation.apply(site_id)
@eventmanager.register(EventType.ConfigChanged)
def reconcile_rule_group_references(self, event: Event) -> None:
@@ -3149,52 +3237,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
):
return
self._reconcile_rule_group_references(valid_names=_rule_group_names(value))
def _reconcile_rule_group_references(
self,
valid_names: set[str],
repository: Optional[SubscriptionRepository] = None,
subscribes: Optional[List[SubscriptionSnapshot]] = None,
) -> None:
"""持久化清理规则组引用,并同步当前搜索使用的订阅对象。"""
system_config = _system_config()
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = system_config.get(config_key) or []
updated = _retain_rule_group_names(original, valid_names)
if updated != original:
system_config.set(config_key, updated)
for config_key in (
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
):
original = system_config.get(config_key) or {}
original_groups = original.get("filter_groups") or []
updated_groups = _retain_rule_group_names(original_groups, valid_names)
if updated_groups == original_groups:
continue
updated = copy.deepcopy(original)
updated["filter_groups"] = updated_groups
system_config.set(config_key, updated)
repository = repository or self.subscription_repository
target_subscribes = subscribes if subscribes is not None else repository.list()
for index, subscribe in enumerate(target_subscribes):
original = getattr(subscribe, "filter_groups", None) or []
updated = _retain_rule_group_names(original, valid_names)
if updated != original:
updated_snapshot = repository.update(
subscribe.id,
SubscriptionPatch({"filter_groups": updated}),
)
if subscribes is not None and updated_snapshot is not None:
subscribes[index] = updated_snapshot
definitions = [dict(group) for group in value if isinstance(group, dict)] \
if isinstance(value, list) else []
with self.rule_group_mutation_scope() as mutation:
mutation.apply(definitions, expected_rule_groups=definitions)
@staticmethod
def __get_default_subscribe_config(mtype: MediaType, default_config_key: str) -> Optional[str]:
@@ -3801,12 +3847,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
old_total_episode=old_total_episode,
)
update_data["last_update"] = now
subscribe = (
self.subscription_repository.update(
subscribe.id,
SubscriptionPatch(update_data),
)
or subscribe
subscribe = self.__apply_subscribe_update(
subscribe,
update_data,
scene="episode_refresh",
)
logger.info(
f"订阅 {subscribe.name}{subscribe.season}季 总集数更新为 {new_total_episode}"

View File

@@ -2,15 +2,85 @@
from __future__ import annotations
import copy
from collections.abc import Callable
from typing import Optional, Union
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.models.systemconfig import SystemConfig
from app.db.oper.userconfig import UserConfigOper
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.common import JsonData
from app.schemas.types import UserConfigKey
from app.schemas.types import SystemConfigKey, UserConfigKey
class SessionSystemConfigurationRepository:
"""复用调用方 Session 锁定和暂存 SystemConfig且不拥有提交。"""
def __init__(self, session: Session | AsyncSession) -> None:
"""绑定规则组命令持有的同步或异步 Session。"""
self._session = session
def _sync_session(self) -> Session:
"""返回同步 Session并拒绝同步异步混用。"""
if not isinstance(self._session, Session):
raise RuntimeError("该系统配置操作需要同步 Session")
return self._session
def _async_session(self) -> AsyncSession:
"""返回异步 Session并拒绝同步异步混用。"""
if not isinstance(self._session, AsyncSession):
raise RuntimeError("该系统配置操作需要异步 Session")
return self._session
def get_for_update(self, key: SystemConfigKey) -> JsonData:
"""同步锁定配置行并返回独立值副本。"""
record = self._sync_session().execute(
select(SystemConfig)
.where(SystemConfig.key == key.value)
.with_for_update()
).scalar_one_or_none()
return copy.deepcopy(record.value if record is not None else None)
async def async_get_for_update(self, key: SystemConfigKey) -> JsonData:
"""异步锁定配置行并返回独立值副本。"""
result = await self._async_session().execute(
select(SystemConfig)
.where(SystemConfig.key == key.value)
.with_for_update()
)
record = result.scalar_one_or_none()
return copy.deepcopy(record.value if record is not None else None)
def stage_set(self, key: SystemConfigKey, value: JsonData) -> None:
"""在同步 Session 中暂存配置值,不提交事务。"""
session = self._sync_session()
record = session.execute(
select(SystemConfig).where(SystemConfig.key == key.value)
).scalar_one_or_none()
if record is None:
session.add(SystemConfig(key=key.value, value=copy.deepcopy(value)))
else:
record.value = copy.deepcopy(value)
async def async_stage_set(
self,
key: SystemConfigKey,
value: JsonData,
) -> None:
"""在 AsyncSession 中暂存配置值,不提交事务。"""
session = self._async_session()
result = await session.execute(
select(SystemConfig).where(SystemConfig.key == key.value)
)
record = result.scalar_one_or_none()
if record is None:
session.add(SystemConfig(key=key.value, value=copy.deepcopy(value)))
else:
record.value = copy.deepcopy(value)
class TransactionalUserConfigurationRepository:

View File

@@ -1,11 +1,14 @@
"""订阅写入端口的 SQLAlchemy 事务适配器。"""
from __future__ import annotations
import builtins
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timedelta, timezone
from typing import Optional, TypeVar, cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -29,11 +32,16 @@ from app.application.subscription.contract import (
SubscriptionIdentity,
SubscriptionPatch,
SubscriptionSnapshot,
SubscriptionStagingPort,
SubscriptionWriteResult,
)
from app.application.subscription.write import (
AsyncCreateSubscriptionBatchCommand,
AsyncCreateSubscriptionCommand,
AsyncSubscriptionOutboxStager,
AsyncUnitOfWork,
CreateSubscriptionCommand,
SubscriptionCreateRequest,
subscription_added_event_key,
subscription_added_notification_key,
subscription_added_report_key,
@@ -264,25 +272,13 @@ class _TransactionalSubscriptionWriter:
class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter):
"""以独立短 Session 实现订阅查询、修改和历史查询端口。"""
"""以独立短 Session 实现订阅查询、新增和历史查询端口。"""
def _read(self, operation: Callable[[SubscribeOper], T]) -> T:
"""在独立同步 Session 中执行查询并投影快照。"""
with self._sync_session() as session:
return operation(SubscribeOper(session))
def _write(self, operation: Callable[[SubscribeOper], T]) -> T:
"""在独立同步 UoW 中执行写入。"""
with self._sync_session() as session:
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
result = operation(SubscribeOper(session))
unit_of_work.commit()
return result
except Exception:
unit_of_work.rollback()
raise
async def _async_read(
self,
operation: Callable[[SubscribeOper], Awaitable[T]],
@@ -291,21 +287,6 @@ class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter):
async with self._async_session() as session:
return await operation(SubscribeOper(session))
async def _async_write(
self,
operation: Callable[[SubscribeOper], Awaitable[T]],
) -> T:
"""在独立异步 UoW 中执行写入。"""
async with self._async_session() as session:
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
try:
result = await operation(SubscribeOper(session))
await unit_of_work.commit()
return result
except Exception:
await unit_of_work.rollback()
raise
def exists(self, identity: SubscriptionIdentity) -> bool:
"""同步判断媒体身份是否已有订阅。"""
return bool(
@@ -374,6 +355,7 @@ class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter):
return await self._async_read(operation)
async def async_list(
self,
state: Optional[str] = None,
@@ -438,46 +420,6 @@ class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter):
return await self._async_read(operation)
def update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""在独立同步事务中更新并返回订阅快照。"""
return self._write(
lambda repository: (
_project_subscription(record)
if (record := repository.update(subscribe_id, patch.to_payload())) is not None
else None
)
)
async def async_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""在独立异步事务中更新并返回订阅快照。"""
async def operation(repository: SubscribeOper) -> Optional[SubscriptionSnapshot]:
"""更新并在当前 Session 中投影订阅。"""
record = await repository.async_update(subscribe_id, patch.to_payload())
return _project_subscription(record) if record is not None else None
return await self._async_write(operation)
async def async_update_filter_groups(
self,
subscribe_id: int,
filter_groups: builtins.list[str],
) -> Optional[SubscriptionSnapshot]:
"""在独立异步事务中更新过滤规则组并返回快照。"""
return await self.async_update(
subscribe_id,
SubscriptionPatch({"filter_groups": filter_groups}),
)
class TransactionalSubscriptionHistoryRepository:
"""以独立短 AsyncSession 实现 Agent 等后台入口的订阅历史查询。"""
@@ -612,6 +554,28 @@ class SessionSubscriptionRepository:
records = await self._async_repository().async_list(state)
return [_project_subscription(record) for record in records]
def list_for_reference_rewrite(self) -> builtins.list[SubscriptionSnapshot]:
"""同步锁定全部订阅,供跨表规则组引用事务稳定重写。"""
session = self._session
if not isinstance(session, Session):
raise RuntimeError("规则组引用重写需要调用方提供同步 Session")
records = session.execute(
select(Subscribe).order_by(Subscribe.id).with_for_update()
).scalars().all()
return [_project_subscription(record) for record in records]
async def async_list_for_reference_rewrite(
self,
) -> builtins.list[SubscriptionSnapshot]:
"""异步锁定全部订阅,供跨表规则组引用事务稳定重写。"""
session = self._session
if not isinstance(session, AsyncSession):
raise RuntimeError("规则组引用重写需要调用方提供 AsyncSession")
result = await session.execute(
select(Subscribe).order_by(Subscribe.id).with_for_update()
)
return [_project_subscription(record) for record in result.scalars().all()]
async def async_list_by_username(
self,
username: str,
@@ -661,6 +625,15 @@ class SessionSubscriptionRepository:
result = await self._async_repository().async_stage_add(identity.to_payload(), payload.to_payload(), username)
return SubscriptionWriteResult(result.subscribe_id, result.message, result.created)
def stage_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""同步暂存更新并返回事务内订阅快照。"""
record = self._sync_repository().update(subscribe_id, patch.to_payload())
return _project_subscription(record) if record is not None else None
async def async_stage_update(
self,
subscribe_id: int,
@@ -670,14 +643,6 @@ class SessionSubscriptionRepository:
record = await self._async_repository().async_stage_update(subscribe_id, patch.to_payload())
return _project_subscription(record) if record is not None else None
async def async_update(
self,
subscribe_id: int,
patch: SubscriptionPatch,
) -> Optional[SubscriptionSnapshot]:
"""拒绝在请求级 adapter 内隐式拥有提交权。"""
raise RuntimeError(f"请求级订阅 {subscribe_id} 更新必须使用 async_stage_update + UoW")
async def get_candidate(
self,
subscribe_id: int,
@@ -781,6 +746,59 @@ class SessionSubscriptionHistoryRepository:
await self._repository.async_delete(history_id)
class SessionSubscriptionBatchWriter:
"""使用请求级共享异步 Session 原子新增一组订阅并结算提交后效果。"""
def __init__(
self,
*,
repository: SubscriptionStagingPort,
unit_of_work: AsyncUnitOfWork,
outbox: AsyncSubscriptionOutboxStager,
dispatch_store: AsyncOutboxDispatchStore,
) -> None:
"""注入同一请求事务的写端口和 durable intent 结算能力。"""
self._command = AsyncCreateSubscriptionBatchCommand(
repository=repository,
unit_of_work=unit_of_work,
outbox=outbox,
)
self._dispatch_store = dispatch_store
async def async_add(
self,
requests: Sequence[SubscriptionCreateRequest],
) -> tuple[SubscriptionWriteResult, ...]:
"""提交整批订阅后逐条认领并结算与单条新增一致的 intents。"""
async def settle(
subscribe_id: int,
request: SubscriptionCreateRequest,
) -> None:
"""在批量事务提交后结算一条订阅的事件和统计 intents。"""
payload = request.payload.to_payload()
notification = (
dict(request.notification) if request.notification else None
)
async def invoke() -> bool | None:
"""复用准备阶段冻结的单条新增提交后副作用。"""
if request.after_commit is None:
return True
delivered = await request.after_commit(subscribe_id)
return None if delivered is None else bool(delivered)
await _deliver_added_effects_async(
self._dispatch_store,
subscribe_id,
payload,
notification,
invoke,
)
return await self._command.execute(requests, after_commit=settle)
def _added_effect_keys(
subscribe_id: int,
payload: dict[str, JsonData],

View File

@@ -1,6 +1,6 @@
import copy
import threading
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any, Optional, TypeVar, Union
from sqlalchemy import select
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.systemconfig import SystemConfig
from app.schemas.types import SystemConfigKey
from app.foundation.singleton import Singleton
from app.schemas.types import SystemConfigKey
T = TypeVar("T")
@@ -55,6 +55,16 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
with self._snapshot_lock:
self.__SYSTEMCONF.pop(key, None)
def publish_many(
self,
values: Mapping[SystemConfigKey, Any],
) -> None:
"""在外部事务提交后一次发布多项配置快照。"""
with self._snapshot_lock:
self._require_loaded()
for key, value in values.items():
self.__SYSTEMCONF[key.value] = copy.deepcopy(value)
def set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
"""
设置系统设置

View File

@@ -1,6 +1,7 @@
"""宿主启动阶段构建的类型化运行时上下文。"""
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from dataclasses import dataclass, field
from typing import Protocol
@@ -16,11 +17,20 @@ from app.application.messaging.chat import (
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.rules import AsyncRuleGroupMutationService, SyncRuleGroupMutationService
from app.application.site.contract import SiteRepository
from app.application.site.mutation import SyncSiteReferenceMutationService
from app.application.subscription.contract import (
SubscriptionHistoryStagingPort,
SubscriptionStagingPort,
)
from app.application.subscription.write import (
AsyncSubscriptionOutboxStager,
SubscriptionBatchWritePort,
)
from app.application.subscription.write import (
AsyncUnitOfWork as SubscriptionAsyncUnitOfWork,
)
from app.application.transfer.execution import TransferExecutionRepository
from app.application.workflow import WorkflowCachePort, WorkflowQueryService
from app.runtime.tasks import TaskRegistry
@@ -69,6 +79,21 @@ class SubscriptionHistoryRepositoryFactory(Protocol):
...
class SubscriptionBatchWriterFactory(Protocol):
"""由请求事务组件构造原子批量订阅写端口的工厂。"""
def __call__(
self,
*,
repository: SubscriptionStagingPort,
unit_of_work: SubscriptionAsyncUnitOfWork,
outbox: AsyncSubscriptionOutboxStager,
dispatch_store: AsyncOutboxDispatchStore,
) -> SubscriptionBatchWritePort:
"""组合共享 Session、UoW 与 outbox 并返回批量写端口。"""
...
class AsyncSessionProvider(Protocol):
"""FastAPI 请求级异步会话提供器。"""
@@ -192,6 +217,16 @@ class SubscriptionRuntime:
transaction: AsyncUnitOfWorkFactory
outbox: AsyncOutboxFactory
dispatch_store: AsyncOutboxDispatchStore
batch_writer: SubscriptionBatchWriterFactory
rule_group_mutation_scope: Callable[
[], AbstractContextManager[SyncRuleGroupMutationService]
]
async_rule_group_mutation_scope: Callable[
[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]
]
site_reference_mutation_scope: Callable[
[], AbstractContextManager[SyncSiteReferenceMutationService]
]
@dataclass(frozen=True, slots=True)

View File

@@ -1,24 +1,34 @@
"""订阅事务作用域及提交后回调的组合装配。"""
from collections.abc import AsyncIterator, Iterator
import asyncio
import threading
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
from contextlib import asynccontextmanager, contextmanager
from typing import Any
from app.adapters.external.server import MoviePilotServerHelper
from app.application.subscription.complete import (
CompleteSubscriptionCommand,
configure_subscription_completion_scope,
from app.application.outbox import AsyncOutboxDispatchStore
from app.application.rules import (
AsyncRuleGroupMutationService,
SyncRuleGroupMutationService,
)
from app.application.site.mutation import SyncSiteReferenceMutationService
from app.application.subscription.complete import CompleteSubscriptionCommand
from app.application.subscription.contract import SubscriptionStagingPort
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SyncDeleteSubscribeCommand,
configure_delete_subscribe_scope,
configure_sync_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionMutationService,
configure_subscription_mutation_scope,
SyncSubscriptionMutationService,
)
from app.application.subscription.write import (
AsyncSubscriptionOutboxStager,
AsyncUnitOfWork,
SubscriptionBatchWritePort,
)
from app.db.adapters.configuration import SessionSystemConfigurationRepository
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
@@ -26,33 +36,121 @@ from app.db.adapters.outbox import (
SqlAlchemyOutboxStager,
)
from app.db.adapters.subscription import (
SessionSubscriptionBatchWriter,
SessionSubscriptionHistoryRepository,
SessionSubscriptionRepository,
)
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.runtime.events import EventManager
from app.schemas.types import EventType
from app.runtime.events import eventmanager
from app.schemas.common import JsonData
from app.schemas.types import EventType, SystemConfigKey
_reference_mutation_lock = threading.Lock()
SystemConfigPublisher = Callable[[Mapping[SystemConfigKey, JsonData]], None]
def build_subscription_batch_writer(
*,
repository: SubscriptionStagingPort,
unit_of_work: AsyncUnitOfWork,
outbox: AsyncSubscriptionOutboxStager,
dispatch_store: AsyncOutboxDispatchStore,
) -> SubscriptionBatchWritePort:
"""组装复用请求级事务且在提交后结算 durable intents 的批量写端口。"""
return SessionSubscriptionBatchWriter(
repository=repository,
unit_of_work=unit_of_work,
outbox=outbox,
dispatch_store=dispatch_store,
)
@contextmanager
def rule_group_mutation_scope(
publish: SystemConfigPublisher,
) -> Iterator[SyncRuleGroupMutationService]:
"""串行构造共享同步 Session 的规则定义与引用原子服务。"""
_reference_mutation_lock.acquire()
session = SessionFactory()
try:
yield SyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=publish,
)
finally:
session.close()
_reference_mutation_lock.release()
@asynccontextmanager
async def async_rule_group_mutation_scope(
publish: SystemConfigPublisher,
) -> AsyncIterator[AsyncRuleGroupMutationService]:
"""非阻塞等待跨入口锁,并构造共享 AsyncSession 的规则原子服务。"""
await asyncio.to_thread(_reference_mutation_lock.acquire)
try:
async with async_session_scope() as session:
async def publish_async(
values: Mapping[SystemConfigKey, JsonData],
) -> None:
"""适配同步快照发布器到异步命令提交后合同。"""
publish(values)
yield AsyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish=publish_async,
)
finally:
_reference_mutation_lock.release()
@contextmanager
def site_reference_mutation_scope(
publish: SystemConfigPublisher,
) -> Iterator[SyncSiteReferenceMutationService]:
"""构造共享同步 Session 的 RSS 与订阅站点引用原子服务。"""
_reference_mutation_lock.acquire()
session = SessionFactory()
try:
yield SyncSiteReferenceMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=publish,
)
finally:
session.close()
_reference_mutation_lock.release()
async def _publish_modified(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅修改事件。"""
await EventManager().async_send_event(EventType.SubscribeModified, payload)
await eventmanager.async_send_event(EventType.SubscribeModified, payload)
def _publish_modified_sync(payload: dict[str, Any]) -> None:
"""为同步 Chain 入口发布事务已提交的订阅修改事件。"""
eventmanager.send_event(EventType.SubscribeModified, payload)
async def _publish_deleted(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅删除事件。"""
await EventManager().async_send_event(EventType.SubscribeDeleted, payload)
await eventmanager.async_send_event(EventType.SubscribeDeleted, payload)
def _publish_deleted_sync(payload: dict[str, Any]) -> None:
"""为同步消息入口发布事务已提交的订阅删除事件。"""
EventManager().send_event(EventType.SubscribeDeleted, payload)
eventmanager.send_event(EventType.SubscribeDeleted, payload)
def _publish_completed(payload: dict[str, Any]) -> None:
"""发布已提交的订阅完成事件。"""
EventManager().send_event(EventType.SubscribeComplete, payload)
eventmanager.send_event(EventType.SubscribeComplete, payload)
@contextmanager
@@ -87,6 +185,22 @@ async def subscription_mutation_scope() -> AsyncIterator[SubscriptionMutationSer
)
@contextmanager
def sync_subscription_mutation_scope() -> Iterator[SyncSubscriptionMutationService]:
"""为同步 Chain 入口创建独占 Session、UoW 与 durable outbox。"""
session = SessionFactory()
try:
yield SyncSubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish_modified=_publish_modified_sync,
)
finally:
session.close()
@asynccontextmanager
async def delete_subscribe_scope() -> AsyncIterator[DeleteSubscribeCommand]:
"""为非 HTTP 入口创建独占订阅删除会话、UoW 与 outbox。"""
@@ -118,11 +232,3 @@ def sync_delete_subscribe_scope() -> Iterator[SyncDeleteSubscribeCommand]:
)
finally:
session.close()
def configure_transactional_subscription_scopes() -> None:
"""登记 Agent 等非 HTTP 入口复用的订阅事务作用域。"""
configure_subscription_mutation_scope(subscription_mutation_scope)
configure_delete_subscribe_scope(delete_subscribe_scope)
configure_sync_delete_subscribe_scope(sync_delete_subscribe_scope)
configure_subscription_completion_scope(subscription_completion_scope)

View File

@@ -1,6 +1,7 @@
import asyncio
import inspect
import sys
from functools import partial
from typing import Any, Callable
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
@@ -111,7 +112,6 @@ from app.application.service import configure_service_directory
from app.application.site.health import SiteHealthService, configure_site_health_service
from app.application.site.query import SiteQueryService, configure_site_query_service
from app.application.subscription.contract import SubscriptionRepository
from app.application.subscription.write import configure_subscribe_writer
from app.application.workflow import (
WorkflowQueryService,
configure_workflow_execution,
@@ -223,7 +223,15 @@ from app.startup.composition.context import (
)
from app.startup.composition.database import build_database_governance
from app.startup.composition.subscription import (
configure_transactional_subscription_scopes,
async_rule_group_mutation_scope,
build_subscription_batch_writer,
delete_subscribe_scope,
rule_group_mutation_scope,
site_reference_mutation_scope,
subscription_completion_scope,
subscription_mutation_scope,
sync_delete_subscribe_scope,
sync_subscription_mutation_scope,
)
from app.startup.initializers.agent import configure_agent_data_context, init_agent
from app.startup.initializers.resources import (
@@ -289,6 +297,7 @@ def _execute_legacy_transfer_command(**kwargs: Any) -> Any:
def _build_chain_runtime_context(
*,
system_config: SystemConfigOper,
site: TransactionalSiteRepository,
subscription: TransactionalSubscriptionRepository,
download_history: TransactionalDownloadHistoryRepository,
@@ -307,6 +316,19 @@ def _build_chain_runtime_context(
module_dispatcher_factory=ModuleInvocationDispatcher,
site_repository=site,
subscription_repository=subscription,
subscription_mutation_scope=subscription_mutation_scope,
sync_subscription_mutation_scope=sync_subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
sync_subscription_delete_scope=sync_delete_subscribe_scope,
subscription_completion_scope=subscription_completion_scope,
rule_group_mutation_scope=partial(
rule_group_mutation_scope,
system_config.publish_many,
),
site_reference_mutation_scope=partial(
site_reference_mutation_scope,
system_config.publish_many,
),
download_history_repository=download_history,
transfer_history_repository=transfer_history,
transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory),
@@ -892,6 +914,12 @@ async def init_modules() -> HostRuntime:
users=_build_transactional_user_repository(),
sites=site_repository,
subscriptions=subscription_repository,
subscription_mutation_scope=subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
async_rule_group_mutation_scope=partial(
async_rule_group_mutation_scope,
system_config.publish_many,
),
subscription_history=subscription_history_repository,
transfer_history=transfer_history_repository,
transfer_execution=transfer_execution_repository,
@@ -937,6 +965,19 @@ async def init_modules() -> HostRuntime:
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope),
batch_writer=build_subscription_batch_writer,
rule_group_mutation_scope=partial(
rule_group_mutation_scope,
system_config.publish_many,
),
async_rule_group_mutation_scope=partial(
async_rule_group_mutation_scope,
system_config.publish_many,
),
site_reference_mutation_scope=partial(
site_reference_mutation_scope,
system_config.publish_many,
),
),
workflow=WorkflowRuntime(
query=workflow_query,
@@ -1001,8 +1042,6 @@ async def init_modules() -> HostRuntime:
sync_transaction=transaction_runner.sync,
)
)
configure_subscribe_writer(lambda: subscription_repository)
configure_transactional_subscription_scopes()
# 托管资源只在这里装配声明与 adapter具体资源仍由首个消费者显式激活。
init_managed_resources()
# 应用服务不反向依赖 Chain由启动组合层注入壁纸来源。
@@ -1010,6 +1049,7 @@ async def init_modules() -> HostRuntime:
# Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。
configure_chain_runtime_context_provider(
lambda: _build_chain_runtime_context(
system_config=system_config,
site=site_repository,
subscription=subscription_repository,
download_history=download_history_repository,

View File

@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 858 / 7,062 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 858 / 7,091 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,734 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 760 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| Ruff 历史诊断 | 754 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.83%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -161,7 +161,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- Event consumer 扫描曾把任意同名 `.register()` 调用当成事件注册S0-L2.5 已改为证明
canonical EventManager receiver10 个动态误报归零并保留唯一 workflow 动态注册。
- S0-L2.6 已将 producer/consumer 合并为逐调用事实源;本轮统一 Transfer 事件发送点后为
97 个 producer96 静态、1 动态)与
95 个 producer94 静态、1 动态)与
17 个 consumer16 静态、1 动态consumer 由不可自动写入的精确人工 policy 管理。
**目标与步骤**
@@ -317,13 +317,14 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] Subscription 增加 AST 门禁,禁止 canonical consumer 导入 raw Oper/ORM、以 `Any` 伪装
Snapshot、复制 CRUD Protocol 或新增多词散落文件;全局 Agent/Transfer locator 已清零并由独立 AST 门禁守护。
**后续顺序**
**交付结果**
1. S1-L4 收口 Subscription 新增、修改、删除、完成和批量修改的单一 UoW删除自动提交写入口
2. S1-L4 对 Subscription 新增、修改、删除、完成和批量修改逐用例验证单一 UoW禁止把逐条短事务
误当作批量原子事务
3. S1-L5 将站点、规则组引用的 SystemConfig 与 Subscription 更新合并为原子命令,并在 commit 后
一次发布配置快照。
1. S1-L4 收口 Subscription 新增、修改、删除、完成和批量修改的单一 UoWcanonical 自动提交写入口
与运行时 locator 清零Servarr 多季新增共享一个请求事务和 outbox任一季失败整批回滚。
2. S1-L4 已逐用例验证 Session-bound Command 的提交、回滚和 post-commit 语义,不再以逐条短事务
冒充批量原子事务。
3. S1-L5 已将站点、规则组及自定义规则改名涉及的 SystemConfig 与 Subscription 更新合并为原子命令,
通过 CAS 拒绝过期快照,并在 commit 后一次发布配置快照。
**验收**
@@ -353,15 +354,15 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**目标与步骤**
- [ ] 将引用分析与修改计划提取为纯函数/值对象。
- [ ] SystemConfig 与 Subscribe 位于同一宿主数据库,首选一个 Application Command/UoW 和批量原子更新;
- [x] 将引用分析与修改计划提取为纯函数/值对象。
- [x] SystemConfig 与 Subscribe 位于同一宿主数据库,首选一个 Application Command/UoW 和批量原子更新;
复用 `SystemConfigOper.update_atomically()` 的锁行能力,而不是预设必须跨存储补偿。
- [ ] 同一 UoW 内修改配置表后,在 commit 成功时一次性发布全部进程内配置快照,并明确
- [x] 同一 UoW 内修改配置表后,在 commit 成功时一次性发布全部进程内配置快照,并明确
`_write_lock`/`_snapshot_lock` 的顺序;读者只能观察完整旧快照或完整新快照。
- [ ] 仅当未来确有无法共享事务的外部状态时,才使用持久、幂等、带 checkpoint 的 reconciliation job。
- [ ] 每一步允许安全重试,返回明确的完成/待恢复状态。
- [ ] 在第 `k` 次写入注入异常,验证整体回滚或下次能恢复到完整状态。
- [ ] 增加并发触发和并发读取测试,验证没有丢更新、死锁或配置中间组合。
- [x] 仅当未来确有无法共享事务的外部状态时,才使用持久、幂等、带 checkpoint 的 reconciliation job。
- [x] 每一步允许安全重试,返回明确的完成/冲突状态。
- [x] 在第 `k` 次写入注入异常,验证整体回滚或下次能恢复到完整状态。
- [x] 增加并发触发和并发读取测试,验证没有丢更新、死锁或配置中间组合。
```bash
.venv/bin/python -m pytest \

View File

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

View File

@@ -108,8 +108,8 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S1-L3.6 Site | `VERIFIED` | S1-L3.5 | Site 配置、用户数据、图标和统计统一为深度冻结 DTO 与 typed query/write/staging Port请求写入复用 AsyncSessionChain/Agent 使用独立短事务canonical 不再接收 raw Oper/ORM旧插件 ABI 只经 SDK Legacy/Compat |
| S1-L3.7 Subscription | `VERIFIED` | S1-L3.6 | `application/subscription/contract.py` 统一深度冻结 Snapshot/History/Identity/Patch 与 typed query/write/staging RepositoryDB adapter 在 Session 内完成 ORM 投影Chain/API/Agent/Workflow/interaction 不再消费 raw Oper/ORM/`Any`;旧 `SubscribeOper`/`SubscribeHistoryOper` 只经同一 SDK Legacy/Compat 门面保留插件 ABI |
| S1-L3.8 Agent/Transfer locator gate | `VERIFIED` | S1-L3.7 | `chain/data.py``agentdata.py` 及其 getter 已删除Chain/Agent 改用显式 typed contextAST 门禁确认 canonical 无 raw getter/Oper/Any全量有效 `6955 passed, 9 skipped`Application 覆盖率提升至 `79.83%`,依赖边降至 `7,062` |
| S1-L4 Subscription mutation UoW | `PLANNED` | S1-L3.8 | 在已完成 typed DTO/Port 的基础上,逐项证明新增、修改、删除、完成及批量修改由用例拥有单一 UoW禁止把短事务 Repository 当作跨记录原子事务,旧自动事务入口退出 canonical 路径 |
| S1-L5 站点/规则引用原子清理 | `PLANNED` | S1-L4 | SystemConfig+Subscribe 同事务更新commit 后快照原子发布,并发/故障注入无部分状态 |
| S1-L4 Subscription mutation UoW | `VERIFIED` | S1-L3.8 | 新增、修改、删除、完成均由显式 Session-bound Command/UoW 拥有事务Servarr 多季新增在一个请求事务内提交全部订阅和 durable intents任一季失败整批回滚canonical 自动提交写入口与 locator 已退出,专项联合测试 327 项通过;当前依赖事实为 7,091 条边 |
| S1-L5 站点/规则引用原子清理 | `VERIFIED` | S1-L4 | 站点、规则组及自定义规则改名的 SystemConfig+Subscribe 共享一个 UoW双事实 CAS 防止过期覆盖commit 后一次发布配置快照;故障注入、通配重置、并发冲突与事件循环心跳测试通过 |
| S1-L6 Outbox 完成语义 | `VERIFIED` | S0 | 事务内 `OutboxStager` 与独立短事务 `OutboxDispatchStore` 已分离;即时投递与 dispatcher 均先 claimcomplete/retry 受 attempt fencing`PostCommitResult` 区分已提交业务、已完成与 pending effect。事件载荷和宿主 correlation context 携带稳定 event key旧通知插件保持原签名并承认 at-least-once 重复边界 |
### S2进程生命周期、循环与 Adapter 边界
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 760 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 754 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口

View File

@@ -7,6 +7,7 @@
import asyncio
import sys
from collections.abc import Awaitable, Callable
from functools import partial
from typing import TypeVar
import pytest
@@ -146,6 +147,16 @@ def configure_plugin_system_services():
build_scheduler_runtime_config,
build_token_runtime_config,
)
from app.startup.composition.subscription import (
async_rule_group_mutation_scope,
delete_subscribe_scope,
rule_group_mutation_scope,
site_reference_mutation_scope,
subscription_completion_scope,
subscription_mutation_scope,
sync_delete_subscribe_scope,
sync_subscription_mutation_scope,
)
configure_token_codec(create_access_token, decode_access_token)
configure_runtime_configuration(
@@ -194,7 +205,6 @@ def configure_plugin_system_services():
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.module import configure_module_runtime
from app.application.plugin.runtime import configure_plugin_runtime
from app.application.subscription.write import configure_subscribe_writer
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
@@ -303,13 +313,6 @@ def configure_plugin_system_services():
"sync": SqlAlchemyUnitOfWork,
},
)
configure_subscribe_writer(
lambda: TransactionalSubscriptionRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
)
def site_repository() -> TransactionalSiteRepository:
"""按生产组合根方式创建显式事务站点仓储。"""
return TransactionalSiteRepository(
@@ -349,6 +352,19 @@ def configure_plugin_system_services():
module_dispatcher_factory=ModuleInvocationDispatcher,
site_repository=site_repository(),
subscription_repository=subscription_repository,
subscription_mutation_scope=subscription_mutation_scope,
sync_subscription_mutation_scope=sync_subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
sync_subscription_delete_scope=sync_delete_subscribe_scope,
subscription_completion_scope=subscription_completion_scope,
rule_group_mutation_scope=partial(
rule_group_mutation_scope,
system_config.publish_many,
),
site_reference_mutation_scope=partial(
site_reference_mutation_scope,
system_config.publish_many,
),
download_history_repository=download_history_repository,
transfer_history_repository=transfer_history_repository,
transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory),
@@ -387,6 +403,12 @@ def configure_plugin_system_services():
users=user_repository(),
sites=site_repository(),
subscriptions=subscription_repository,
subscription_mutation_scope=subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
async_rule_group_mutation_scope=partial(
async_rule_group_mutation_scope,
system_config.publish_many,
),
subscription_history=TransactionalSubscriptionHistoryRepository(
async_session=async_session_scope,
),

View File

@@ -1435,8 +1435,8 @@
"runtime_only": true
}
},
"edge_count": 7062,
"edge_sha256": "5aca9b3ce61d2ed53f133fb119eaaadb83e6a1ea6a07c1fb31aeac253cb2f000",
"edge_count": 7091,
"edge_sha256": "a9ee879767dd76255d903e128fd57d387f3ed3514c8f6e3b4cfd7400f0be86b2",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2709,7 +2709,7 @@
"app.agent.tools.impl.search_subscribe -> app.agent.tools.tags",
"app.agent.tools.impl.search_subscribe -> app.application",
"app.agent.tools.impl.search_subscribe -> app.application.subscription",
"app.agent.tools.impl.search_subscribe -> app.application.subscription.contract",
"app.agent.tools.impl.search_subscribe -> app.application.subscription.mutation",
"app.agent.tools.impl.search_subscribe -> app.chain",
"app.agent.tools.impl.search_subscribe -> app.chain.subscribe",
"app.agent.tools.impl.search_subscribe -> app.runtime",
@@ -3056,6 +3056,7 @@
"app.api.dependencies.subscription -> app.application.subscription.mutation",
"app.api.dependencies.subscription -> app.application.subscription.query",
"app.api.dependencies.subscription -> app.application.subscription.search",
"app.api.dependencies.subscription -> app.application.subscription.write",
"app.api.dependencies.subscription -> app.runtime",
"app.api.dependencies.subscription -> app.runtime.events",
"app.api.dependencies.subscription -> app.runtime.log",
@@ -3669,6 +3670,7 @@
"app.api.endpoints.subscribe -> app.application.configuration",
"app.api.endpoints.subscribe -> app.application.scheduling",
"app.api.endpoints.subscribe -> app.application.subscription",
"app.api.endpoints.subscribe -> app.application.subscription.contract",
"app.api.endpoints.subscribe -> app.application.subscription.delete",
"app.api.endpoints.subscribe -> app.application.subscription.identity",
"app.api.endpoints.subscribe -> app.application.subscription.mutation",
@@ -3680,11 +3682,9 @@
"app.api.endpoints.subscribe -> app.domain.context",
"app.api.endpoints.subscribe -> app.domain.metainfo",
"app.api.endpoints.subscribe -> app.runtime",
"app.api.endpoints.subscribe -> app.runtime.events",
"app.api.endpoints.subscribe -> app.runtime.tasks",
"app.api.endpoints.subscribe -> app.schemas",
"app.api.endpoints.subscribe -> app.schemas.common",
"app.api.endpoints.subscribe -> app.schemas.event",
"app.api.endpoints.subscribe -> app.schemas.media",
"app.api.endpoints.subscribe -> app.schemas.response",
"app.api.endpoints.subscribe -> app.schemas.subscribe",
@@ -3707,6 +3707,7 @@
"app.api.endpoints.system -> app.agent.llm",
"app.api.endpoints.system -> app.agent.llm.server_tools",
"app.api.endpoints.system -> app.api",
"app.api.endpoints.system -> app.api.context",
"app.api.endpoints.system -> app.api.dependencies",
"app.api.endpoints.system -> app.api.dependencies.auth",
"app.api.endpoints.system -> app.api.principal",
@@ -3738,7 +3739,6 @@
"app.api.endpoints.system -> app.foundation.environment",
"app.api.endpoints.system -> app.foundation.url",
"app.api.endpoints.system -> app.runtime",
"app.api.endpoints.system -> app.runtime.config",
"app.api.endpoints.system -> app.runtime.events",
"app.api.endpoints.system -> app.runtime.execution",
"app.api.endpoints.system -> app.runtime.localization",
@@ -3756,6 +3756,9 @@
"app.api.endpoints.system -> app.schemas.system",
"app.api.endpoints.system -> app.schemas.token",
"app.api.endpoints.system -> app.schemas.types",
"app.api.endpoints.system -> app.startup",
"app.api.endpoints.system -> app.startup.composition",
"app.api.endpoints.system -> app.startup.composition.context",
"app.api.endpoints.tmdb -> app.adapters",
"app.api.endpoints.tmdb -> app.adapters.web",
"app.api.endpoints.tmdb -> app.adapters.web.security",
@@ -3913,6 +3916,8 @@
"app.api.servarr -> app.api.response",
"app.api.servarr -> app.application",
"app.api.servarr -> app.application.servarr",
"app.api.servarr -> app.application.subscription",
"app.api.servarr -> app.application.subscription.write",
"app.api.servarr -> app.chain",
"app.api.servarr -> app.chain.media",
"app.api.servarr -> app.chain.subscribe",
@@ -3994,6 +3999,7 @@
"app.application.configuration -> app.application",
"app.application.configuration -> app.application.database",
"app.application.configuration -> app.schemas",
"app.application.configuration -> app.schemas.common",
"app.application.configuration -> app.schemas.types",
"app.application.dashboard -> app.application",
"app.application.dashboard -> app.application.history",
@@ -4285,9 +4291,13 @@
"app.application.rules -> app.adapters.system.rust",
"app.application.rules -> app.application",
"app.application.rules -> app.application.configuration",
"app.application.rules -> app.application.outbox",
"app.application.rules -> app.application.subscription",
"app.application.rules -> app.application.subscription.contract",
"app.application.rules -> app.domain",
"app.application.rules -> app.domain.context",
"app.application.rules -> app.schemas",
"app.application.rules -> app.schemas.common",
"app.application.rules -> app.schemas.rule",
"app.application.rules -> app.schemas.system",
"app.application.rules -> app.schemas.types",
@@ -4360,12 +4370,16 @@
"app.application.site.health -> app.application.site",
"app.application.site.health -> app.application.site.contract",
"app.application.site.mutation -> app.application",
"app.application.site.mutation -> app.application.configuration",
"app.application.site.mutation -> app.application.outbox",
"app.application.site.mutation -> app.application.site",
"app.application.site.mutation -> app.application.site.contract",
"app.application.site.mutation -> app.application.subscription",
"app.application.site.mutation -> app.application.subscription.contract",
"app.application.site.mutation -> app.application.subscription.delete",
"app.application.site.mutation -> app.schemas",
"app.application.site.mutation -> app.schemas.common",
"app.application.site.mutation -> app.schemas.types",
"app.application.site.query -> app.application",
"app.application.site.query -> app.application.site",
"app.application.site.query -> app.application.site.contract",
@@ -4569,6 +4583,7 @@
"app.chain._music -> app.application.configuration",
"app.chain._music -> app.application.subscription",
"app.chain._music -> app.application.subscription.contract",
"app.chain._music -> app.application.subscription.mutation",
"app.chain._music -> app.application.torrent",
"app.chain._music -> app.chain",
"app.chain._music -> app.chain._contracts",
@@ -4583,6 +4598,7 @@
"app.chain._music -> app.runtime",
"app.chain._music -> app.runtime.log",
"app.chain._music -> app.schemas",
"app.chain._music -> app.schemas.common",
"app.chain._music -> app.schemas.types",
"app.chain._recognition -> app.adapters",
"app.chain._recognition -> app.adapters.external",
@@ -4944,9 +4960,9 @@
"app.chain.subscribe -> app.application.messaging.message",
"app.chain.subscribe -> app.application.messaging.subscribe",
"app.chain.subscribe -> app.application.subscription",
"app.chain.subscribe -> app.application.subscription.complete",
"app.chain.subscribe -> app.application.subscription.contract",
"app.chain.subscribe -> app.application.subscription.delete",
"app.chain.subscribe -> app.application.subscription.mutation",
"app.chain.subscribe -> app.application.subscription.priority",
"app.chain.subscribe -> app.application.subscription.query",
"app.chain.subscribe -> app.application.subscription.write",
@@ -4972,6 +4988,7 @@
"app.chain.subscribe -> app.runtime.log",
"app.chain.subscribe -> app.runtime.stop",
"app.chain.subscribe -> app.schemas",
"app.chain.subscribe -> app.schemas.common",
"app.chain.subscribe -> app.schemas.event",
"app.chain.subscribe -> app.schemas.media",
"app.chain.subscribe -> app.schemas.mediaserver",
@@ -5167,6 +5184,8 @@
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.configuration -> app.db",
"app.db.adapters.configuration -> app.db.models",
"app.db.adapters.configuration -> app.db.models.systemconfig",
"app.db.adapters.configuration -> app.db.oper",
"app.db.adapters.configuration -> app.db.oper.userconfig",
"app.db.adapters.configuration -> app.db.uow",
@@ -7967,10 +7986,13 @@
"app.startup.composition.context -> app.application.messaging",
"app.startup.composition.context -> app.application.messaging.chat",
"app.startup.composition.context -> app.application.outbox",
"app.startup.composition.context -> app.application.rules",
"app.startup.composition.context -> app.application.site",
"app.startup.composition.context -> app.application.site.contract",
"app.startup.composition.context -> app.application.site.mutation",
"app.startup.composition.context -> app.application.subscription",
"app.startup.composition.context -> app.application.subscription.contract",
"app.startup.composition.context -> app.application.subscription.write",
"app.startup.composition.context -> app.application.transfer",
"app.startup.composition.context -> app.application.transfer.execution",
"app.startup.composition.context -> app.application.workflow",
@@ -7995,12 +8017,19 @@
"app.startup.composition.subscription -> app.adapters.external",
"app.startup.composition.subscription -> app.adapters.external.server",
"app.startup.composition.subscription -> app.application",
"app.startup.composition.subscription -> app.application.outbox",
"app.startup.composition.subscription -> app.application.rules",
"app.startup.composition.subscription -> app.application.site",
"app.startup.composition.subscription -> app.application.site.mutation",
"app.startup.composition.subscription -> app.application.subscription",
"app.startup.composition.subscription -> app.application.subscription.complete",
"app.startup.composition.subscription -> app.application.subscription.contract",
"app.startup.composition.subscription -> app.application.subscription.delete",
"app.startup.composition.subscription -> app.application.subscription.mutation",
"app.startup.composition.subscription -> app.application.subscription.write",
"app.startup.composition.subscription -> app.db",
"app.startup.composition.subscription -> app.db.adapters",
"app.startup.composition.subscription -> app.db.adapters.configuration",
"app.startup.composition.subscription -> app.db.adapters.outbox",
"app.startup.composition.subscription -> app.db.adapters.subscription",
"app.startup.composition.subscription -> app.db.session",
@@ -8008,6 +8037,7 @@
"app.startup.composition.subscription -> app.runtime",
"app.startup.composition.subscription -> app.runtime.events",
"app.startup.composition.subscription -> app.schemas",
"app.startup.composition.subscription -> app.schemas.common",
"app.startup.composition.subscription -> app.schemas.types",
"app.startup.initializers.agent -> app.agent",
"app.startup.initializers.agent -> app.agent.llm",
@@ -8119,7 +8149,6 @@
"app.startup.initializers.modules -> app.application.site.query",
"app.startup.initializers.modules -> app.application.subscription",
"app.startup.initializers.modules -> app.application.subscription.contract",
"app.startup.initializers.modules -> app.application.subscription.write",
"app.startup.initializers.modules -> app.application.workflow",
"app.startup.initializers.modules -> app.chain",
"app.startup.initializers.modules -> app.chain.download",

View File

@@ -308,14 +308,9 @@
"type-arg": 3
},
"app/agent/tools/impl/_filter_rule_utils.py": {
"arg-type": 1,
"attr-defined": 2,
"call-overload": 1,
"index": 2,
"attr-defined": 1,
"no-any-return": 2,
"no-untyped-call": 1,
"type-arg": 13,
"var-annotated": 1
"type-arg": 10
},
"app/agent/tools/impl/_plugin_tool_utils.py": {
"arg-type": 2,
@@ -774,7 +769,7 @@
"override": 1
},
"app/agent/tools/impl/update_rule_group.py": {
"arg-type": 5,
"arg-type": 3,
"list-item": 1,
"misc": 1,
"no-untyped-def": 2,
@@ -836,7 +831,7 @@
"arg-type": 2
},
"app/api/dependencies/subscription.py": {
"arg-type": 7
"arg-type": 5
},
"app/api/dependencies/workflow.py": {
"arg-type": 3,
@@ -1016,7 +1011,7 @@
"type-arg": 1
},
"app/api/endpoints/subscribe.py": {
"arg-type": 13,
"arg-type": 12,
"assignment": 3,
"attr-defined": 2,
"misc": 28,
@@ -1079,7 +1074,7 @@
},
"app/api/servarr.py": {
"arg-type": 4,
"assignment": 2,
"assignment": 1,
"attr-defined": 1,
"index": 3,
"misc": 16,
@@ -1231,10 +1226,7 @@
"type-arg": 5
},
"app/application/rules.py": {
"attr-defined": 1,
"no-untyped-call": 1,
"no-untyped-def": 1,
"type-arg": 7
"attr-defined": 1
},
"app/application/security/auth.py": {
"no-untyped-call": 2,
@@ -1488,16 +1480,15 @@
"type-arg": 2
},
"app/chain/subscribe.py": {
"arg-type": 58,
"assignment": 18,
"arg-type": 49,
"assignment": 16,
"attr-defined": 4,
"call-overload": 1,
"dict-item": 2,
"index": 1,
"misc": 7,
"no-any-return": 2,
"no-redef": 2,
"no-untyped-call": 19,
"no-untyped-call": 14,
"no-untyped-def": 16,
"operator": 5,
"return-value": 5,
@@ -2138,7 +2129,7 @@
"assignment": 4,
"empty-body": 1,
"index": 1,
"no-untyped-call": 3,
"no-untyped-call": 2,
"no-untyped-def": 3,
"operator": 2,
"return": 1,
@@ -3320,8 +3311,7 @@
"assignment": 1
},
"app/startup/composition/subscription.py": {
"arg-type": 2,
"no-untyped-call": 4
"arg-type": 2
},
"app/startup/initializers/agent.py": {
"no-untyped-call": 2,

View File

@@ -208,9 +208,6 @@
"app/agent/tools/impl/uninstall_plugin.py": {
"I001": 1
},
"app/agent/tools/impl/update_custom_filter_rule.py": {
"I001": 1
},
"app/agent/tools/impl/update_custom_identifiers.py": {
"I001": 1
},
@@ -226,13 +223,6 @@
"app/api/endpoints/agent.py": {
"F841": 1
},
"app/api/endpoints/subscribe.py": {
"F841": 1
},
"app/api/endpoints/system.py": {
"F401": 1,
"I001": 1
},
"app/application/directory.py": {
"I001": 1
},
@@ -339,9 +329,6 @@
"app/db/oper/site.py": {
"I001": 1
},
"app/db/oper/systemconfig.py": {
"I001": 1
},
"app/db/oper/user.py": {
"I001": 1
},
@@ -1351,9 +1338,6 @@
"tests/test_plugin_sdk.py": {
"I001": 3
},
"tests/test_plugin_system_setting_admission.py": {
"I001": 1
},
"tests/test_plugin_virtual_instances.py": {
"E731": 1
},

View File

@@ -1669,11 +1669,11 @@
],
"producer_fingerprints": [
"14564c7ef4f2057a2a03bd0070cafe7c5421184ae7b2c28d5f9735722691337c",
"5fe23577466054d66cb12b34d4df8834f8933596be0cbc84e4cbfc22694b3c03",
"6c5b8155f0e6ff9ed879b8ea43904ea05c74f81762d6ae87c71de40673a5629b",
"91ec6e0454403b22f1ddc7cb6e4fc9414df8a00892e615131e41ff0599618dce",
"91ec6e0454403b22f1ddc7cb6e4fc9414df8a00892e615131e41ff0599618dce",
"cdf41d4446e65b14d457621594f04b3e6bfce252e7701cb4be862b2259e97700",
"f2ea1cb371877b73077defb4aa5aff0dfebd81c450d02ca52f4d70d95f7b4cc1"
"cdf41d4446e65b14d457621594f04b3e6bfce252e7701cb4be862b2259e97700"
]
},
"EventType.DownloadAdded": {
@@ -1792,28 +1792,26 @@
"EventType.SubscribeComplete": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c",
"eba9652f1bf1fab63b1ab459a1636e2167e03142099cfd2dd665dab6c88989f9",
"ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b"
]
},
"EventType.SubscribeDeleted": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"49cb2961dd38ea10d2a53c7208fb3ca66ecda4c7c2f8cb2c29ea3ac62629a512",
"7ee0ba0bfdba520a389d45c33d361120a0d710c3ea37b1e03532dd7364a52d47",
"a08051c5da0861d7198f3f5aa1b4ce6ed054abfbeab35e06ecb64dea71ba8c5e",
"a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
"cbff1d6d00bcade626974178f292471c2bb7643ceb9360a0ad9fa23b87c3597d"
"ea7538a3f2ad3ddbf8de6264e0525f065eec338d2ee003a501b725e3194d490a"
]
},
"EventType.SubscribeModified": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"330678cc36e39079b052e5a6f1493cd38cf40565ddf8b231dc20c94207cb1ba9",
"3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
"4204fef37b0e1b87ff665209b1085359b778096827bed514cf61c6488ede3ee3",
"4fc5c37849498747d5d61944b47381612c88d9b6320fda9ab48e3c1fa2444232",
"89165b03827d9801a260dd54dd03e77f903795b8872bd043a79e8283181c786f",
"ca81edd9f904843fdaaec15c0b774861c055742cdb0a27215ad43b0acfeaa73b"
"ca9bf55c3886cd02fa31feade91fe333377fa8bf7bc9cc2d1bbdfb45214acfa3",
"dbd5afd0aa1c2b449880ad3853e7e988dc29d799d40d2309780cf133a7e3c4a5"
]
},
"EventType.SubtitleTransferComplete": {
@@ -1874,11 +1872,11 @@
]
}
},
"fact_count": 114,
"fact_count": 112,
"invalid_consumer_count": 0,
"invalid_producer_count": 0,
"producer_call_count": 97,
"producer_event_reference_count": 98,
"producer_call_count": 95,
"producer_event_reference_count": 96,
"producers": [
{
"caller": "app.agent.orchestrator",
@@ -1910,10 +1908,10 @@
"events": [
"EventType.ConfigChanged"
],
"fingerprint": "f2ea1cb371877b73077defb4aa5aff0dfebd81c450d02ca52f4d70d95f7b4cc1",
"fingerprint": "5fe23577466054d66cb12b34d4df8834f8933596be0cbc84e4cbfc22694b3c03",
"invalid": false,
"method": "async_send_event",
"qualname": "_publish_rule_config_changed",
"qualname": "publish_rule_config_changed",
"receiver_kind": "canonical_singleton"
},
{
@@ -2048,42 +2046,6 @@
"qualname": "source",
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.api.endpoints.subscribe",
"dynamic": false,
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "4204fef37b0e1b87ff665209b1085359b778096827bed514cf61c6488ede3ee3",
"invalid": false,
"method": "async_send_event",
"qualname": "reset_subscribes",
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.api.endpoints.subscribe",
"dynamic": false,
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "ca81edd9f904843fdaaec15c0b774861c055742cdb0a27215ad43b0acfeaa73b",
"invalid": false,
"method": "async_send_event",
"qualname": "update_subscribe",
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.api.endpoints.subscribe",
"dynamic": false,
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "4fc5c37849498747d5d61944b47381612c88d9b6320fda9ab48e3c1fa2444232",
"invalid": false,
"method": "async_send_event",
"qualname": "update_subscribe_status",
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.api.endpoints.system",
"dynamic": false,
@@ -2834,11 +2796,11 @@
"events": [
"EventType.SubscribeComplete"
],
"fingerprint": "a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c",
"fingerprint": "eba9652f1bf1fab63b1ab459a1636e2167e03142099cfd2dd665dab6c88989f9",
"invalid": false,
"method": "send_event",
"qualname": "_publish_completed",
"receiver_kind": "constructed_manager"
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.startup.composition.subscription",
@@ -2846,11 +2808,11 @@
"events": [
"EventType.SubscribeDeleted"
],
"fingerprint": "49cb2961dd38ea10d2a53c7208fb3ca66ecda4c7c2f8cb2c29ea3ac62629a512",
"fingerprint": "ea7538a3f2ad3ddbf8de6264e0525f065eec338d2ee003a501b725e3194d490a",
"invalid": false,
"method": "async_send_event",
"qualname": "_publish_deleted",
"receiver_kind": "constructed_manager"
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.startup.composition.subscription",
@@ -2858,11 +2820,11 @@
"events": [
"EventType.SubscribeDeleted"
],
"fingerprint": "cbff1d6d00bcade626974178f292471c2bb7643ceb9360a0ad9fa23b87c3597d",
"fingerprint": "a08051c5da0861d7198f3f5aa1b4ce6ed054abfbeab35e06ecb64dea71ba8c5e",
"invalid": false,
"method": "send_event",
"qualname": "_publish_deleted_sync",
"receiver_kind": "constructed_manager"
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.startup.composition.subscription",
@@ -2870,11 +2832,23 @@
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "330678cc36e39079b052e5a6f1493cd38cf40565ddf8b231dc20c94207cb1ba9",
"fingerprint": "dbd5afd0aa1c2b449880ad3853e7e988dc29d799d40d2309780cf133a7e3c4a5",
"invalid": false,
"method": "async_send_event",
"qualname": "_publish_modified",
"receiver_kind": "constructed_manager"
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.startup.composition.subscription",
"dynamic": false,
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "ca9bf55c3886cd02fa31feade91fe333377fa8bf7bc9cc2d1bbdfb45214acfa3",
"invalid": false,
"method": "send_event",
"qualname": "_publish_modified_sync",
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.startup.initializers.modules",
@@ -3046,7 +3020,7 @@
}
],
"static_consumer_count": 16,
"static_producer_call_count": 96
"static_producer_call_count": 94
},
"event_specs": {
"ChainEventType.AgentLLMProvider": {

View File

@@ -1,9 +1,10 @@
"""Agent 数据上下文与类型化任务适配器测试。"""
from dataclasses import FrozenInstanceError
from contextlib import asynccontextmanager
from dataclasses import FrozenInstanceError, replace
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -11,13 +12,17 @@ import app.agent.tools.factory as tool_factory_module
import app.application.agent as agent_services
from app.agent.memory import MemoryManager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.delete_rule_group import DeleteRuleGroupTool
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.agent.tools.impl.update_custom_filter_rule import UpdateCustomFilterRuleTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.application.agent import AgentDataContext
from app.application.agenttask import AgentTaskRepository, AgentTaskSnapshot
from app.db.adapters.agent import TransactionalAgentTaskRepository
from app.db.session import SessionFactory
from app.scheduler import Scheduler
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.startup.initializers import agent as agent_initializer
@@ -31,6 +36,9 @@ def _context(tasks: AgentTaskRepository) -> AgentDataContext:
users=cast(object, dependency),
sites=cast(object, dependency),
subscriptions=cast(object, dependency),
subscription_mutation_scope=cast(object, dependency),
subscription_delete_scope=cast(object, dependency),
async_rule_group_mutation_scope=cast(object, dependency),
subscription_history=cast(object, dependency),
transfer_history=cast(object, dependency),
transfer_execution=cast(object, dependency),
@@ -49,6 +57,99 @@ def test_agent_tool_receives_exact_data_context() -> None:
assert tool.data.tasks is repository
@pytest.mark.asyncio
async def test_delete_rule_group_uses_injected_atomic_mutation_scope(monkeypatch) -> None:
"""Agent 删除规则组必须一次提交定义和全部引用并在释放作用域后广播。"""
mutation = MagicMock()
mutation.apply = AsyncMock(
return_value=SimpleNamespace(to_dict=lambda: {"subscribes": []})
)
@asynccontextmanager
async def mutation_scope():
"""提供可观测的异步规则组事务作用域。"""
yield mutation
context = replace(
_context(TransactionalAgentTaskRepository(SessionFactory)),
async_rule_group_mutation_scope=mutation_scope,
)
monkeypatch.setattr(
"app.agent.tools.impl.delete_rule_group.get_rule_groups",
lambda: [FilterRuleGroup(name="old", rule_string="4K")],
)
publish = AsyncMock()
monkeypatch.setattr(
"app.agent.tools.impl.delete_rule_group.publish_rule_config_changed",
publish,
)
tool = DeleteRuleGroupTool(session_id="session", user_id="user", data=context)
result = await tool.run(name="old")
assert '"success": true' in result
mutation.apply.assert_awaited_once_with(
[],
expected_rule_groups=[{"name": "old", "rule_string": "4K"}],
previous_name="old",
)
publish.assert_awaited_once()
@pytest.mark.asyncio
async def test_custom_rule_rename_commits_rule_and_group_definitions_together(
monkeypatch,
) -> None:
"""自定义规则改名必须用一个事务同时提交规则本体和规则组表达式。"""
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace())
@asynccontextmanager
async def mutation_scope():
"""提供可观测的异步组合事务作用域。"""
yield mutation
context = replace(
_context(TransactionalAgentTaskRepository(SessionFactory)),
async_rule_group_mutation_scope=mutation_scope,
)
monkeypatch.setattr(
"app.agent.tools.impl.update_custom_filter_rule.get_custom_rules",
lambda: [CustomRule(id="OLD", name="旧规则", include="old")],
)
monkeypatch.setattr(
"app.agent.tools.impl.update_custom_filter_rule.get_rule_groups",
lambda: [FilterRuleGroup(name="group", rule_string="OLD & 4K")],
)
publish = AsyncMock()
save = AsyncMock()
monkeypatch.setattr(
"app.agent.tools.impl.update_custom_filter_rule.publish_rule_config_changed",
publish,
)
monkeypatch.setattr(
"app.agent.tools.impl.update_custom_filter_rule.save_system_config",
save,
)
tool = UpdateCustomFilterRuleTool(
session_id="session",
user_id="user",
data=context,
)
result = await tool.run(current_rule_id="OLD", new_rule_id="NEW")
assert '"success": true' in result
mutation.apply.assert_awaited_once_with(
[{"name": "group", "rule_string": "NEW & 4K"}],
expected_rule_groups=[{"name": "group", "rule_string": "OLD & 4K"}],
custom_rules=[{"id": "NEW", "name": "旧规则", "include": "old"}],
expected_custom_rules=[{"id": "OLD", "name": "旧规则", "include": "old"}],
)
save.assert_not_awaited()
assert publish.await_count == 2
def test_agent_service_facade_resolves_registered_dependencies(monkeypatch) -> None:
"""Agent 服务门面应稳定处理未装配状态并转发组合根注入能力。"""
provider_names = (

View File

@@ -3,7 +3,7 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
@@ -19,19 +19,20 @@ def test_agent_delete_subscribe_uses_transactional_delete_command():
subscribe = SimpleNamespace(id=7, name="测试订阅", year="2026")
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=subscribe))
command = SimpleNamespace(execute=AsyncMock(return_value=True))
data = SimpleNamespace(
subscription_mutation_scope=lambda: _scope(mutation),
subscription_delete_scope=lambda: _scope(command),
)
with patch(
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _scope(mutation),
), patch(
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
side_effect=lambda: _scope(command),
):
result = asyncio.run(
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=7
)
result = asyncio.run(
DeleteSubscribeTool(
session_id="session-1",
user_id="10001",
data=data,
).run(
subscribe_id=7
)
)
assert result == "成功删除订阅:测试订阅 (2026)"
mutation.get_accessible.assert_awaited_once()
@@ -44,20 +45,21 @@ def test_agent_delete_subscribe_uses_transactional_delete_command():
def test_agent_delete_subscribe_skips_command_when_record_is_missing():
"""预读未命中时保持原有不存在提示,且不创建删除副作用。"""
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=None))
delete_scope = AsyncMock()
delete_scope = MagicMock()
data = SimpleNamespace(
subscription_mutation_scope=lambda: _scope(mutation),
subscription_delete_scope=delete_scope,
)
with patch(
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _scope(mutation),
), patch(
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
delete_scope,
):
result = asyncio.run(
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=404
)
result = asyncio.run(
DeleteSubscribeTool(
session_id="session-1",
user_id="10001",
data=data,
).run(
subscribe_id=404
)
)
assert result == "订阅 ID 404 不存在"
delete_scope.assert_not_called()

View File

@@ -2,11 +2,17 @@
import asyncio
import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, call
from unittest.mock import AsyncMock, MagicMock, call
from app.agent.tools.impl.search_subscribe import SearchSubscribeTool
from app.application.subscription.contract import SubscriptionPatch
@asynccontextmanager
async def _scope(value):
"""把测试替身包装成工具使用的异步作用域。"""
yield value
def _subscribe(*, state: str = "R") -> SimpleNamespace:
@@ -38,7 +44,6 @@ def test_search_subscribe_uses_async_data_port(monkeypatch) -> None:
class _AsyncSubscribePort:
def __init__(self) -> None:
self.async_get = AsyncMock(side_effect=[record, updated])
self.async_update = AsyncMock()
def get(self, _subscribe_id):
raise AssertionError("async 工具不应调用同步订阅查询")
@@ -47,6 +52,7 @@ def test_search_subscribe_uses_async_data_port(monkeypatch) -> None:
raise AssertionError("async 工具不应调用同步订阅更新")
port = _AsyncSubscribePort()
mutation = SimpleNamespace(update=AsyncMock())
async def _run_blocking(*_args, **_kwargs):
await asyncio.sleep(0)
@@ -57,7 +63,10 @@ def test_search_subscribe_uses_async_data_port(monkeypatch) -> None:
SearchSubscribeTool(
session_id="test",
user_id="1",
data=SimpleNamespace(subscriptions=port),
data=SimpleNamespace(
subscriptions=port,
subscription_mutation_scope=lambda: _scope(mutation),
),
).run(
subscribe_id=record.id,
filter_groups=["default"],
@@ -67,10 +76,14 @@ def test_search_subscribe_uses_async_data_port(monkeypatch) -> None:
payload = json.loads(result)
assert payload["success"] is True
assert port.async_get.await_args_list == [call(record.id), call(record.id)]
port.async_update.assert_awaited_once_with(
record.id,
SubscriptionPatch({"filter_groups": ["default"]}),
)
mutation.update.assert_awaited_once()
mutation_args = mutation.update.await_args.args
assert mutation_args[:2] == (record.id, {"filter_groups": ["default"]})
assert mutation_args[2].is_superuser is True
assert mutation.update.await_args.kwargs == {
"existing": record,
"scene": "agent_search",
}
def test_search_subscribe_rejects_paused_subscription_without_search(monkeypatch) -> None:
@@ -79,7 +92,6 @@ def test_search_subscribe_rejects_paused_subscription_without_search(monkeypatch
class _AsyncSubscribePort:
async_get = AsyncMock(return_value=record)
async_update = AsyncMock()
port = _AsyncSubscribePort()
run_blocking = AsyncMock()
@@ -89,7 +101,10 @@ def test_search_subscribe_rejects_paused_subscription_without_search(monkeypatch
SearchSubscribeTool(
session_id="test",
user_id="1",
data=SimpleNamespace(subscriptions=port),
data=SimpleNamespace(
subscriptions=port,
subscription_mutation_scope=MagicMock(),
),
).run(
subscribe_id=record.id,
)

View File

@@ -1,7 +1,7 @@
import asyncio
import json
from contextlib import asynccontextmanager
from unittest.mock import patch
from types import SimpleNamespace
from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
from app.application.subscription.mutation import SubscriptionMutation
@@ -16,17 +16,17 @@ def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=9,
name="新标题",
state="S",
)
result = asyncio.run(
UpdateSubscribeTool(
session_id="session-1",
user_id="10001",
data=_data(mutation),
).run(
subscribe_id=9,
name="新标题",
state="S",
)
)
payload = json.loads(result)
assert payload["success"] is True
@@ -48,16 +48,16 @@ def test_agent_update_subscribe_ignores_unchanged_total_episode():
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=160,
total_episode=175,
)
result = asyncio.run(
UpdateSubscribeTool(
session_id="session-1",
user_id="10001",
data=_data(mutation),
).run(
subscribe_id=160,
total_episode=175,
)
)
payload = json.loads(result)
assert payload == {"success": False, "message": "没有提供要更新的字段"}
@@ -80,17 +80,17 @@ def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_e
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=160,
total_episode=175,
best_version=1,
)
result = asyncio.run(
UpdateSubscribeTool(
session_id="session-1",
user_id="10001",
data=_data(mutation),
).run(
subscribe_id=160,
total_episode=175,
best_version=1,
)
)
payload = json.loads(result)
assert payload["success"] is True
@@ -114,16 +114,16 @@ def test_agent_update_subscribe_marks_changed_total_episode_as_manual():
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=160,
total_episode=190,
)
result = asyncio.run(
UpdateSubscribeTool(
session_id="session-1",
user_id="10001",
data=_data(mutation),
).run(
subscribe_id=160,
total_episode=190,
)
)
payload = json.loads(result)
assert payload["success"] is True
@@ -191,6 +191,7 @@ class _MutationServiceStub:
updated = await self.oper.async_update(subscribe_id, payload)
self.calls.append((subscribe_id, dict(payload), scene))
return SubscriptionMutation(
snapshot=updated,
old=old,
new=updated.to_dict(),
event_published=True,
@@ -201,3 +202,10 @@ class _MutationServiceStub:
async def _mutation_scope(service):
"""把测试修改服务包装成 Agent 使用的异步事务作用域。"""
yield service
def _data(mutation):
"""构造仅暴露订阅修改作用域的工具数据上下文。"""
return SimpleNamespace(
subscription_mutation_scope=lambda: _mutation_scope(mutation),
)

View File

@@ -435,17 +435,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"] == 97
assert events["static_producer_call_count"] == 96
assert events["producer_call_count"] == 95
assert events["static_producer_call_count"] == 94
assert events["dynamic_producer_count"] == 1
assert events["invalid_producer_count"] == 0
assert events["producer_event_reference_count"] == 98
assert events["producer_event_reference_count"] == 96
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"] == 114
assert events["fact_count"] == 112
assert len({fact["fingerprint"] for fact in events["consumers"]}) == 17
assert all(
not fact["caller"].startswith("app.plugins")

View File

@@ -450,6 +450,11 @@ def test_startup_composes_typed_chain_and_agent_data_contexts():
{
"site_repository",
"subscription_repository",
"subscription_mutation_scope",
"sync_subscription_mutation_scope",
"subscription_delete_scope",
"sync_subscription_delete_scope",
"subscription_completion_scope",
"download_history_repository",
"transfer_history_repository",
"transfer_admission_repository",
@@ -468,6 +473,8 @@ def test_startup_composes_typed_chain_and_agent_data_contexts():
"users",
"sites",
"subscriptions",
"subscription_mutation_scope",
"subscription_delete_scope",
"subscription_history",
"transfer_history",
"transfer_execution",

View File

@@ -74,6 +74,13 @@ class ChainRateLimitTest(unittest.TestCase):
module_dispatcher_factory=ModuleInvocationDispatcher,
site_repository=Mock(),
subscription_repository=Mock(),
subscription_mutation_scope=Mock(),
sync_subscription_mutation_scope=Mock(),
subscription_delete_scope=Mock(),
sync_subscription_delete_scope=Mock(),
subscription_completion_scope=Mock(),
rule_group_mutation_scope=Mock(),
site_reference_mutation_scope=Mock(),
download_history_repository=Mock(),
transfer_history_repository=Mock(),
transfer_admission_repository=Mock(),

View File

@@ -25,6 +25,13 @@ def _context() -> ChainRuntimeContext:
module_dispatcher_factory=ModuleInvocationDispatcher,
site_repository=Mock(),
subscription_repository=Mock(),
subscription_mutation_scope=Mock(),
sync_subscription_mutation_scope=Mock(),
subscription_delete_scope=Mock(),
sync_subscription_delete_scope=Mock(),
subscription_completion_scope=Mock(),
rule_group_mutation_scope=Mock(),
site_reference_mutation_scope=Mock(),
download_history_repository=Mock(),
transfer_history_repository=Mock(),
transfer_admission_repository=Mock(),
@@ -84,5 +91,10 @@ def test_chain_keeps_explicit_typed_repositories() -> None:
assert chain.site_repository is context.site_repository
assert chain.subscription_repository is context.subscription_repository
assert chain.subscription_mutation_scope is context.subscription_mutation_scope
assert chain.sync_subscription_mutation_scope is context.sync_subscription_mutation_scope
assert chain.subscription_delete_scope is context.subscription_delete_scope
assert chain.sync_subscription_delete_scope is context.sync_subscription_delete_scope
assert chain.subscription_completion_scope is context.subscription_completion_scope
assert chain.transfer_execution_repository is context.transfer_execution_repository
assert chain.user_repository is context.user_repository

View File

@@ -10,6 +10,12 @@ RETIRED_MODULES = {
"app.application.chain.data",
}
RETIRED_GETTERS = {
"_get_subscribe_writer",
"configure_delete_subscribe_scope",
"configure_subscribe_writer",
"configure_subscription_completion_scope",
"configure_subscription_mutation_scope",
"configure_sync_delete_subscribe_scope",
"get_agent_chat_port",
"get_agent_download_history_port",
"get_agent_plugin_data_port",
@@ -28,6 +34,10 @@ RETIRED_GETTERS = {
"get_chain_transfer_history_port",
"get_chain_transfer_pending_port",
"get_chain_user_port",
"get_delete_subscribe_scope",
"get_subscription_completion_scope",
"get_subscription_mutation_scope",
"get_sync_delete_subscribe_scope",
}
@@ -116,6 +126,11 @@ def test_injected_data_contexts_use_owned_typed_ports() -> None:
expected_chain_fields = {
"site_repository",
"subscription_repository",
"subscription_mutation_scope",
"sync_subscription_mutation_scope",
"subscription_delete_scope",
"sync_subscription_delete_scope",
"subscription_completion_scope",
"download_history_repository",
"transfer_history_repository",
"transfer_admission_repository",
@@ -131,6 +146,8 @@ def test_injected_data_contexts_use_owned_typed_ports() -> None:
"users",
"sites",
"subscriptions",
"subscription_mutation_scope",
"subscription_delete_scope",
"subscription_history",
"transfer_history",
"transfer_execution",

View File

@@ -141,6 +141,9 @@ def _runtime() -> HostRuntime:
users=SimpleNamespace(),
sites=SimpleNamespace(),
subscriptions=SimpleNamespace(),
subscription_mutation_scope=SimpleNamespace(),
subscription_delete_scope=SimpleNamespace(),
async_rule_group_mutation_scope=SimpleNamespace(),
subscription_history=SimpleNamespace(),
transfer_history=SimpleNamespace(),
transfer_execution=SimpleNamespace(),
@@ -184,6 +187,10 @@ def _runtime() -> HostRuntime:
transaction=_UnitOfWork,
outbox=_Outbox,
dispatch_store=_DispatchStore(),
batch_writer=SimpleNamespace(),
rule_group_mutation_scope=SimpleNamespace(),
async_rule_group_mutation_scope=SimpleNamespace(),
site_reference_mutation_scope=SimpleNamespace(),
),
workflow=WorkflowRuntime(
query=SimpleNamespace(),

View File

@@ -1,9 +1,16 @@
from contextlib import contextmanager
from dataclasses import fields
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from app.application.subscription.contract import SubscriptionIdentity, SubscriptionPatch
from app.application.subscription.contract import (
SubscriptionIdentity,
SubscriptionPatch,
SubscriptionSnapshot,
)
from app.application.subscription.mutation import SubscriptionMutation
from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.domain.context import (
MUSIC_ENTITY_ALBUM,
@@ -81,7 +88,35 @@ def _subscribe(**overrides) -> SimpleNamespace:
backdrop=None,
)
values.update(overrides)
return SimpleNamespace(**values)
subscribe = SimpleNamespace(**values)
subscribe.to_dict = lambda: dict(values)
return subscribe
def _configure_subscription_write(chain, repository) -> None:
"""为音乐测试链注入显式同步修改作用域。"""
chain.subscription_repository = repository
@contextmanager
def mutation_scope():
"""把同步修改命令委托给当前测试 repository。"""
def update(subscribe_id, payload, _actor, existing=None, scene="update"):
"""按生产命令返回结构记录测试写入。"""
repository.update(subscribe_id, SubscriptionPatch(payload))
old = {
field.name: getattr(existing, field.name, None)
for field in fields(SubscriptionSnapshot)
}
new = {**old, **payload}
return SubscriptionMutation(
snapshot=SubscriptionSnapshot(**new),
old=old,
new=new,
)
yield SimpleNamespace(update=update)
chain.sync_subscription_mutation_scope = mutation_scope
def test_build_subscribe_meta_returns_music_meta():
@@ -241,7 +276,7 @@ def test_music_best_version_persists_downloaded_rule_priority():
subscribe_oper.update.return_value = updated
chain = SubscribeChain()
chain.finish_subscribe_or_not = Mock()
chain.subscription_repository = subscribe_oper
_configure_subscription_write(chain, subscribe_oper)
with patch("app.chain._music.DownloadChain", return_value=download_chain):
chain._download_music_subscribe(subscribe, _music_info(), [downloaded])
@@ -258,7 +293,8 @@ def test_music_best_version_persists_downloaded_rule_priority():
)
assert subscribe.current_priority == 90
chain.finish_subscribe_or_not.assert_called_once()
assert chain.finish_subscribe_or_not.call_args.kwargs["subscribe"] is updated
persisted = chain.finish_subscribe_or_not.call_args.kwargs["subscribe"]
assert persisted.current_priority == updated.current_priority
chain.finish_subscribe_or_not.assert_called_once()
@@ -346,7 +382,7 @@ def test_album_best_version_requires_confirmed_full_coverage():
subscribe_oper = Mock()
subscribe_oper.get.return_value = subscribe
chain = SubscribeChain()
chain.subscription_repository = subscribe_oper
_configure_subscription_write(chain, subscribe_oper)
with patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \
patch.object(chain, "_SubscribeChain__finish_subscribe") as finish:
@@ -645,14 +681,14 @@ def test_recording_target_sync_clears_stale_album_track_count():
subscribe_oper.update.return_value = updated
chain = SubscribeChain()
chain.subscription_repository = subscribe_oper
_configure_subscription_write(chain, subscribe_oper)
result = chain._sync_music_subscribe_target(subscribe, _music_info())
subscribe_oper.update.assert_called_once_with(
subscribe.id,
SubscriptionPatch({"total_tracks": None}),
)
assert result is updated
assert result.total_tracks == updated.total_tracks
assert subscribe.total_tracks == 11

View File

@@ -2,6 +2,7 @@
import asyncio
import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -13,7 +14,6 @@ from app.application.plugin import runtime as plugin_runtime
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.schemas.types import SystemConfigKey
PLUGIN_RUNTIME_KEYS = (
SystemConfigKey.UserInstalledPlugins,
SystemConfigKey.PluginInstances,
@@ -233,7 +233,22 @@ async def test_rule_group_setting_reconciles_stale_references_when_unchanged(
) -> None:
"""重复保存规则组也应按有效名称对账,修复旧版本遗留的悬空订阅引用。"""
config = MagicMock()
config.async_set = AsyncMock(return_value=None)
definitions = [{"name": "keep", "rule_string": "4K"}]
config.get.return_value = definitions
config.async_set = AsyncMock()
mutation = MagicMock()
mutation.apply = AsyncMock()
@asynccontextmanager
async def mutation_scope():
"""提供可观测的异步规则组事务作用域。"""
yield mutation
runtime = SimpleNamespace(
subscription=SimpleNamespace(
async_rule_group_mutation_scope=mutation_scope,
)
)
monkeypatch.setattr(
system_endpoint,
"get_runtime_settings",
@@ -248,11 +263,17 @@ async def test_rule_group_setting_reconciles_stale_references_when_unchanged(
response = await system_endpoint.set_setting(
SystemConfigKey.UserFilterRuleGroups.value,
[{"name": "keep", "rule_string": "4K"}],
definitions,
None,
runtime=runtime,
)
assert response.success is True
mutation.apply.assert_awaited_once_with(
definitions,
expected_rule_groups=definitions,
)
config.async_set.assert_not_awaited()
system_endpoint.eventmanager.async_send_event.assert_awaited_once()

View File

@@ -0,0 +1,382 @@
"""SystemConfig 与 Subscription 跨表引用修改的真实事务测试。"""
import asyncio
from collections.abc import Mapping
import pytest
from app.application.rules import (
AsyncRuleGroupMutationService,
RuleGroupMutationConflictError,
SyncRuleGroupMutationService,
)
from app.application.site.mutation import SyncSiteReferenceMutationService
from app.db.adapters.configuration import SessionSystemConfigurationRepository
from app.db.adapters.subscription import SessionSubscriptionRepository
from app.db.models.subscribe import Subscribe
from app.db.models.systemconfig import SystemConfig
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.schemas.common import JsonData
from app.schemas.types import MediaType, SystemConfigKey
from app.startup.composition import subscription as subscription_composition
_INITIAL_RULE_GROUPS = [
{"name": "keep", "rule_string": "4K"},
{"name": "old", "rule_string": "1080P"},
]
_INITIAL_CUSTOM_RULES = [{"id": "OLD", "name": "旧规则", "include": "old"}]
def _seed_references(db) -> int:
"""写入一组规则、RSS 和订阅引用,并刷新进程配置快照。"""
db.watermark(SystemConfig, Subscribe)
rows = [
SystemConfig(
key=SystemConfigKey.UserFilterRuleGroups.value,
value=_INITIAL_RULE_GROUPS,
),
SystemConfig(
key=SystemConfigKey.SearchFilterRuleGroups.value,
value=["old", "keep", "dangling"],
),
SystemConfig(
key=SystemConfigKey.SubscribeFilterRuleGroups.value,
value=["old"],
),
SystemConfig(
key=SystemConfigKey.BestVersionFilterRuleGroups.value,
value=["keep"],
),
SystemConfig(
key=SystemConfigKey.DefaultMovieSubscribeConfig.value,
value={"quality": "WEB-DL", "filter_groups": ["old", "keep"]},
),
SystemConfig(
key=SystemConfigKey.DefaultTvSubscribeConfig.value,
value={"filter_groups": ["dangling"]},
),
SystemConfig(
key=SystemConfigKey.RssSites.value,
value=[1, 2, 3],
),
]
subscription = Subscribe(
name="跨表引用订阅",
type=MediaType.TV.value,
media_source="themoviedb",
media_id="reference-mutation",
filter_groups=["old", "keep", "dangling"],
sites=[1, 2],
)
db.add(*rows, subscription)
SystemConfigOper().load_snapshot(db.session)
return subscription.id
def _config_value(db, key: SystemConfigKey):
"""从真实数据库读取单项 SystemConfig 值。"""
db.session.expire_all()
return db.session.query(SystemConfig).filter_by(key=key.value).one().value
def test_rule_group_prune_commits_all_references_and_publishes_after_commit(db) -> None:
"""通用 definitions 写入口应原子清悬空引用且不创建空默认配置。"""
subscribe_id = _seed_references(db)
published: list[Mapping[SystemConfigKey, JsonData]] = []
with SessionFactory() as session:
service = SyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=published.append,
)
result = service.apply(
[{"name": "keep", "rule_string": "4K"}],
expected_rule_groups=_INITIAL_RULE_GROUPS,
)
assert _config_value(db, SystemConfigKey.SearchFilterRuleGroups) == ["keep"]
assert _config_value(db, SystemConfigKey.SubscribeFilterRuleGroups) == []
assert _config_value(db, SystemConfigKey.DefaultMovieSubscribeConfig) == {
"quality": "WEB-DL",
"filter_groups": ["keep"],
}
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).filter_groups == ["keep"]
assert published and SystemConfigKey.UserFilterRuleGroups in published[0]
assert result.subscriptions[0].subscribe_id == subscribe_id
assert db.session.query(SystemConfig).filter_by(
key=SystemConfigKey.DefaultMusicSubscribeConfig.value
).one_or_none() is None
@pytest.mark.parametrize("failure", ["configuration", "subscription"])
def test_rule_group_stage_failure_rolls_back_every_table(db, failure: str) -> None:
"""配置或订阅任一暂存失败时,定义、引用和订阅行必须完整回滚。"""
subscribe_id = _seed_references(db)
published = []
with SessionFactory() as session:
configuration = SessionSystemConfigurationRepository(session)
subscriptions = SessionSubscriptionRepository(session)
if failure == "configuration":
original_stage_set = configuration.stage_set
def fail_configuration(key, value) -> None:
"""先暂存部分配置,再模拟配置适配器失败。"""
original_stage_set(key, value)
if key == SystemConfigKey.SubscribeFilterRuleGroups:
raise RuntimeError("configuration stage failed")
configuration.stage_set = fail_configuration # type: ignore[method-assign]
else:
original_stage_update = subscriptions.stage_update
def fail_subscription(subscribe_id, patch):
"""先暂存订阅更新,再模拟订阅适配器失败。"""
original_stage_update(subscribe_id, patch)
raise RuntimeError("subscription stage failed")
subscriptions.stage_update = fail_subscription # type: ignore[method-assign]
service = SyncRuleGroupMutationService(
configuration=configuration,
subscriptions=subscriptions,
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=published.append,
)
with pytest.raises(RuntimeError, match=f"{failure} stage failed"):
service.apply(
[{"name": "keep", "rule_string": "4K"}],
expected_rule_groups=_INITIAL_RULE_GROUPS,
)
assert _config_value(db, SystemConfigKey.UserFilterRuleGroups)[1]["name"] == "old"
assert _config_value(db, SystemConfigKey.SearchFilterRuleGroups) == [
"old",
"keep",
"dangling",
]
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).filter_groups == [
"old",
"keep",
"dangling",
]
assert published == []
def test_combined_custom_rule_stage_failure_rolls_back_both_definitions(db) -> None:
"""自定义规则与规则组组合写任一暂存失败时不得留下半更新。"""
subscribe_id = _seed_references(db)
db.add(
SystemConfig(
key=SystemConfigKey.CustomFilterRules.value,
value=_INITIAL_CUSTOM_RULES,
)
)
published = []
with SessionFactory() as session:
configuration = SessionSystemConfigurationRepository(session)
original_stage_set = configuration.stage_set
def fail_custom_rule_stage(key, value) -> None:
"""先暂存两份定义,再在第二份配置上模拟数据库失败。"""
original_stage_set(key, value)
if key == SystemConfigKey.CustomFilterRules:
raise RuntimeError("custom rule stage failed")
configuration.stage_set = fail_custom_rule_stage # type: ignore[method-assign]
service = SyncRuleGroupMutationService(
configuration=configuration,
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=published.append,
)
with pytest.raises(RuntimeError, match="custom rule stage failed"):
service.apply(
[
{"name": "keep", "rule_string": "4K"},
{"name": "old", "rule_string": "NEW"},
],
expected_rule_groups=_INITIAL_RULE_GROUPS,
custom_rules=[
{"id": "NEW", "name": "旧规则", "include": "old"}
],
expected_custom_rules=_INITIAL_CUSTOM_RULES,
)
assert _config_value(db, SystemConfigKey.UserFilterRuleGroups) == _INITIAL_RULE_GROUPS
assert _config_value(db, SystemConfigKey.CustomFilterRules) == _INITIAL_CUSTOM_RULES
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).filter_groups == [
"old",
"keep",
"dangling",
]
assert published == []
def test_site_reference_mutation_commits_and_rolls_back_atomically(db) -> None:
"""RssSites 与 Subscription.sites 使用同一 UoW失败不留下半更新。"""
subscribe_id = _seed_references(db)
with SessionFactory() as session:
configuration = SessionSystemConfigurationRepository(session)
subscriptions = SessionSubscriptionRepository(session)
original_stage_update = subscriptions.stage_update
def fail_subscription(target_id, patch):
"""暂存订阅站点清理后注入失败。"""
original_stage_update(target_id, patch)
raise RuntimeError("site subscription stage failed")
subscriptions.stage_update = fail_subscription # type: ignore[method-assign]
service = SyncSiteReferenceMutationService(
configuration=configuration,
subscriptions=subscriptions,
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=lambda _values: None,
)
with pytest.raises(RuntimeError, match="site subscription stage failed"):
service.apply(1)
assert _config_value(db, SystemConfigKey.RssSites) == [1, 2, 3]
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).sites == [1, 2]
with SessionFactory() as session:
service = SyncSiteReferenceMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=SystemConfigOper().publish_many,
)
result = service.apply(1)
assert _config_value(db, SystemConfigKey.RssSites) == [2, 3]
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).sites == [2]
assert result.subscription_ids == (subscribe_id,)
assert SystemConfigOper().get(SystemConfigKey.RssSites) == [2, 3]
with SessionFactory() as session:
service = SyncSiteReferenceMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=SystemConfigOper().publish_many,
)
reset_result = service.apply("*")
assert _config_value(db, SystemConfigKey.RssSites) == []
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).sites == []
assert reset_result.rss_sites == ()
assert reset_result.subscription_ids == (subscribe_id,)
assert SystemConfigOper().get(SystemConfigKey.RssSites) == []
def test_async_rule_group_path_commits_real_database_transaction(db) -> None:
"""Agent 使用的异步服务与同步服务共享相同的原子引用语义。"""
subscribe_id = _seed_references(db)
async def execute(session) -> None:
"""在真实 AsyncSession 中执行一次改名。"""
async def publish(values) -> None:
"""模拟提交后发布配置快照。"""
SystemConfigOper().publish_many(values)
service = AsyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish=publish,
)
await service.apply(
[
{"name": "keep", "rule_string": "4K"},
{"name": "new", "rule_string": "1080P"},
],
expected_rule_groups=_INITIAL_RULE_GROUPS,
previous_name="old",
current_name="new",
)
db.run_async_session(execute)
assert _config_value(db, SystemConfigKey.SearchFilterRuleGroups) == [
"new",
"keep",
]
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).filter_groups == ["new", "keep"]
def test_async_scope_serializes_writes_without_blocking_event_loop(db, monkeypatch) -> None:
"""并发规则修改应串行收口,等待共享锁时事件循环仍能推进。"""
subscribe_id = _seed_references(db)
original_commit = subscription_composition.SqlAlchemyAsyncUnitOfWork.commit
async def delayed_commit(unit_of_work) -> None:
"""延长持锁事务,给并发等待和事件循环心跳留下观察窗口。"""
await asyncio.sleep(0.04)
await original_commit(unit_of_work)
monkeypatch.setattr(
subscription_composition.SqlAlchemyAsyncUnitOfWork,
"commit",
delayed_commit,
)
async def execute() -> int:
"""并发执行两个幂等 prune并统计等待期间的 loop 心跳。"""
running = True
ticks = 0
async def mutate(target_name: str) -> object:
"""通过生产异步 scope 提交基于同一旧快照的不同改名意图。"""
async with subscription_composition.async_rule_group_mutation_scope(
SystemConfigOper().publish_many
) as service:
try:
return await service.apply(
[
{"name": "keep", "rule_string": "4K"},
{"name": target_name, "rule_string": "1080P"},
],
expected_rule_groups=_INITIAL_RULE_GROUPS,
previous_name="old",
current_name=target_name,
)
except RuleGroupMutationConflictError as error:
return error
async def heartbeat() -> None:
"""证明线程锁等待没有阻塞事件循环线程。"""
nonlocal ticks
while running:
ticks += 1
await asyncio.sleep(0.005)
heartbeat_task = asyncio.create_task(heartbeat())
results = await asyncio.gather(mutate("first"), mutate("second"))
running = False
await heartbeat_task
assert sum(isinstance(result, RuleGroupMutationConflictError) for result in results) == 1
return ticks
ticks = asyncio.run(execute())
assert ticks >= 5
final_groups = _config_value(db, SystemConfigKey.UserFilterRuleGroups)
final_name = final_groups[1]["name"]
assert final_name in {"first", "second"}
assert _config_value(db, SystemConfigKey.SearchFilterRuleGroups) == [
final_name,
"keep",
]
db.session.expire_all()
assert db.session.get(Subscribe, subscribe_id).filter_groups == [
final_name,
"keep",
]

View File

@@ -1,9 +1,8 @@
from types import SimpleNamespace
from contextlib import contextmanager
from unittest.mock import MagicMock
from app.agent.tools.impl._filter_rule_utils import normalize_media_type
from app.application.rules import RuleHelper
from app.application.subscription.contract import SubscriptionPatch
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
from app.modules.filter import FilterModule
@@ -76,47 +75,16 @@ def test_rule_group_category_cannot_cross_media_types(monkeypatch):
def test_reconcile_rule_group_references_removes_all_dangling_bindings(monkeypatch):
"""规则组设置保存后应清理全局默认项、订阅默认值和已有订阅中的悬空名称"""
values = {
SystemConfigKey.SearchFilterRuleGroups: ["keep", "deleted"],
SystemConfigKey.SubscribeFilterRuleGroups: ["deleted"],
SystemConfigKey.BestVersionFilterRuleGroups: ["keep"],
SystemConfigKey.DefaultMovieSubscribeConfig: {
"quality": "WEB-DL",
"filter_groups": ["deleted", "keep"],
},
SystemConfigKey.DefaultTvSubscribeConfig: {"filter_groups": ["deleted"]},
SystemConfigKey.DefaultMusicSubscribeConfig: {},
}
"""规则组事件必须委托单一原子服务处理全部引用"""
mutation = MagicMock()
class Config:
"""记录规则引用对账产生的系统配置更新。"""
@contextmanager
def mutation_scope():
"""提供可观测的同步规则组事务作用域。"""
yield mutation
def get(self, key):
"""读取当前测试配置。"""
return values.get(key)
def set(self, key, value):
"""保存配置并更新测试快照。"""
values[key] = value
return True
subscribes = [
SimpleNamespace(
id=1,
name="Example",
season=1,
filter_groups=["deleted", "keep"],
)
]
updates = []
subscribe_port = SimpleNamespace(
list=lambda: subscribes,
update=lambda subscribe_id, payload: updates.append((subscribe_id, payload)),
)
monkeypatch.setattr(subscribe_module, "_system_config", lambda: Config())
chain = object.__new__(SubscribeChain)
chain.subscription_repository = subscribe_port
chain.rule_group_mutation_scope = mutation_scope
SubscribeChain.reconcile_rule_group_references(
chain,
@@ -129,15 +97,28 @@ def test_reconcile_rule_group_references_removes_all_dangling_bindings(monkeypat
),
)
assert values[SystemConfigKey.SearchFilterRuleGroups] == ["keep"]
assert values[SystemConfigKey.SubscribeFilterRuleGroups] == []
assert values[SystemConfigKey.BestVersionFilterRuleGroups] == ["keep"]
assert values[SystemConfigKey.DefaultMovieSubscribeConfig] == {
"quality": "WEB-DL",
"filter_groups": ["keep"],
}
assert values[SystemConfigKey.DefaultTvSubscribeConfig] == {
"filter_groups": [],
}
assert updates == [(1, SubscriptionPatch({"filter_groups": ["keep"]}))]
assert subscribes[0].filter_groups == ["deleted", "keep"]
definitions = [{"name": "keep", "rule_string": "4K"}]
mutation.apply.assert_called_once_with(
definitions,
expected_rule_groups=definitions,
)
def test_site_deleted_delegates_reference_cleanup_to_atomic_scope() -> None:
"""站点删除事件不得分别修改 SystemConfig 和订阅仓储。"""
mutation = MagicMock()
@contextmanager
def mutation_scope():
"""提供可观测的同步站点引用事务作用域。"""
yield mutation
chain = object.__new__(SubscribeChain)
chain.site_reference_mutation_scope = mutation_scope
SubscribeChain.remove_site(
chain,
Event(EventType.SiteDeleted, {"site_id": 3}),
)
mutation.apply.assert_called_once_with(3)

View File

@@ -44,19 +44,20 @@ def _series(tmdb_id=None, seasons=None):
)
def _run_add(tv, subscriptions):
def _run_add(tv, subscriptions, batch_writer):
"""直接调用新增剧集订阅处理函数。"""
return asyncio.run(
arr_add_series(
tv=tv,
_="api-token",
subscriptions=subscriptions,
batch_writer=batch_writer,
)
)
def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")):
"""统一 patch 媒体链、订阅链与订阅查询。"""
"""统一 patch 媒体链、批量订阅链与订阅查询。"""
media_chain = MagicMock()
media_chain.tvdb_info.return_value = {
"name": "Tales of Herding Gods",
@@ -65,32 +66,34 @@ def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")):
}
media_chain.recognize_by_meta.return_value = mediainfo
subscribe_chain = MagicMock()
subscribe_chain.async_add = AsyncMock(return_value=add_result)
subscribe_chain.async_add_batch = AsyncMock(return_value=add_result)
subscriptions = MagicMock()
subscriptions.exists = AsyncMock(return_value=bool(exists))
batch_writer = MagicMock()
return patch(
"app.api.servarr.MediaChain",
return_value=media_chain,
), patch(
"app.api.servarr.SubscribeChain",
return_value=subscribe_chain,
), subscriptions, subscribe_chain
), subscriptions, subscribe_chain, batch_writer
def test_add_series_without_tmdbid_resolves_identity_via_tvdbid():
"""Seerr 请求体不携带 tmdbId 时,应按 tvdbId 补全媒体身份并创建订阅。"""
tv = _series(seasons=[SonarrSeason(seasonNumber=1, monitored=True)])
media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains(
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
mediainfo=_fake_mediainfo()
)
with media_patch, chain_patch:
result = _run_add(tv, subscriptions)
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 123
subscribe_chain.async_add.assert_awaited_once_with(
subscribe_chain.async_add_batch.assert_awaited_once_with(
title="Tales of Herding Gods",
year=2024,
season=1,
seasons=[1],
batch_writer=batch_writer,
media_source=MediaSource.TMDB,
media_id=str(_TMDB_ID),
mtype=MediaType.TV,
@@ -101,17 +104,16 @@ def test_add_series_without_tmdbid_resolves_identity_via_tvdbid():
def test_add_series_with_empty_seasons_falls_back_to_all_seasons():
"""请求体季列表为空时不应静默成功,应兜底订阅已识别的全部季。"""
tv = _series()
media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains(
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
mediainfo=_fake_mediainfo(seasons={1: [1, 2, 3], 2: [1]})
)
subscribe_chain.async_add = AsyncMock(side_effect=[(100, ""), (101, "")])
subscribe_chain.async_add_batch.return_value = (101, "")
with media_patch, chain_patch:
result = _run_add(tv, subscriptions)
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 101
assert subscribe_chain.async_add.await_count == 2
assert subscribe_chain.async_add.await_args_list[0].kwargs["season"] == 1
assert subscribe_chain.async_add.await_args_list[1].kwargs["season"] == 2
subscribe_chain.async_add_batch.assert_awaited_once()
assert subscribe_chain.async_add_batch.await_args.kwargs["seasons"] == [1, 2]
def test_add_series_already_subscribed_returns_existing():
@@ -120,23 +122,25 @@ def test_add_series_already_subscribed_returns_existing():
tmdb_id=_TMDB_ID,
seasons=[SonarrSeason(seasonNumber=1, monitored=True)],
)
media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains(
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
exists=SimpleNamespace(id=9)
)
with media_patch, chain_patch:
result = _run_add(tv, subscriptions)
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 1
subscribe_chain.async_add.assert_not_awaited()
subscribe_chain.async_add_batch.assert_not_awaited()
def test_add_series_identity_resolution_failure_returns_500():
"""媒体身份补全失败时返回 500避免 Seerr 误判请求已成功。"""
tv = _series()
media_patch, chain_patch, subscriptions, _ = _patch_chains(mediainfo=None)
media_patch, chain_patch, subscriptions, _, batch_writer = _patch_chains(
mediainfo=None
)
with media_patch, chain_patch:
with pytest.raises(HTTPException) as excinfo:
_run_add(tv, subscriptions)
_run_add(tv, subscriptions, batch_writer)
assert excinfo.value.status_code == 500

View File

@@ -2,16 +2,22 @@ import asyncio
import importlib.util
import sys
import types
from contextlib import contextmanager
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app import schemas
from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionWriteResult,
)
from app.application.subscription.mutation import SubscriptionMutation
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
from app.testing import stub_modules
@@ -40,8 +46,36 @@ def _load_subscribe_chain_class():
subscription_repository = SimpleNamespace()
def __init__(self):
"""装配隔离链依赖和显式同步订阅修改作用域。"""
self.messagehelper = SimpleNamespace(put=lambda *args, **kwargs: None)
self.subscription_repository = type(self).subscription_repository
self.sync_subscription_mutation_scope = self._subscription_mutation_scope
@contextmanager
def _subscription_mutation_scope(self):
"""提供委托当前测试 repository 的同步修改命令。"""
yield SimpleNamespace(update=self._update_subscription)
def _update_subscription(
self,
subscribe_id,
payload,
_actor,
existing=None,
scene="update",
):
"""记录测试写入并返回与生产命令一致的前后快照。"""
updated = self.subscription_repository.update(
subscribe_id,
SubscriptionPatch(payload),
)
old = existing.to_dict() if existing else {}
new = {**old, **payload} if updated else {}
return SubscriptionMutation(
snapshot=replace(existing, **payload),
old=old,
new=new,
)
def post_message(self, *args, **kwargs):
return None
@@ -3913,3 +3947,99 @@ class TestSubscribeDownloadFacts:
assert updates[-1]["current_priority"] == 90
assert updates[-1]["last_update"]
assert subscribe.last_update is None
def test_async_add_batch_reuses_prepared_defaults_notifications_and_effects():
"""批量新增把 Chain 准备好的默认字段、通知和原提交后回调逐季交给 writer。"""
module, subscribe_chain = _load_subscribe_chain_class()
chain = subscribe_chain()
def context(season: int):
"""构造已完成识别、季集补齐和默认参数处理的一季上下文。"""
mediainfo = SimpleNamespace(
title="批量剧集",
year="2026",
type=MediaType.TV,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
episode_group=None,
seasons={1: [1], 2: [1, 2]},
vote_average=8.5,
overview="批量准备",
get_poster_image=lambda: "poster.jpg",
get_backdrop_image=lambda: "backdrop.jpg",
)
return module._SubscribeCreateContext(
title="批量剧集",
year="2026",
mtype=MediaType.TV,
episode_group=None,
season=season,
channel=None,
source=None,
userid=None,
username="Seerr",
message=True,
exist_ok=False,
options={
"media_source": MediaSource.TMDB,
"media_id": "batch-chain-1",
"filter": "default-filter",
"total_episode": season,
"lack_episode": season,
},
explicit_identity=True,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
requested_music_type=None,
metainfo=SimpleNamespace(type=MediaType.TV),
mediainfo=mediainfo,
)
prepared = AsyncMock(side_effect=[(context(1), None), (context(2), None)])
notification = MagicMock(
side_effect=[{"title": "第 1 季"}, {"title": "第 2 季"}]
)
post_commit = AsyncMock(return_value=True)
setattr(chain, "_SubscribeChain__async_prepare_subscribe_create", prepared)
setattr(chain, "_SubscribeChain__build_subscribe_notification", notification)
setattr(chain, "_SubscribeChain__async_post_subscribe_added", post_commit)
batch_writer = MagicMock()
async def persist(requests):
"""模拟数据库批量 writer并在提交点后调用冻结的逐季副作用。"""
for subscribe_id, request in zip((31, 32), requests):
assert request.after_commit is not None
await request.after_commit(subscribe_id)
return (
SubscriptionWriteResult(31, "新增订阅成功", True),
SubscriptionWriteResult(32, "新增订阅成功", True),
)
batch_writer.async_add = AsyncMock(side_effect=persist)
result = asyncio.run(
chain.async_add_batch(
title="批量剧集",
year="2026",
seasons=[1, 2],
batch_writer=batch_writer,
mtype=MediaType.TV,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
username="Seerr",
)
)
assert result == (32, "新增订阅成功")
requests = batch_writer.async_add.await_args.args[0]
assert [request.identity.season for request in requests] == [1, 2]
assert [request.payload.to_payload()["filter"] for request in requests] == [
"default-filter",
"default-filter",
]
assert [dict(request.notification or {}) for request in requests] == [
{"title": "第 1 季"},
{"title": "第 2 季"},
]
assert [call.args[0] for call in post_commit.await_args_list] == [31, 32]

View File

@@ -1,20 +1,38 @@
"""订阅新增事务所有权与默认入口集成测试。"""
import asyncio
from dataclasses import replace
from unittest.mock import AsyncMock, Mock
import pytest
from sqlalchemy import select
from app.application.subscription.contract import (
SubscriptionIdentity,
SubscriptionPatch,
)
from app.application.subscription.write import (
AsyncCreateSubscriptionBatchCommand,
AsyncCreateSubscriptionCommand,
CreateSubscriptionCommand,
SubscriptionCreateRequest,
add_subscribe,
async_add_subscribe,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
)
from app.db.adapters.subscription import (
SessionSubscriptionBatchWriter,
SessionSubscriptionRepository,
TransactionalSubscriptionRepository,
)
from app.db.models.outbox import OutboxMessage
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper, SubscribeStageResult
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
@@ -32,6 +50,40 @@ def _media(media_id: str) -> MediaInfo:
return media
def _writer() -> TransactionalSubscriptionRepository:
"""按组合根合同构造显式短事务订阅仓储。"""
return TransactionalSubscriptionRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _batch_request(
media_id: str,
season: int,
) -> SubscriptionCreateRequest:
"""构造真实数据库批量写测试使用的一季订阅请求。"""
return SubscriptionCreateRequest(
identity=SubscriptionIdentity(
media_source=MediaSource.TMDB,
media_id=media_id,
season=season,
),
payload=SubscriptionPatch({
"name": "批量事务剧集",
"year": "2026",
"type": MediaType.TV.value,
"media_source": str(MediaSource.TMDB),
"media_id": media_id,
"season": season,
"username": "Seerr",
"total_episode": 12,
"lack_episode": 12,
}),
notification={"title": f"{season} 季订阅"},
)
def test_sync_command_orders_stage_commit_before_caller_effect() -> None:
"""同步新增只有在仓储暂存和提交成功后才把结果交给外部副作用。"""
calls: list[str] = []
@@ -167,14 +219,147 @@ async def test_async_report_failure_happens_after_event_without_rollback() -> No
unit_of_work.rollback.assert_not_awaited()
def test_async_batch_command_commits_once_and_stages_each_added_intent(db) -> None:
"""真实数据库中的多季订阅与各自事件、通知、统计 intent 只提交一次。"""
db.watermark(Subscribe, OutboxMessage)
async def execute() -> tuple[tuple[int, ...], list[int]]:
"""在真实 AsyncSession 中执行批量 writer 并记录提交后回调。"""
async with async_session_scope() as session:
actual_uow = SqlAlchemyAsyncUnitOfWork(session)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(wraps=actual_uow.commit)
unit_of_work.rollback = AsyncMock(wraps=actual_uow.rollback)
effects: list[int] = []
async def after_commit(subscribe_id: int) -> bool:
"""证明冻结在每季请求内的副作用只在唯一提交后执行。"""
assert unit_of_work.commit.await_count == 1
effects.append(subscribe_id)
return True
requests = [
replace(
_batch_request("servarr-batch-success", season),
after_commit=after_commit,
)
for season in (1, 2)
]
writer = SessionSubscriptionBatchWriter(
repository=SessionSubscriptionRepository(session),
unit_of_work=unit_of_work,
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
)
results = await writer.async_add(requests)
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited()
return tuple(result.subscribe_id for result in results), effects
subscribe_ids, effects = asyncio.run(execute())
assert effects == list(subscribe_ids)
rows = db.session.execute(
select(Subscribe)
.where(Subscribe.media_id == "servarr-batch-success")
.order_by(Subscribe.season)
).scalars().all()
assert [(row.id, row.season) for row in rows] == [
(subscribe_ids[0], 1),
(subscribe_ids[1], 2),
]
intents = db.session.execute(
select(OutboxMessage)
.where(OutboxMessage.event_key.contains("servarr-batch-success"))
.order_by(OutboxMessage.id)
).scalars().all()
assert [intent.topic for intent in intents] == [
"subscribe.added",
"subscribe.added.notification",
"subscribe.added.report",
"subscribe.added",
"subscribe.added.notification",
"subscribe.added.report",
]
assert all(intent.status == "completed" for intent in intents)
def test_async_batch_command_rolls_back_all_rows_when_later_season_fails(db) -> None:
"""真实数据库中后一季暂存失败时回滚此前季和已经暂存的 outbox intents。"""
db.watermark(Subscribe, OutboxMessage)
requests = [
_batch_request("servarr-batch-rollback", 1),
_batch_request("servarr-batch-rollback", 2),
]
failure = RuntimeError("second season failed")
async def execute() -> None:
"""让第二次暂存失败,并使用真实 UoW 验证完整回滚。"""
async with async_session_scope() as session:
repository = SessionSubscriptionRepository(session)
stage_calls = 0
async def fail_second(*args, **kwargs):
"""第一季真实 flush第二季在同一事务内抛出失败。"""
nonlocal stage_calls
stage_calls += 1
if stage_calls == 2:
raise failure
return await repository.async_stage_add(*args, **kwargs)
failing_repository = Mock()
failing_repository.async_stage_add = AsyncMock(side_effect=fail_second)
actual_uow = SqlAlchemyAsyncUnitOfWork(session)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(wraps=actual_uow.commit)
unit_of_work.rollback = AsyncMock(wraps=actual_uow.rollback)
command = AsyncCreateSubscriptionBatchCommand(
repository=failing_repository,
unit_of_work=unit_of_work,
outbox=SqlAlchemyAsyncOutboxStager(session),
)
with pytest.raises(RuntimeError) as raised:
await command.execute(requests)
assert raised.value is failure
unit_of_work.commit.assert_not_awaited()
unit_of_work.rollback.assert_awaited_once_with()
asyncio.run(execute())
rows = db.session.execute(
select(Subscribe).where(
Subscribe.media_id == "servarr-batch-rollback"
)
).scalars().all()
intents = db.session.execute(
select(OutboxMessage).where(
OutboxMessage.event_key.contains("servarr-batch-rollback")
)
).scalars().all()
assert rows == []
assert intents == []
def test_default_sync_writer_persists_once_and_reuses_duplicate(db) -> None:
"""Chain 默认入口使用独立事务写入,重复媒体身份返回同一订阅。"""
db.watermark(Subscribe)
media = _media("arch-221-sync")
after_commit = Mock()
first = add_subscribe(mediainfo=media, after_commit=after_commit)
second = add_subscribe(mediainfo=media, after_commit=after_commit)
writer = _writer()
first = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=after_commit,
)
second = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=after_commit,
)
assert first[0] > 0
assert first[1] == "新增订阅成功"
@@ -196,6 +381,7 @@ def test_default_sync_writer_keeps_failed_report_pending_without_raising(db) ->
subscribe_id, message = add_subscribe(
mediainfo=media,
subscribe_oper=_writer(),
after_commit=lambda _subscribe_id: False,
)
@@ -222,7 +408,11 @@ def test_default_async_writer_keeps_failed_report_pending_without_raising(db) ->
return False
subscribe_id, message = asyncio.run(
async_add_subscribe(mediainfo=media, after_commit=report_failed)
async_add_subscribe(
mediainfo=media,
subscribe_oper=_writer(),
after_commit=report_failed,
)
)
assert subscribe_id > 0
@@ -276,7 +466,9 @@ def test_default_async_writer_persists_committed_row(db) -> None:
db.watermark(Subscribe)
media = _media("arch-221-async")
subscribe_id, message = asyncio.run(async_add_subscribe(mediainfo=media))
subscribe_id, message = asyncio.run(
async_add_subscribe(mediainfo=media, subscribe_oper=_writer())
)
assert subscribe_id > 0
assert message == "新增订阅成功"

View File

@@ -7,6 +7,7 @@ import pytest
from pydantic import ValidationError
from app.api.endpoints.subscribe import create_subscribe
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.contract import (
SubscriptionHistorySnapshot,
SubscriptionPatch,
@@ -14,6 +15,7 @@ from app.application.subscription.contract import (
)
from app.application.subscription.mutation import SubscriptionMutationService
from app.application.subscription.query import SubscriptionQueryService
from app.runtime.events import eventmanager
from app.schemas.subscribe import Subscribe
from app.schemas.types import EventType, MediaSource, MediaType
@@ -35,12 +37,58 @@ def _subscription_mutation(
history_repository: "_SubscriptionHistoryRepositoryFake | None" = None,
) -> SubscriptionMutationService:
"""构造使用 typed 内存仓储的订阅写服务。"""
outbox = _EndpointOutbox()
async def publish_modified(payload: dict) -> None:
"""把测试服务提交后的修改事件交给真实事件边界。"""
await eventmanager.async_send_event(EventType.SubscribeModified, payload)
return SubscriptionMutationService(
repository=repository,
unit_of_work=_EndpointUnitOfWork(),
outbox=outbox,
dispatch_store=outbox,
publish_modified=publish_modified,
history_repository=history_repository,
)
class _EndpointUnitOfWork:
"""为内存 endpoint 仓储提供无副作用的事务端口。"""
async def commit(self) -> None:
"""确认内存更新。"""
async def rollback(self) -> None:
"""结束失败的内存更新。"""
class _EndpointOutbox:
"""让 endpoint 测试观察服务层的一次即时 outbox 派发。"""
async def stage(self, _intent, _now) -> None:
"""接受内存 intent。"""
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""认领当前测试刚暂存的 intent。"""
return ClaimedOutboxMessage(
message_id=1,
event_key=event_key,
topic="subscribe.modified",
payload={},
payload_version=1,
attempt=1,
)
async def complete(self, _message_id, _attempt, _completed_at) -> bool:
"""确认服务只完成一次事件派发。"""
return True
async def retry(self, _message_id, _attempt, **_kwargs) -> bool:
"""允许失败路径释放内存 lease。"""
return True
class TestSubscribeEndpoint:
"""
订阅接口回归测试。
@@ -152,7 +200,7 @@ class TestSubscribeEndpoint:
]:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -193,7 +241,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -240,7 +288,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -278,7 +326,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
@@ -315,7 +363,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
@@ -371,7 +419,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
@@ -402,7 +450,7 @@ class TestSubscribeEndpoint:
]:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -609,7 +657,7 @@ class TestSubscribeEndpoint:
with patch("app.api.endpoints.subscribe.SubscribeChain") as subscribe_chain:
result = subscribe_files(
subscribe_id=19,
mutation=_subscription_mutation(repository),
repository=repository,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
@@ -929,7 +977,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -971,7 +1019,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -1021,7 +1069,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
@@ -1042,6 +1090,53 @@ class TestSubscribeEndpoint:
assert payload["old_subscribe_info"]["name"] == "旧标题"
assert payload["subscribe_info"]["name"] == "新标题"
def test_update_subscribe_does_not_republish_pending_outbox_event(self):
"""服务返回 pending 时 endpoint 仍只返回业务成功,不得直接二次发布。"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=70,
username="alice",
name="旧标题",
total_episode=8,
lack_episode=2,
sites=[],
filter_groups=[],
start_episode=0,
)
mutation = SimpleNamespace(
get_accessible=AsyncMock(return_value=subscribe),
update=AsyncMock(
return_value=SimpleNamespace(
old=subscribe.to_dict(),
new={**subscribe.to_dict(), "name": "新标题"},
event_published=False,
business_committed=True,
pending_effects=("subscribe.modified:70:update:test:v1",),
)
),
)
with patch(
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(
id=70,
name="新标题",
total_episode=8,
lack_episode=2,
),
mutation=mutation,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
assert response.success
send_event.assert_not_awaited()
def test_update_subscribe_ignores_runtime_fact_fields(self):
"""
公共普通更新不得覆盖运行事实,状态调整继续由专用接口负责。
@@ -1080,7 +1175,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
@@ -1125,7 +1220,7 @@ class TestSubscribeEndpoint:
repository = _SubscriptionRepositoryFake(subscribe)
with patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
"app.runtime.events.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(

View File

@@ -1,8 +1,11 @@
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.application.subscription.contract import SubscriptionPatch, SubscriptionSnapshot
from app.application.subscription.mutation import SubscriptionMutation
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.schemas.types import MediaType
@@ -48,6 +51,27 @@ class _TimedOutLock:
raise AssertionError("未持有的订阅锁不应被释放")
def _configure_subscription_write(chain, repository) -> None:
"""为绕过构造器的搜索链注入显式同步修改作用域。"""
chain.subscription_repository = repository
@contextmanager
def mutation_scope():
"""把同步修改命令委托给状态测试 repository。"""
def update(subscribe_id, payload, _actor, existing=None, scene="update"):
"""执行测试更新并返回生产命令形状。"""
updated = repository.update(subscribe_id, SubscriptionPatch(payload))
return SubscriptionMutation(
snapshot=updated,
old=existing.to_dict() if existing else {},
new=updated.to_dict() if updated else {},
)
yield SimpleNamespace(update=update)
chain.sync_subscription_mutation_scope = mutation_scope
def _new_subscribe(created_at: datetime) -> SubscriptionSnapshot:
"""
构造一个新建电影订阅。
@@ -93,7 +117,7 @@ def test_new_subscribe_search_marks_state_after_attempt(monkeypatch) -> None:
media_chain.recognize_media.return_value = None
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
chain = object.__new__(SubscribeChain)
chain.subscription_repository = _SubscribeOper()
_configure_subscription_write(chain, _SubscribeOper())
chain.search(state="N", manual=False)
media_chain.recognize_media.assert_called_once()

View File

@@ -27,8 +27,10 @@ import asyncio
import pytest
from app.application.subscription.write import add_subscribe, async_add_subscribe
from app.db.adapters.subscription import TransactionalSubscriptionRepository
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.db.session import SessionFactory, async_session_scope
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.types import MediaSource, MediaType
@@ -69,10 +71,14 @@ def _musicinfo(media_id: str, music_type: str, **kwargs) -> MusicInfo:
def _add(_oper: SubscribeOper, is_async: bool, **kwargs):
"""经组合根配置的 typed 仓储分派同步或异步新增。"""
"""显式注入短事务仓储分派同步或异步新增。"""
writer = TransactionalSubscriptionRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
if is_async:
return asyncio.run(async_add_subscribe(**kwargs))
return add_subscribe(**kwargs)
return asyncio.run(async_add_subscribe(subscribe_oper=writer, **kwargs))
return add_subscribe(subscribe_oper=writer, **kwargs)
def _row(db, subscribe_id: int) -> Subscribe:

View File

@@ -238,6 +238,11 @@ def test_chain_runtime_context_owns_typed_subscription_repository() -> None:
}
assert annotations["subscription_repository"] == "SubscriptionRepository"
assert annotations["subscription_mutation_scope"] == "SubscriptionMutationScope"
assert annotations["sync_subscription_mutation_scope"] == "SyncSubscriptionMutationScope"
assert annotations["subscription_delete_scope"] == "DeleteSubscribeScope"
assert annotations["sync_subscription_delete_scope"] == "SyncDeleteSubscribeScope"
assert annotations["subscription_completion_scope"] == "CompletionScope"
assert not (APP_ROOT / "application" / "chain" / "data.py").exists()

View File

@@ -13,7 +13,7 @@ from app.application.subscription.mutation import (
class _Repository:
"""记录订阅读取、兼容更新和事务内暂存顺序。"""
"""记录订阅读取和事务内暂存顺序。"""
def __init__(self, subscribe: SubscriptionSnapshot, calls: list) -> None:
"""保存订阅对象与共享调用序列。"""
@@ -25,12 +25,6 @@ class _Repository:
self.calls.append(("get", subscribe_id))
return self.subscribe
async def async_update(self, subscribe_id: int, payload: SubscriptionPatch):
"""模拟旧兼容自动提交路径。"""
self.calls.append(("legacy_update", subscribe_id, payload))
self.subscribe = replace(self.subscribe, **payload.to_payload())
return self.subscribe
async def async_stage_update(self, subscribe_id: int, payload: SubscriptionPatch) -> SubscriptionSnapshot:
"""模拟调用方事务内的更新暂存。"""
self.calls.append(("stage_update", subscribe_id, payload))
@@ -41,6 +35,12 @@ class _Repository:
"""提供协议要求的同步读取。"""
return self.subscribe if subscribe_id == self.subscribe.id else None
def stage_update(self, subscribe_id: int, payload: SubscriptionPatch) -> SubscriptionSnapshot:
"""模拟同步调用方事务内的更新暂存。"""
self.calls.append(("stage_update", subscribe_id, payload))
self.subscribe = replace(self.subscribe, **payload.to_payload())
return self.subscribe
class _UnitOfWork:
"""记录订阅修改事务提交和回滚。"""
@@ -61,10 +61,16 @@ class _UnitOfWork:
class _Outbox:
"""记录修改事件 intent 暂存和完成。"""
def __init__(self, calls: list, stage_error: Exception | None = None) -> None:
def __init__(
self,
calls: list,
stage_error: Exception | None = None,
claim: bool = True,
) -> None:
"""保存共享调用序列与可选暂存异常。"""
self.calls = calls
self.stage_error = stage_error
self.claim = claim
async def stage(self, intent, _now) -> None:
"""记录 intent 并按需失败。"""
@@ -75,6 +81,8 @@ class _Outbox:
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的派发 lease。"""
self.calls.append(("outbox_claim", event_key))
if not self.claim:
return None
return ClaimedOutboxMessage(
message_id=7,
event_key=event_key,
@@ -134,6 +142,7 @@ async def test_modified_event_is_staged_with_update_and_completed_after_publish(
assert change is not None
assert change.event_published is True
assert change.snapshot.name == "新标题"
assert change.old["name"] == "旧标题"
assert change.new["name"] == "新标题"
assert [call[0] for call in calls] == [
@@ -198,3 +207,29 @@ async def test_modified_event_failure_keeps_committed_intent_pending():
"event",
"outbox_retry",
]
@pytest.mark.asyncio
async def test_unclaimed_committed_intent_stays_pending_without_second_publish():
"""即时派发未取得 lease 时只返回 pending调用方不得绕过 outbox 再发布。"""
calls = []
service = _service(calls, outbox=_Outbox(calls, claim=False))
change = await service.update(
7,
{"name": "新标题"},
SubscriptionActor(name="alice", is_superuser=False),
)
assert change is not None
assert change.snapshot.name == "新标题"
assert change.business_committed is True
assert change.event_published is False
assert change.pending_effects[0].startswith("subscribe.modified:7:update:")
assert [call[0] for call in calls] == [
"get",
"stage_update",
"outbox_stage",
"commit",
"outbox_claim",
]

View File

@@ -1,6 +1,7 @@
"""订阅仓储的短 Session 投影与请求事务所有权测试。"""
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.subscription.complete import CompleteSubscriptionCommand
@@ -11,12 +12,20 @@ from app.application.subscription.contract import (
from app.application.subscription.mutation import (
SubscriptionActor,
SubscriptionMutationService,
SyncSubscriptionMutationService,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.adapters.subscription import (
SessionSubscriptionHistoryRepository,
SessionSubscriptionRepository,
TransactionalSubscriptionRepository,
)
from app.db.models.outbox import OutboxMessage
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.session import SessionFactory, async_session_scope
@@ -24,6 +33,14 @@ from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.schemas.types import MediaSource, MediaType
async def _ignore_async_modified(_payload: dict) -> None:
"""为不触发修改事件的测试提供完整异步发布端口。"""
def _ignore_sync_modified(_payload: dict) -> None:
"""为不触发修改事件的测试提供完整同步发布端口。"""
def test_transactional_repository_returns_frozen_detached_snapshot(db) -> None:
"""standalone 查询在 Session 内复制 JSON返回值不携带 ORM 生命周期。"""
row = db.add(
@@ -150,8 +167,11 @@ def test_delete_history_commits_request_transaction(db) -> None:
"""使用真实 request adapter 和 UoW 删除历史。"""
service = SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
history_repository=SessionSubscriptionHistoryRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope),
publish_modified=_ignore_async_modified,
history_repository=SessionSubscriptionHistoryRepository(session),
)
return await service.delete_history(
history_id,
@@ -193,8 +213,11 @@ def test_delete_history_rolls_back_when_commit_fails(db) -> None:
"""执行会在提交阶段失败的真实历史删除。"""
service = SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
history_repository=SessionSubscriptionHistoryRepository(session),
unit_of_work=_FailingUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope),
publish_modified=_ignore_async_modified,
history_repository=SessionSubscriptionHistoryRepository(session),
)
with pytest.raises(RuntimeError, match="commit failed"):
await service.delete_history(
@@ -205,3 +228,257 @@ def test_delete_history_rolls_back_when_commit_fails(db) -> None:
db.run_async_session(delete_history)
db.session.expire_all()
assert db.session.get(SubscribeHistory, history_id) is not None
def test_async_mutation_commits_row_and_outbox_atomically(db) -> None:
"""异步修改把订阅行与 outbox intent 一次提交,发布成功后再完成 intent。"""
db.watermark(Subscribe, OutboxMessage)
row = db.add(
Subscribe(
name="异步修改前",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id="subscription-async-mutation-commit",
username="alice",
)
)
published = []
async def execute(session: AsyncSession):
"""在真实异步请求 Session 中执行一次完整修改。"""
async def publish(payload: dict) -> None:
"""记录提交后收到的 durable 事件快照。"""
published.append(payload)
service = SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope),
publish_modified=publish,
)
return await service.update(
row.id,
{"name": "异步修改后"},
SubscriptionActor(name="alice", is_superuser=False),
)
change = db.run_async_session(execute)
db.session.expire_all()
assert change is not None and change.business_committed and change.event_published
assert change.snapshot.name == "异步修改后"
assert db.session.get(Subscribe, row.id).name == "异步修改后"
intent = db.session.query(OutboxMessage).filter_by(
event_key=published[0]["idempotency_key"]
).one()
assert intent.status == "completed"
assert intent.payload["subscribe_info"]["name"] == "异步修改后"
def test_async_mutation_rolls_back_row_and_outbox_together(db) -> None:
"""异步 intent 暂存失败时,已 flush 的订阅行和 intent 都必须回滚。"""
db.watermark(Subscribe, OutboxMessage)
intent_watermark = db.session.execute(
select(func.max(OutboxMessage.id))
).scalar() or 0
row = db.add(
Subscribe(
name="异步回滚前",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id="subscription-async-mutation-rollback",
username="alice",
)
)
class _FailingStager:
"""真实暂存 intent 后抛错,验证整个业务事务回滚。"""
def __init__(self, session: AsyncSession) -> None:
"""绑定与订阅行相同的异步 Session。"""
self._stager = SqlAlchemyAsyncOutboxStager(session)
async def stage(self, intent, now) -> None:
"""先 flush intent再模拟事务内后续失败。"""
await self._stager.stage(intent, now)
raise RuntimeError("outbox stage failed")
async def execute(session: AsyncSession) -> None:
"""执行预期回滚的异步修改。"""
service = SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=_FailingStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope),
publish_modified=_ignore_async_modified,
)
with pytest.raises(RuntimeError, match="outbox stage failed"):
await service.update(
row.id,
{"name": "不应提交"},
SubscriptionActor(name="alice", is_superuser=False),
)
db.run_async_session(execute)
db.session.expire_all()
assert db.session.get(Subscribe, row.id).name == "异步回滚前"
assert not any(
intent.payload.get("subscribe_id") == row.id
for intent in db.session.query(OutboxMessage)
.filter(OutboxMessage.id > intent_watermark)
.all()
)
def test_async_pending_intent_is_not_published_outside_outbox(db) -> None:
"""即时认领失败时保留 pending服务不得绕过 outbox 调用 publisher。"""
db.watermark(Subscribe, OutboxMessage)
row = db.add(
Subscribe(
name="待派发修改前",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id="subscription-async-mutation-pending",
username="alice",
)
)
published = []
class _UnavailableStore:
"""模拟 intent 已提交但当前未取得即时派发 lease。"""
async def claim_by_event_key(self, _event_key, _now, _lease_until):
"""拒绝当前即时认领。"""
return None
async def complete(self, *_args, **_kwargs) -> bool:
"""未认领时禁止完成。"""
raise AssertionError("pending intent 不得完成")
async def retry(self, *_args, **_kwargs) -> bool:
"""未认领时禁止释放。"""
raise AssertionError("pending intent 不得重试")
async def execute(session: AsyncSession):
"""提交后返回 pending 状态。"""
async def publish(payload: dict) -> None:
"""记录任何越过 outbox 的错误发布。"""
published.append(payload)
service = SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=_UnavailableStore(),
publish_modified=publish,
)
return await service.update(
row.id,
{"name": "待 dispatcher 派发"},
SubscriptionActor(name="alice", is_superuser=False),
)
change = db.run_async_session(execute)
db.session.expire_all()
assert change is not None and change.business_committed
assert change.snapshot.name == "待 dispatcher 派发"
assert not change.event_published and len(change.pending_effects) == 1
assert published == []
intent = db.session.query(OutboxMessage).filter_by(
event_key=change.pending_effects[0]
).one()
assert intent.status == "pending"
assert db.session.get(Subscribe, row.id).name == "待 dispatcher 派发"
def test_sync_mutation_commits_row_and_outbox_atomically(db) -> None:
"""同步修改与异步服务共享同一行、intent、提交后派发语义。"""
db.watermark(Subscribe, OutboxMessage)
row = db.add(
Subscribe(
name="同步修改前",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id="subscription-sync-mutation-commit",
username="alice",
)
)
published = []
with SessionFactory() as session:
service = SyncSubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish_modified=published.append,
)
change = service.update(
row.id,
{"name": "同步修改后"},
SubscriptionActor(name="alice", is_superuser=False),
)
db.session.expire_all()
assert change is not None and change.business_committed and change.event_published
assert change.snapshot.name == "同步修改后"
assert db.session.get(Subscribe, row.id).name == "同步修改后"
intent = db.session.query(OutboxMessage).filter_by(
event_key=published[0]["idempotency_key"]
).one()
assert intent.status == "completed"
def test_sync_mutation_rolls_back_row_and_outbox_together(db) -> None:
"""同步 intent 暂存失败时回滚同行更新和已 flush 的 intent。"""
db.watermark(Subscribe, OutboxMessage)
intent_watermark = db.session.execute(
select(func.max(OutboxMessage.id))
).scalar() or 0
row = db.add(
Subscribe(
name="同步回滚前",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id="subscription-sync-mutation-rollback",
username="alice",
)
)
class _FailingStager:
"""同步暂存 intent 后模拟事务内异常。"""
def __init__(self, session) -> None:
"""绑定与订阅行相同的同步 Session。"""
self._stager = SqlAlchemyOutboxStager(session)
def stage(self, intent, now) -> None:
"""先 flush intent再中断业务事务。"""
self._stager.stage(intent, now)
raise RuntimeError("sync outbox stage failed")
with SessionFactory() as session:
service = SyncSubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=_FailingStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish_modified=_ignore_sync_modified,
)
with pytest.raises(RuntimeError, match="sync outbox stage failed"):
service.update(
row.id,
{"name": "不应提交"},
SubscriptionActor(name="alice", is_superuser=False),
)
db.session.expire_all()
assert db.session.get(Subscribe, row.id).name == "同步回滚前"
assert not any(
intent.payload.get("subscribe_id") == row.id
for intent in db.session.query(OutboxMessage)
.filter(OutboxMessage.id > intent_watermark)
.all()
)