refactor: unify music subscription data port

This commit is contained in:
jxxghp
2026-08-24 04:20:06 +08:00
parent a13c96d30b
commit 286b5836a1
5 changed files with 38 additions and 14 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ from app.application.subscription.contract import (
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.application.chain.data import SubscribePortProxy as SubscribeOper
from app.application.chain.data import get_chain_subscribe_port
from app.application.configuration import get_configured_system_config
from app.domain.context import Context, MediaInfo, MusicInfo
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
@@ -197,7 +197,7 @@ class MusicSubscribeMixin:
update_data["total_tracks"] = total_tracks
if not update_data:
return
SubscribeOper().update(subscribe.id, update_data)
get_chain_subscribe_port().update(subscribe.id, update_data)
for key, value in update_data.items():
setattr(subscribe, key, value)
@@ -348,10 +348,10 @@ class MusicSubscribeMixin:
"current_bit_depth": best_meta.bit_depth,
"current_sample_rate": best_meta.sample_rate,
}
SubscribeOper().update(subscribe.id, quality_data)
get_chain_subscribe_port().update(subscribe.id, quality_data)
for key, value in quality_data.items():
setattr(subscribe, key, value)
current_subscribe = SubscribeOper().get(subscribe.id)
current_subscribe = get_chain_subscribe_port().get(subscribe.id)
if current_subscribe:
self.finish_subscribe_or_not(
subscribe=current_subscribe,
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口。
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口
## 当前复核结论(2026-08-24
@@ -160,6 +160,15 @@
- 兼容边界不变:数据库 `UserOper``UserPortProxy`、User/Interaction/Message Chain 公开方法、消息渠道
payload 和插件调用方式均未改动。
### 长期整改阶段 14:音乐订阅 Chain 数据端口收口(2026-08-24
- 音乐订阅辅助链原先通过 `SubscribePortProxy as SubscribeOper` 更新音乐元数据、质量和当前订阅;现在
统一调用 `get_chain_subscribe_port()`,测试改为替换同一个命名 getter。
- 架构门禁单独覆盖 `_music.py`,禁止重新引入 `SubscribePortProxy`。主订阅 Chain 仍有更多数据端口和
事务/副作用耦合,继续作为独立高风险阶段处理。
- 兼容边界不变:音乐订阅公开方法、数据库 `SubscribeOper``SubscribePortProxy`、字段写入与插件调用
合同均未修改。
### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+5 -5
View File
@@ -131,11 +131,11 @@ Session. `app/db/adapters/` is the concrete persistence-adapter layer: it may
depend on Application-owned Protocols, UoW/Session and Oper implementations.
This deliberate dependency inversion is the only `DB implementation ->
Application contract` direction; Application must remain free of DB imports.
Migrated workflow, user, interaction and messaging Chain consumers use the named
`get_chain_*_port()` functions from `app/application/chain/data.py`; they must not
alias migration-time `*PortProxy` classes back to database Oper names. Those
proxy classes remain compatibility boundaries while the other established Chain
domains migrate independently.
Migrated workflow, user, interaction, messaging and music Chain consumers use
the named `get_chain_*_port()` functions from `app/application/chain/data.py`;
they must not alias migration-time `*PortProxy` classes back to database Oper
names. Those proxy classes remain compatibility boundaries while the other
established Chain domains migrate independently.
### Adapter boundaries
+15
View File
@@ -452,6 +452,21 @@ def test_user_and_messaging_chains_use_explicit_data_port_getters():
assert violations == []
def test_music_chain_uses_explicit_subscribe_data_port_getter():
"""音乐订阅链不得把 SubscribePortProxy 伪装成 SubscribeOper。"""
path = APP_ROOT / "chain" / "_music.py"
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
violations = [
f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}"
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module == "app.application.chain.data"
and any(alias.name == "SubscribePortProxy" for alias in node.names)
]
assert violations == []
def test_plugin_components_do_not_reexport_legacy_abi_names():
"""新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。"""
violations: list[str] = []
+4 -4
View File
@@ -134,7 +134,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
patch("app.chain._music.SearchChain", return_value=search_chain), \
patch("app.chain._music.DownloadChain", return_value=download_chain), \
patch("app.chain._music.SubscribeOper") as subscribe_oper:
patch("app.chain._music.get_chain_subscribe_port") as subscribe_oper:
subscribe_oper.return_value.get.return_value = subscribe
chain._search_music_subscribe(subscribe)
@@ -240,7 +240,7 @@ def test_music_best_version_persists_downloaded_rule_priority():
chain.finish_subscribe_or_not = Mock()
with patch("app.chain._music.DownloadChain", return_value=download_chain), \
patch("app.chain._music.SubscribeOper", return_value=subscribe_oper):
patch("app.chain._music.get_chain_subscribe_port", return_value=subscribe_oper):
chain._download_music_subscribe(subscribe, _music_info(), [downloaded])
subscribe_oper.update.assert_called_once_with(
@@ -441,7 +441,7 @@ def test_music_rss_match_reuses_cached_context_without_second_site_search():
torrent_helper.filter_torrent.return_value = True
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain._music.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain._music.get_chain_subscribe_port", return_value=subscribe_oper), \
patch("app.chain._music.TorrentHelper", return_value=torrent_helper), \
patch("app.chain._music.DownloadChain", return_value=download_chain), \
patch("app.chain.subscribe.SearchChain") as search_chain, \
@@ -638,7 +638,7 @@ def test_recording_target_sync_clears_stale_album_track_count():
subscribe = _subscribe(total_tracks=11)
subscribe_oper = Mock()
with patch("app.chain._music.SubscribeOper", return_value=subscribe_oper):
with patch("app.chain._music.get_chain_subscribe_port", return_value=subscribe_oper):
SubscribeChain._sync_music_subscribe_target(subscribe, _music_info())
subscribe_oper.update.assert_called_once_with(subscribe.id, {"total_tracks": None})