mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
docs(architecture): enforce host persistence boundary
This commit is contained in:
@@ -24,8 +24,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
4. **治理事实源不一致**:架构规则、总览、AST 基线和 CI 语义存在漂移;快照门禁能证明“没有变化”,
|
||||
但不能自动证明依赖合理、没有新环或所有运行资源都有生命周期 owner。
|
||||
|
||||
当前唯一 P0 是主线 `mypy` ratchet 已失败。后续架构优化应先恢复主线,再按可靠性、边界类型化、
|
||||
生命周期、复杂度的顺序推进;不建议再做一次大规模目录搬迁。
|
||||
审计时唯一 P0 的主线 `mypy` ratchet 失败已由 `5df388719` 修复并推送。后续架构优化按可靠性、
|
||||
边界类型化、生命周期、复杂度的顺序推进;不建议再做一次大规模目录搬迁。
|
||||
|
||||
## 2. 范围与证据边界
|
||||
|
||||
@@ -105,8 +105,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
| ID | 优先级 | 状态 | 事项 | 目标结果 |
|
||||
|---|---|---|---|---|
|
||||
| ARCH-001 | P0 | 阻塞 | 恢复 mypy ratchet | 主线所有既有 CI gate 重新通过 |
|
||||
| ARCH-101 | P1 | 待执行 | 统一规则、总览、基线和语义门禁 | 文档声明与机器拒绝条件一一对应 |
|
||||
| ARCH-001 | P0 | 已交付 | 恢复 mypy ratchet | `5df388719` 已推送,主线既有 CI gate 通过 |
|
||||
| ARCH-101 | P1 | 执行中 | 统一规则、总览、基线和语义门禁 | 文档声明与机器拒绝条件一一对应 |
|
||||
| ARCH-102 | P1 | 待执行 | 将 Transfer pending 升级为真实 E3 状态机 | 崩溃窗口可判定恢复,结果未知时进入人工确认 |
|
||||
| ARCH-103 | P1 | 待执行 | 类型化 Chain/Agent 数据 Port 与 DTO | 宿主主路径不再注入无 Session Oper,不向入口泄漏 ORM |
|
||||
| ARCH-104 | P1 | 待执行 | 收口跨多次写入的业务事务 | 站点/规则引用清理可整体回滚或幂等恢复 |
|
||||
@@ -135,9 +135,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
**执行要求**
|
||||
|
||||
- [ ] 将标题键和位置键使用不同、带语义的变量名,修复真实类型错误。
|
||||
- [ ] 不得用 `mypy_ratchet.py --write` 接受增长。
|
||||
- [ ] 保留音乐专辑“精确标题优先、碟号/曲序回退”的现有行为。
|
||||
- [x] 将标题键和位置键使用不同、带语义的变量名,修复真实类型错误。
|
||||
- [x] 未使用 `mypy_ratchet.py --write` 接受增长。
|
||||
- [x] 保留音乐专辑“精确标题优先、碟号/曲序回退”的现有行为。
|
||||
|
||||
**验收**
|
||||
|
||||
@@ -152,8 +152,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
**问题与证据**
|
||||
|
||||
- `docs/rules/04-design-patterns.md:125-131` 仍示范 `SubscribeOper()` 无 Session 调用,
|
||||
与 `docs/rules/10-data-and-persistent.md:111-132` 的生产路径显式 UoW 规则冲突。
|
||||
- 审计时 `docs/rules/04-design-patterns.md` 仍示范 `SubscribeOper()` 无 Session 调用,
|
||||
与生产路径显式 UoW 规则冲突;该项已由 S0-L2.1 修复并增加文档门禁。
|
||||
- `docs/rules/05-architecture.md:507` 禁止 `application -> concrete adapter`,但同一文档
|
||||
`177-183` 又要求 RSS 消费 network adapter;当前测试没有形式化这类例外。
|
||||
- 规则 `docs/rules/05-architecture.md:516` 禁止任意模块级环,实际 SCC 测试只覆盖特定根。
|
||||
@@ -163,7 +163,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
**目标与步骤**
|
||||
|
||||
- [ ] 指定一个机器可读事实源,文档指标由 fixture 生成或只保留不易漂移的语义描述。
|
||||
- [ ] 修正 Oper 示例,分别展示宿主显式 UoW 与插件兼容 Facade。
|
||||
- [x] 修正 Oper 示例,分别展示宿主显式 UoW 与插件兼容 Facade,并以文档测试禁止回退。
|
||||
- [ ] 对 Adapter 规则作出明确决策:允许哪些通用技术机制,哪些命名外部/持久化能力必须注入。
|
||||
- [ ] 让 SCC 规则、精确 allowlist 和文档声明一致。
|
||||
- [ ] Event 扫描只识别 EventManager 实例/别名和事件装饰器。
|
||||
|
||||
@@ -72,11 +72,13 @@ G-ARCH 只有在以下条件全部满足后才可完成:
|
||||
|
||||
| Leaf | 状态 | 依赖 | 完成定义 |
|
||||
|---|---|---|---|
|
||||
| S0-L1 可信基线恢复 | `VERIFIED` | 无 | 交付架构审计/路线图,修复 `ARCH-001` 两个 mypy 增量错误;现有专项和主线门禁通过 |
|
||||
| S0-L2 规范事实统一 | `PLANNED` | S0-L1 | 修正 Oper、Adapter、HTTP、SCC、调用图规则冲突;规范目标与已执行门禁逐项对应 |
|
||||
| S0-L3 完整宿主 SCC 门禁 | `PLANNED` | S0-L2 | 完整宿主 SCC 集合只允许 TMDB 精确 allowlist;Chain 环清零前门禁提供可审查低水位,最终由 S2-L2 清零 |
|
||||
| S0-L4 Adapter/Egress 债务门禁 | `PLANNED` | S0-L2 | Application/Chain Adapter 导入和 direct HTTP egress 有精确分类;未解释项归零且禁止增长 |
|
||||
| S0-L5 Event 扫描事实修复 | `PLANNED` | S0-L2 | consumer 只识别 EventManager 注册;动态误报归零,台账与真实生产/消费一致 |
|
||||
| S0-L1 可信基线恢复 | `DELIVERED` | 无 | `5df388719`:交付架构审计/路线图,修复 `ARCH-001` 两个 mypy 增量错误;远端 `0/0` |
|
||||
| S0-L2.1 Host Oper/UoW 规范 | `VERIFIED` | S0-L1 | 宿主规则中未标注兼容范围的无 Session Oper 示例归零,并由文档测试锁定 |
|
||||
| S0-L2.2 完整宿主 SCC policy | `PLANNED` | S0-L2.1 | 完整宿主 SCC 全部精确分类;Chain 临时债务和 TMDB containment 分离 |
|
||||
| S0-L2.3 Adapter 直连事实 | `PLANNED` | S0-L2.1 | 收集 Application/Chain 的原始 Adapter import,不因父包展开重复计数 |
|
||||
| S0-L2.4 Adapter zero-growth | `PLANNED` | S0-L2.3 | 当前直连均登记迁移 owner,新增/替换失败,删除后要求清理陈旧 policy |
|
||||
| S0-L2.5 Event consumer 识别 | `PLANNED` | S0-L2.1 | consumer 只识别可静态证明的 EventManager 注册,动态误报归零 |
|
||||
| S0-L2.6 事实源与 CI 投影 | `PLANNED` | S0-L2.2,S0-L2.4,S0-L2.5 | fixture/policy/overview 职责固定,CI 分开报告语义 policy 与快照一致性 |
|
||||
|
||||
### S1:可靠性、事务与数据合同
|
||||
|
||||
@@ -151,53 +153,38 @@ G-ARCH 只有在以下条件全部满足后才可完成:
|
||||
|
||||
## 4. 当前活动叶子
|
||||
|
||||
### S0-L1 可信基线恢复
|
||||
### S0-L2.1 Host Oper/UoW 规范
|
||||
|
||||
**Status:** `VERIFIED`(本地验收完成,等待提交、推送和远端一致性确认)
|
||||
|
||||
**Outcome**
|
||||
|
||||
在不接受新债务的前提下恢复当前主线类型门禁,交付审计清单、Goal 路线图和与实际基线一致的架构总览。
|
||||
让宿主数据访问规范与已经建立的 Application Port、DB Adapter、显式 Session/UoW
|
||||
边界一致;无 Session Oper 只作为明确标注的插件 Legacy/Compat ABI 出现。
|
||||
|
||||
**Ownership**
|
||||
|
||||
- `app/chain/media.py` 的音乐曲目匹配局部类型回归。
|
||||
- `docs/architecture-optimization-checklist.md`。
|
||||
- `docs/architecture-refactor-roadmap.md`。
|
||||
- `docs/architecture-overview.md` 的当前事实与文档索引。
|
||||
- `docs/rules/04-design-patterns.md` 的 Oper、SystemConfig、UserConfig 示例。
|
||||
- `docs/rules/10-data-and-persistent.md` 的对应交叉规则。
|
||||
- `tests/test_architecture_documentation.py` 的无 Session Oper 文档门禁。
|
||||
- 本路线图的叶子状态和交付记录。
|
||||
|
||||
**Excluded**
|
||||
|
||||
- 不在本叶实现 ARCH-102 之后的业务重构。
|
||||
- 不刷新 mypy、Ruff、依赖或复杂度 fixture。
|
||||
- 不修改 `app/plugins/**` 或独立插件仓。
|
||||
- 不迁移当前 startup 注入的无 Session Oper;该债务由 ARCH-103 逐领域切换。
|
||||
- 不修改运行时代码、fixture、`app/plugins/**` 或独立插件仓。
|
||||
- 不以删除 Legacy/Compat ABI 伪装宿主规范收口。
|
||||
|
||||
**Acceptance**
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/test_music_album_match.py -q
|
||||
.venv/bin/mypy --config-file mypy.ini
|
||||
.venv/bin/python scripts/architecture/mypy_ratchet.py
|
||||
.venv/bin/python -m pytest tests/test_architecture_documentation.py -q
|
||||
.venv/bin/python scripts/architecture/baseline.py --check-host
|
||||
.venv/bin/python scripts/architecture/complexity.py
|
||||
.venv/bin/python scripts/architecture/async_blocking.py
|
||||
.venv/bin/python scripts/architecture/task_ownership.py
|
||||
.venv/bin/python scripts/architecture/service_locator.py
|
||||
.venv/bin/python scripts/architecture/ruff_ratchet.py
|
||||
.venv/bin/python -m pytest \
|
||||
tests/test_architecture_dependencies.py \
|
||||
tests/test_architecture_contract_baseline.py \
|
||||
tests/test_architecture_baseline_cli.py \
|
||||
tests/test_complexity_gate.py \
|
||||
tests/test_async_blocking_gate.py \
|
||||
tests/test_task_ownership_gate.py \
|
||||
tests/test_quality_ratchets.py \
|
||||
tests/test_mypy_gate.py \
|
||||
tests/test_architecture_ci.py -q
|
||||
rg -n 'SubscribeOper\(\)|SystemConfigOper\(\)|UserConfigOper\(\)' docs/rules
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Delivery**
|
||||
|
||||
- 单一提交主题:恢复可信架构基线并建立长期路线图。
|
||||
- 单一提交主题:统一宿主持久化规范并建立文档门禁。
|
||||
- 推送 `origin/v3` 后确认提交祖先关系、远端 SHA 和 ahead/behind `0/0`。
|
||||
|
||||
@@ -110,7 +110,9 @@ eventmanager.send_event(EventType.TransferComplete, data_dict)
|
||||
|
||||
## 4. Repository (Oper) Pattern
|
||||
|
||||
**When to use:** All database reads and writes. Never issue SQLAlchemy queries directly from chain, module, or endpoint code.
|
||||
**When to use:** Implementing table-oriented persistence behind an Application-owned
|
||||
Protocol. Never issue SQLAlchemy queries or construct Oper objects directly from
|
||||
chain, module, endpoint, Agent, scheduler, or workflow code.
|
||||
|
||||
**Convention:** Each SQLAlchemy model in `app/db/models/` has a corresponding `<Model>Oper` class in `app/db/oper/<model>.py` — the two packages mirror each other file for file, so the module name carries the entity and the package carries the role.
|
||||
|
||||
@@ -120,16 +122,36 @@ app/db/models/systemconfig.py → app/db/oper/systemconfig.py (SystemConfi
|
||||
app/db/models/transferhistory.py → app/db/oper/transferhistory.py (TransferHistoryOper)
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
**Host usage:**
|
||||
|
||||
Host entrypoints depend on an Application service or Protocol. The concrete
|
||||
`app/db/adapters/` implementation creates one operation-scoped Session, adapts the
|
||||
Protocol with Oper objects, and gives commit/rollback ownership to the Application
|
||||
command. Oper methods only query, stage, or flush in that caller-owned Session.
|
||||
|
||||
```python
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.application.subscription.write import SubscribeWriter
|
||||
|
||||
oper = SubscribeOper()
|
||||
subscribe = oper.get(sid=1)
|
||||
oper.add(Subscribe(name="Example", type="电影"))
|
||||
def create_subscription(writer: SubscribeWriter, identity: dict, payload: dict):
|
||||
return writer.add(identity=identity, payload=payload)
|
||||
```
|
||||
|
||||
The explicit Session/Oper composition belongs in `app/db/adapters/`, not at the
|
||||
host call site:
|
||||
|
||||
```python
|
||||
with SessionFactory() as session:
|
||||
command = CreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
)
|
||||
result = command.execute(identity=identity, payload=payload)
|
||||
```
|
||||
|
||||
The no-Session `SubscribeOper()` facade is legacy plugin ABI only. It may remain
|
||||
available through the curated SDK/Compat boundary, but new host code and examples
|
||||
must not copy that form.
|
||||
|
||||
---
|
||||
|
||||
## 5. Config Reload Pattern
|
||||
@@ -180,17 +202,21 @@ Do not introduce new singletons unless the class genuinely manages global shared
|
||||
|
||||
**Enum:** `SystemConfigKey` in `app/schemas/types.py`
|
||||
|
||||
**Oper class:** `SystemConfigOper` in `app/db/oper/systemconfig.py`
|
||||
**Host service:** `SystemConfigService` and `get_configured_system_config()` in
|
||||
`app/application/configuration.py`.
|
||||
|
||||
```python
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
|
||||
oper = SystemConfigOper()
|
||||
value = oper.get(SystemConfigKey.RssUrls)
|
||||
oper.set(SystemConfigKey.RssUrls, ["https://..."])
|
||||
configuration = get_configured_system_config()
|
||||
value = configuration.get(SystemConfigKey.RssUrls)
|
||||
configuration.set(SystemConfigKey.RssUrls, ["https://..."])
|
||||
```
|
||||
|
||||
`SystemConfigOper` is the DB/composition implementation and legacy plugin-facing
|
||||
facade; canonical host consumers do not construct it.
|
||||
|
||||
**Rule:** Never use raw string literals as SystemConfig keys. Always add a new entry to the `SystemConfigKey` enum first.
|
||||
|
||||
---
|
||||
@@ -199,9 +225,12 @@ oper.set(SystemConfigKey.RssUrls, ["https://..."])
|
||||
|
||||
**When to use:** Per-user settings that must survive across sessions but differ by user.
|
||||
|
||||
**Oper class:** `UserConfigOper` in `app/db/oper/userconfig.py`
|
||||
**Host service:** `UserConfigurationService` and
|
||||
`get_configured_user_configuration()` in
|
||||
`app/application/security/userconfig.py`.
|
||||
|
||||
Usage mirrors `SystemConfigOper` but scoped to a `user_id`.
|
||||
The concrete repository uses `UserConfigOper` behind the service boundary. Host
|
||||
callers do not construct it.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,8 +241,8 @@ Usage mirrors `SystemConfigOper` but scoped to a `user_id`.
|
||||
| `module -> chain` coupling | Move orchestration into `chain` and shared logic into its owning canonical package |
|
||||
| `module -> module` direct calls | Use `chain` to orchestrate cross-module workflows |
|
||||
| Lower-level module importing a chain or manager | Register a callback/resolver from `app/startup/` or move orchestration to `chain` |
|
||||
| Raw SQLAlchemy queries in endpoints or chains | Use the corresponding Oper class in `app/db/oper/` |
|
||||
| Raw SQLAlchemy queries or Oper construction in host entrypoints | Define/use an Application Protocol or command; implement it in `app/db/adapters/` with an operation-scoped Session/UoW |
|
||||
| Raw string keys for SystemConfig | Define and use a `SystemConfigKey` enum entry |
|
||||
| HTTP requests via `requests` or `httpx` directly | Host code uses `RequestUtils` from `app/adapters/network/http.py`; plugins use `app.sdk.network` |
|
||||
|
||||
*Last Updated: 2026-08-14*
|
||||
*Last Updated: 2026-08-27*
|
||||
|
||||
@@ -116,9 +116,9 @@ All new code must follow these conventions. Consistent naming is how the codebas
|
||||
| `def GetSubscribe():` | `def get_subscribe():` |
|
||||
| `TORRENT_info = ...` | `torrent_info = ...` |
|
||||
| `def handleConfigChanged():` | `def on_config_changed():` or `def handle_config_changed():` |
|
||||
| `SystemConfigOper().get("RssUrls")` | `SystemConfigOper().get(SystemConfigKey.RssUrls)` |
|
||||
| `configuration.get("RssUrls")` | `configuration.get(SystemConfigKey.RssUrls)` |
|
||||
| `class subscribe_oper:` | `class SubscribeOper:` |
|
||||
| `MessageChannel.Telegram`(新代码) | `NotificationChannel.Telegram` |
|
||||
| `Notification(title=...)`(新代码) | `Message(title=...)` |
|
||||
|
||||
*Last Updated: 2026-08-16*
|
||||
*Last Updated: 2026-08-27*
|
||||
|
||||
@@ -97,7 +97,7 @@ if not self._initialized:
|
||||
|
||||
```python
|
||||
# 获取订阅列表 ← 这只是在重述代码,不需要
|
||||
subscribes = SubscribeOper().list()
|
||||
subscribes = repository.list()
|
||||
|
||||
# 如果 result 为 None 则返回 ← 无意义
|
||||
if result is None:
|
||||
@@ -139,4 +139,4 @@ When modifying code, update or remove any comment that no longer accurately desc
|
||||
| Commented-out dead code | Delete it; git history preserves it |
|
||||
| New contract documentation in English inside an otherwise Chinese file | Breaks the repository's default documentation language and local consistency |
|
||||
|
||||
*Last Updated: 2026-08-13*
|
||||
*Last Updated: 2026-08-27*
|
||||
|
||||
@@ -199,19 +199,21 @@ oper.delete(sid=1) # Delete by key
|
||||
|
||||
**Enum:** `SystemConfigKey` in `app/schemas/types.py`
|
||||
|
||||
**Oper:** `SystemConfigOper` in `app/db/oper/systemconfig.py`
|
||||
**Host service:** `SystemConfigService` and `get_configured_system_config()` in
|
||||
`app/application/configuration.py`. `SystemConfigOper` is used behind the
|
||||
composition/persistence boundary and remains available for legacy plugin ABI.
|
||||
|
||||
```python
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
|
||||
oper = SystemConfigOper()
|
||||
configuration = get_configured_system_config()
|
||||
|
||||
# Read
|
||||
rss_urls = oper.get(SystemConfigKey.RssUrls)
|
||||
rss_urls = configuration.get(SystemConfigKey.RssUrls)
|
||||
|
||||
# Write
|
||||
oper.set(SystemConfigKey.RssUrls, ["https://example.com/rss"])
|
||||
configuration.set(SystemConfigKey.RssUrls, ["https://example.com/rss"])
|
||||
```
|
||||
|
||||
**Rule:** Never use raw string literals as `SystemConfig` keys. Always define a new `SystemConfigKey` enum entry first. Raw string key lookups are not searchable and cannot be refactored safely.
|
||||
@@ -220,16 +222,21 @@ oper.set(SystemConfigKey.RssUrls, ["https://example.com/rss"])
|
||||
|
||||
## UserConfig — Per-User Configuration
|
||||
|
||||
**Purpose:** Settings that differ per user account. Uses `UserConfigOper`.
|
||||
**Purpose:** Settings that differ per user account. Host callers use the configured
|
||||
`UserConfigurationService`; its concrete repository adapts `UserConfigOper` behind
|
||||
the persistence boundary.
|
||||
|
||||
```python
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.application.security.userconfig import get_configured_user_configuration
|
||||
|
||||
oper = UserConfigOper()
|
||||
value = oper.get(user_id=1, key="notification_enabled")
|
||||
oper.set(user_id=1, key="notification_enabled", value=True)
|
||||
configuration = get_configured_user_configuration()
|
||||
value = configuration.get(username="alice", key="notification_enabled")
|
||||
configuration.set(username="alice", key="notification_enabled", value=True)
|
||||
```
|
||||
|
||||
The no-Session `UserConfigOper()` form is legacy plugin ABI only and must not be
|
||||
copied into host code.
|
||||
|
||||
---
|
||||
|
||||
## Settings / Environment Configuration
|
||||
@@ -290,4 +297,4 @@ When `REDIS_HOST` is configured, `app/modules/redis/` provides a distributed cac
|
||||
- `settings.API_TOKEN` and other secret fields must not be included in log output or API responses.
|
||||
- The `config list --show-secrets` flag exists specifically to gate secret visibility in the CLI.
|
||||
|
||||
*Last Updated: 2026-08-24*
|
||||
*Last Updated: 2026-08-27*
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""架构规则文档的可执行一致性约束。"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
RULE_DOCUMENTS = tuple(sorted((PROJECT_ROOT / "docs" / "rules").glob("*.md")))
|
||||
NO_SESSION_OPER = re.compile(r"\b[A-Z][A-Za-z0-9]*Oper\(\)")
|
||||
COMPATIBILITY_MARKERS = ("legacy plugin abi", "compatibility", "兼容")
|
||||
|
||||
|
||||
def test_no_session_oper_examples_are_explicitly_compatibility_only() -> None:
|
||||
"""无 Session Oper 示例必须紧邻 Legacy/Compat 说明,不能冒充宿主规范。"""
|
||||
violations: list[str] = []
|
||||
for path in RULE_DOCUMENTS:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for occurrence in NO_SESSION_OPER.finditer(content):
|
||||
context = content[
|
||||
max(0, occurrence.start() - 500):occurrence.end() + 200
|
||||
].lower()
|
||||
if not any(marker in context for marker in COMPATIBILITY_MARKERS):
|
||||
line = content.count("\n", 0, occurrence.start()) + 1
|
||||
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{line}")
|
||||
|
||||
assert violations == [], (
|
||||
"无 Session Oper 仅允许出现在明确标注的插件 Legacy/Compat 示例中: "
|
||||
+ ", ".join(violations)
|
||||
)
|
||||
Reference in New Issue
Block a user