diff --git a/app/agent/tools/impl/_filter_rule_utils.py b/app/agent/tools/impl/_filter_rule_utils.py index dd9665f06..77cffeb3b 100644 --- a/app/agent/tools/impl/_filter_rule_utils.py +++ b/app/agent/tools/impl/_filter_rule_utils.py @@ -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: diff --git a/app/agent/tools/impl/add_rule_group.py b/app/agent/tools/impl/add_rule_group.py index 611ff2884..017e06401 100644 --- a/app/agent/tools/impl/add_rule_group.py +++ b/app/agent/tools/impl/add_rule_group.py @@ -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, diff --git a/app/agent/tools/impl/delete_rule_group.py b/app/agent/tools/impl/delete_rule_group.py index 559e86362..c24fef469 100644 --- a/app/agent/tools/impl/delete_rule_group.py +++ b/app/agent/tools/impl/delete_rule_group.py @@ -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( { diff --git a/app/agent/tools/impl/delete_subscribe.py b/app/agent/tools/impl/delete_subscribe.py index 9013819cc..0d6c9d0e2 100644 --- a/app/agent/tools/impl/delete_subscribe.py +++ b/app/agent/tools/impl/delete_subscribe.py @@ -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), diff --git a/app/agent/tools/impl/search_subscribe.py b/app/agent/tools/impl/search_subscribe.py index 76b24643c..a2b23f88f 100644 --- a/app/agent/tools/impl/search_subscribe.py +++ b/app/agent/tools/impl/search_subscribe.py @@ -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 线程池。 diff --git a/app/agent/tools/impl/update_custom_filter_rule.py b/app/agent/tools/impl/update_custom_filter_rule.py index 7a25cfba2..564ee232a 100644 --- a/app/agent/tools/impl/update_custom_filter_rule.py +++ b/app/agent/tools/impl/update_custom_filter_rule.py @@ -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, diff --git a/app/agent/tools/impl/update_rule_group.py b/app/agent/tools/impl/update_rule_group.py index 4269bba60..e07ce387c 100644 --- a/app/agent/tools/impl/update_rule_group.py +++ b/app/agent/tools/impl/update_rule_group.py @@ -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, diff --git a/app/agent/tools/impl/update_subscribe.py b/app/agent/tools/impl/update_subscribe.py index a30c9137b..d5cad27ec 100644 --- a/app/agent/tools/impl/update_subscribe.py +++ b/app/agent/tools/impl/update_subscribe.py @@ -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, diff --git a/app/api/dependencies/subscription.py b/app/api/dependencies/subscription.py index 497ab2ba2..e194728d5 100644 --- a/app/api/dependencies/subscription.py +++ b/app/api/dependencies/subscription.py @@ -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, ) diff --git a/app/api/deps.py b/app/api/deps.py index 9007667af..6f840def1 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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", diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 81c5ce627..a70ecc5eb 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -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() diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 4fcd33272..823d43acc 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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", ), ) diff --git a/app/api/servarr.py b/app/api/servarr.py index c16cb975d..abeba944a 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -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( diff --git a/app/application/agent.py b/app/application/agent.py index fb4454351..80466a2fe 100644 --- a/app/application/agent.py +++ b/app/application/agent.py @@ -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 diff --git a/app/application/chain/context.py b/app/application/chain/context.py index 217e479d4..e0588795b 100644 --- a/app/application/chain/context.py +++ b/app/application/chain/context.py @@ -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 diff --git a/app/application/configuration.py b/app/application/configuration.py index fb7dc62b7..be5749020 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -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 暴露的最小可变合同。""" diff --git a/app/application/rules.py b/app/application/rules.py index 46c1d4621..11d573959 100644 --- a/app/application/rules.py +++ b/app/application/rules.py @@ -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 的列表结构。 """ diff --git a/app/application/site/mutation.py b/app/application/site/mutation.py index 11755dffa..d153f56d9 100644 --- a/app/application/site/mutation.py +++ b/app/application/site/mutation.py @@ -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: """统一执行站点新增、更新、优先级和删除事务。""" diff --git a/app/application/subscription/complete.py b/app/application/subscription/complete.py index 6dfaac334..053ae31d2 100644 --- a/app/application/subscription/complete.py +++ b/app/application/subscription/complete.py @@ -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() diff --git a/app/application/subscription/contract.py b/app/application/subscription/contract.py index 46de87636..c1e19e2c3 100644 --- a/app/application/subscription/contract.py +++ b/app/application/subscription/contract.py @@ -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, diff --git a/app/application/subscription/delete.py b/app/application/subscription/delete.py index 111ef310d..c37e7ece4 100644 --- a/app/application/subscription/delete.py +++ b/app/application/subscription/delete.py @@ -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() diff --git a/app/application/subscription/mutation.py b/app/application/subscription/mutation.py index e23e9cf0f..97830c585 100644 --- a/app/application/subscription/mutation.py +++ b/app/application/subscription/mutation.py @@ -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], +] diff --git a/app/application/subscription/write.py b/app/application/subscription/write.py index 001c646ac..a6223e3e9 100644 --- a/app/application/subscription/write.py +++ b/app/application/subscription/write.py @@ -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", ] diff --git a/app/chain/__init__.py b/app/chain/__init__.py index f7af5ed95..1fd1ac5f7 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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 diff --git a/app/chain/_music.py b/app/chain/_music.py index e82161792..740ed1c9e 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -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) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index ad8eae4d2..367920c50 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -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}," diff --git a/app/db/adapters/configuration.py b/app/db/adapters/configuration.py index dfa2d28bb..0e670a369 100644 --- a/app/db/adapters/configuration.py +++ b/app/db/adapters/configuration.py @@ -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: diff --git a/app/db/adapters/subscription.py b/app/db/adapters/subscription.py index 7efbfc86e..f40fca1ba 100644 --- a/app/db/adapters/subscription.py +++ b/app/db/adapters/subscription.py @@ -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], diff --git a/app/db/oper/systemconfig.py b/app/db/oper/systemconfig.py index 1e352854a..b745f8943 100644 --- a/app/db/oper/systemconfig.py +++ b/app/db/oper/systemconfig.py @@ -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]: """ 设置系统设置 diff --git a/app/startup/composition/context.py b/app/startup/composition/context.py index 38a940f76..c20d3b98a 100644 --- a/app/startup/composition/context.py +++ b/app/startup/composition/context.py @@ -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) diff --git a/app/startup/composition/subscription.py b/app/startup/composition/subscription.py index 15cf8f2e2..04a86c9a9 100644 --- a/app/startup/composition/subscription.py +++ b/app/startup/composition/subscription.py @@ -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) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index a92417f4a..c46124dfa 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -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, diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index f6721a2dc..dfa60bc50 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -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 receiver,10 个动态误报归零并保留唯一 workflow 动态注册。 - S0-L2.6 已将 producer/consumer 合并为逐调用事实源;本轮统一 Transfer 事件发送点后为 - 97 个 producer(96 静态、1 动态)与 + 95 个 producer(94 静态、1 动态)与 17 个 consumer(16 静态、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 新增、修改、删除、完成和批量修改的单一 UoW,canonical 自动提交写入口 + 与运行时 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 \ diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1f4f47bc2..94a48c655 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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 | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) | | Event Contract | 53 | -| Event producer / consumer | 97(96 静态、1 动态)/ 17(16 静态、1 动态) | +| Event producer / consumer | 95(94 静态、1 动态)/ 17(16 静态、1 动态) | | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index 73d4efaeb..e7b5240be 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -108,8 +108,8 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | S1-L3.6 Site | `VERIFIED` | S1-L3.5 | Site 配置、用户数据、图标和统计统一为深度冻结 DTO 与 typed query/write/staging Port;请求写入复用 AsyncSession,Chain/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 Repository;DB 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 context,AST 门禁确认 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 均先 claim,complete/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 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/tests/conftest.py b/tests/conftest.py index 99022324b..1e73ca6cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, ), diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index b36b0a9bb..d65b518cc 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index a5bc91775..fbd14abcc 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -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, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 601e3c7af..7643c058b 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -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 }, diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 9bbeb3a93..dbd029c28 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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": { diff --git a/tests/test_agent_data_ports.py b/tests/test_agent_data_ports.py index 123893f3d..275a608d7 100644 --- a/tests/test_agent_data_ports.py +++ b/tests/test_agent_data_ports.py @@ -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 = ( diff --git a/tests/test_agent_delete_subscribe.py b/tests/test_agent_delete_subscribe.py index 805fea840..6f455c002 100644 --- a/tests/test_agent_delete_subscribe.py +++ b/tests/test_agent_delete_subscribe.py @@ -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() diff --git a/tests/test_agent_search_subscribe.py b/tests/test_agent_search_subscribe.py index 1123de983..c0cbced35 100644 --- a/tests/test_agent_search_subscribe.py +++ b/tests/test_agent_search_subscribe.py @@ -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, ) diff --git a/tests/test_agent_update_subscribe.py b/tests/test_agent_update_subscribe.py index 5c1a6f046..f4757ee0c 100644 --- a/tests/test_agent_update_subscribe.py +++ b/tests/test_agent_update_subscribe.py @@ -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), + ) diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index f9458809f..cddec8094 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -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") diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 7898a7229..712d7d5f2 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -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", diff --git a/tests/test_chain_rate_limit.py b/tests/test_chain_rate_limit.py index 511d03723..c7b13c383 100644 --- a/tests/test_chain_rate_limit.py +++ b/tests/test_chain_rate_limit.py @@ -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(), diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py index e6e7918c9..92e58eea6 100644 --- a/tests/test_chain_runtime_context.py +++ b/tests/test_chain_runtime_context.py @@ -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 diff --git a/tests/test_data_locator_architecture.py b/tests/test_data_locator_architecture.py index e68e98666..bc669c363 100644 --- a/tests/test_data_locator_architecture.py +++ b/tests/test_data_locator_architecture.py @@ -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", diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 95ff8fa4f..88db64966 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -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(), diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 1c057d12d..02fdc5bc0 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -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 diff --git a/tests/test_plugin_system_setting_admission.py b/tests/test_plugin_system_setting_admission.py index 9d0a84491..7408f0c65 100644 --- a/tests/test_plugin_system_setting_admission.py +++ b/tests/test_plugin_system_setting_admission.py @@ -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() diff --git a/tests/test_reference_mutation.py b/tests/test_reference_mutation.py new file mode 100644 index 000000000..47a26f762 --- /dev/null +++ b/tests/test_reference_mutation.py @@ -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", + ] diff --git a/tests/test_rule_group_media.py b/tests/test_rule_group_media.py index 435f0e1f1..07f82be51 100644 --- a/tests/test_rule_group_media.py +++ b/tests/test_rule_group_media.py @@ -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) diff --git a/tests/test_servarr_series_add.py b/tests/test_servarr_series_add.py index 2432c8387..11bcf422f 100644 --- a/tests/test_servarr_series_add.py +++ b/tests/test_servarr_series_add.py @@ -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 diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index a8f4fc0c7..154a69d55 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -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] diff --git a/tests/test_subscribe_create_command.py b/tests/test_subscribe_create_command.py index 15fd768e0..a087d4967 100644 --- a/tests/test_subscribe_create_command.py +++ b/tests/test_subscribe_create_command.py @@ -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 == "新增订阅成功" diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index e60df72b3..434d2aa8f 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -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( diff --git a/tests/test_subscribe_search_state.py b/tests/test_subscribe_search_state.py index 2a7d30e41..51028918d 100644 --- a/tests/test_subscribe_search_state.py +++ b/tests/test_subscribe_search_state.py @@ -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() diff --git a/tests/test_subscribe_write_path.py b/tests/test_subscribe_write_path.py index b41e5367a..6b6f84b05 100644 --- a/tests/test_subscribe_write_path.py +++ b/tests/test_subscribe_write_path.py @@ -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: diff --git a/tests/test_subscription_architecture.py b/tests/test_subscription_architecture.py index cbd0ed02b..9047d02ef 100644 --- a/tests/test_subscription_architecture.py +++ b/tests/test_subscription_architecture.py @@ -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() diff --git a/tests/test_subscription_mutation_outbox.py b/tests/test_subscription_mutation_outbox.py index a86e8404e..e6e8e9595 100644 --- a/tests/test_subscription_mutation_outbox.py +++ b/tests/test_subscription_mutation_outbox.py @@ -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", + ] diff --git a/tests/test_subscription_repository.py b/tests/test_subscription_repository.py index efa28f51e..10dd98547 100644 --- a/tests/test_subscription_repository.py +++ b/tests/test_subscription_repository.py @@ -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() + )