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