From 72a0b2171e77d62a548e7e715a0365bf5aff7a69 Mon Sep 17 00:00:00 2001 From: rayhpeng Date: Wed, 29 Jul 2026 18:29:04 +0800 Subject: [PATCH] docs(hexagonal): rewrite the guide as a normative spec - HEXAGONAL_ARCHITECTURE_zh.md becomes the spec: Cockburn/AWS-sourced standard structure (domain seven-piece layout, file naming rules), the four-transformation conversion chain with fixed owners and method names, commands/events design with upgrade triggers, an enforced rule table, and generic read/write sequence + class diagrams - FEEDBACK_DESIGN_zh.md is rewritten as the reference-implementation walkthrough of that spec (commands, exceptions split, _apply mapping, composition root, updated test map and pitfalls) - add the definition and dispatch diagrams under docs/assets --- backend/docs/FEEDBACK_DESIGN_zh.md | 291 +++++------- backend/docs/HEXAGONAL_ARCHITECTURE_zh.md | 425 +++++++++++------- backend/docs/README.md | 2 +- .../docs/assets/hexagonal_architecture.png | Bin 0 -> 1339805 bytes .../assets/hexagonal_dispatch_relation.png | Bin 0 -> 1011017 bytes 5 files changed, 375 insertions(+), 343 deletions(-) create mode 100644 backend/docs/assets/hexagonal_architecture.png create mode 100644 backend/docs/assets/hexagonal_dispatch_relation.png diff --git a/backend/docs/FEEDBACK_DESIGN_zh.md b/backend/docs/FEEDBACK_DESIGN_zh.md index a4cc662e1..e87898441 100644 --- a/backend/docs/FEEDBACK_DESIGN_zh.md +++ b/backend/docs/FEEDBACK_DESIGN_zh.md @@ -1,10 +1,8 @@ # 用户反馈(Feedback)模块设计 -> 面向想读懂或二次开发这个模块的人。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。 +> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释,只讲三件事——feedback 特有的产品决定、每条规范规则落在哪个文件、以及 feedback 特有的陷阱。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。 > -> 本文假定你已读过 [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md)——那里讲六边形的规则,这里讲规则在这个模块上的具体样子。术语(端口、适配器、聚合、防腐层)不再重复解释。 -> -> 姊妹文档:[SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)——定时任务模块,同样的结构但复杂度高一个量级(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**)。 +> 姊妹文档:[SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)——定时任务模块,同样的结构但复杂度高一个量级。 --- @@ -27,50 +25,29 @@ PUT /api/threads/{tid}/runs/{rid}/feedback 设置当前评价(幂等) DELETE /api/threads/{tid}/runs/{rid}/feedback 撤回 ``` -## 2. 当前状态:已完整迁移 +评价在业务上**与 run 绑定**。早期版本曾有一个可选的 `message_id`(把评价收窄到 run 内某一条消息),从未被任何读写方使用,属于冗余设计,已连同存储列一起移除(迁移 `0011_feedback_drop_message_id`)。 -这是仓库第一个走完六边形切片的模块,也是后续模块的样板。内圈、外圈、组合根、契约测试都已到位: +## 2. 内圈全景 ``` -packages/harness/deerflow/domain/feedback/ 内圈(harness) -backend/app/adapters/feedback/ 从适配器(app) -backend/app/gateway/routers/feedback.py 主适配器(app) -backend/app/gateway/deps.py::langgraph_runtime 组合根(app) +domain/feedback/ 对应规范 §2 的 domain 七件套 +├── __init__.py 上下文公开 API:聚合 + 命令 + 错误 + 服务(端口刻意不导出) +├── model.py Feedback(聚合根)· 2 个白名单常量 → 七件套 model/ +├── exceptions.py 5 个领域错误,一个基类 FeedbackError → 七件套 exceptions/ +├── commands.py RateRun · RetractRunRating → 七件套 commands/ +├── ports.py FeedbackRepository · RunLookup → 七件套 ports/ +└── service.py FeedbackService(写方法即 handler) → 七件套 command_handlers/ ``` -与 schedule 模块不同,这里**没有新旧并存**——旧的 `FeedbackRepository`(散在 `persistence/` 下的那一版)已经删除,没有兼容层,没有双写。 +`model.py` 是**单文件**而非目录,因为这里只有一个聚合、没有值对象、没有状态机——对比 schedule 的 `model/` 包。模块规模决定形态,不必强行对称。events 缺席:feedback 的业务事实目前没有任何跨上下文订阅方(规范 §3.2 的触发条件未到)。 -已知的收尾待办见总纲 §7,其中优先级最高的一项是本文 §11 的第一个陷阱。 +`__init__.py` 的导出策略值得注意:**它导出聚合、命令、错误和服务,但不导出端口**。端口是给适配器和测试用的契约,不是日常调用点的符号,所以要写 `from deerflow.domain.feedback.ports import FeedbackRepository`——多打几个字,换来"谁在实现契约"这件事在 import 里就看得见。 -## 3. 内圈全景 +## 3. `Feedback`:规则本体 -``` -domain/feedback/ -├── __init__.py 上下文的公开 API:领域对象 + 服务(端口刻意不在这里导出) -├── model.py Feedback(聚合根)· 5 个领域错误 · 2 个白名单常量 -├── ports.py 2 个 Protocol:FeedbackRepository · RunLookup -└── service.py FeedbackService —— 用例编排(input port) -``` +一个 frozen dataclass。 -`model.py` 是**单文件**而非目录,因为这里只有一个聚合、没有值对象、没有状态机——对比 schedule 的 `model/` 包(两个聚合 + 两个状态机 + 值对象)。模块规模决定形态,不必强行对称。 - -三层纪律: - -| 文件 | 职责 | 纪律 | -|---|---|---| -| `model.py` | 业务事实与不变量 | 零依赖(仅标准库);不知道存储和 HTTP 存在;不变量在构造期校验 | -| `ports.py` | 领域声明的接口 | 技术中立——签名里不出现 SQL、表名、HTTP 状态码 | -| `service.py` | 用例编排 | 内圈唯一调用 output port 的地方;`user_id` 显式传参;自身不含业务规则 | - -`__init__.py` 的导出策略值得注意:**它导出领域对象和服务,但不导出端口**。端口是给适配器和测试用的契约,不是日常调用点的符号,所以要写 `from deerflow.domain.feedback.ports import FeedbackRepository`——多打几个字,换来"谁在实现契约"这件事在 import 里就看得见。 - -CI 的 `tests/test_harness_domain_purity.py` 会 AST 扫描整个 `domain/`,禁止 import sqlalchemy / fastapi / pydantic / app / harness 基础设施模块。 - -## 4. `Feedback`:规则本体 - -`model.py`,93 行,一个 frozen dataclass 加五个错误类。 - -### 4.1 字段与身份 +### 3.1 字段与身份 ```python @dataclass(frozen=True) @@ -80,19 +57,16 @@ class Feedback: thread_id: str # ├ 业务身份三元组 rating: int # │ (thread_id, run_id, user_id) user_id: str | None = None# ┘ - message_id: str | None = None comment: str | None = None tags: tuple[str, ...] = () created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) ``` -**两套身份,别混。** `feedback_id` 是行的代理主键;决定"是不是同一条评价"的是 `(thread_id, run_id, user_id)`。upsert 按业务身份查找、更新时**保留原有的 `feedback_id`**——`SqlFeedbackRepository.save` 和 `InMemoryFeedbackRepository.save` 都显式实现了这一点,契约测试 `test_save_updates_keeping_identity` 逐字段盯着它。前端如果拿 `feedback_id` 做 key,重新评价后 key 不变。 +**两套身份,别混。** `feedback_id` 是行的代理主键;决定"是不是同一条评价"的是 `(thread_id, run_id, user_id)`。upsert 按业务身份查找、更新时**保留原有的 `feedback_id`**——这条契约由适配器的 `_apply` 设计承载(代理主键不在其中,见 §7.1),契约测试 `test_save_updates_keeping_identity` 逐字段盯着它。前端如果拿 `feedback_id` 做 key,重新评价后 key 不变。 -`user_id` 可以是 `None`——那是免鉴权模式(`database.backend` 无认证部署)。所有读方法的 `user_id` 参数因此都是 `str | None`,`None` 表示不按所有者过滤。 +`user_id` 可以是 `None`——那是免鉴权模式(`database.backend` 无认证部署)。读方法的 `None` 表示不按所有者过滤;**删除路径的 `None` 语义不同**,见 §5.2。 -`message_id` 把评价收窄到 run 里的某一条消息而不是整个 run,可选。 - -### 4.2 创建即一致 +### 3.2 创建即一致 ```python def __post_init__(self): @@ -103,11 +77,13 @@ def __post_init__(self): raise InvalidTagError(...) ``` -校验在 `__post_init__`,不在工厂里,所以**绕不过去**:`Feedback.create(...)` 和 `Feedback(...)` 直接构造走同一条路(`test_direct_construction_also_validates` 盯着这条)。适配器从数据库读出来重建对象时也会过这一遍——一行手工改坏的数据在读取时就会炸,而不是流到前端。 +校验在 `__post_init__`,不在工厂里,所以**绕不过去**:`Feedback.create(...)` 和直接构造走同一条路(`test_direct_construction_also_validates` 盯着这条)。适配器从数据库读出来重建对象时也会过这一遍——一行手工改坏的数据在读取时就会炸,而不是流到前端。 -推论一条:**非法输入永远在任何 IO 之前报错**。`FeedbackService.rate_run` 先构造聚合再查 run(见 §6.2),所以一个非法 rating 配一个不存在的 run,报的是 `InvalidRatingError` 而不是 `RunNotFoundError`。 +推论一条:**非法输入永远在任何 IO 之前报错**。handler 先构造聚合再查 run(见 §6.2),所以一个非法 rating 配一个不存在的 run,报的是 `InvalidRatingError` 而不是 `RunNotFoundError`。 -### 4.3 tags 是语言中立的 slug +`Feedback.create` 的参数是散装关键字参数——这是规范 §2.1 变形 ② 的规定形态(工厂参数 = 聚合字段减代理主键,永不收 command);feedback 的六个参数彼此独立、无可聚类的领域概念,所以没有值对象。 + +### 3.3 tags 是语言中立的 slug ```python VALID_FEEDBACK_TAGS = frozenset({ @@ -115,28 +91,35 @@ VALID_FEEDBACK_TAGS = frozenset({ }) ``` -白名单在领域里,翻译在前端。存储和分析只见 slug,所以中文界面和英文界面提交的"回答不对"能聚合到一起。 +白名单在领域里,翻译在前端。存储和分析只见 slug,所以中文界面和英文界面提交的"回答不对"能聚合到一起。加标签见 §10.2。 -加标签要动这个 frozenset,见 §10.2。 +### 3.4 时间戳的真相源 -### 4.4 `frozen=True` 意味着什么 +`created_at` 是**记账戳**,由聚合构造时刻确定(`default_factory` 读时钟),upsert 更新时随新聚合刷新——语义是"当前这份看法是什么时候给出的"。它**不参与任何业务规则**(没有"超过 N 天的评价失效"这类逻辑),所以允许 `default_factory` 读时钟;一旦时间成为**规则输入**,必须改为显式 `now=` 传参(规范 §4 的时钟规则管的是后者,schedule 的 `next_after(now)` 是对照实例)。存储侧:`_apply` 把聚合的 `created_at` 写入行,读回经 `_tz_aware` 补时区——聚合构造时刻是唯一真相源,适配器不打自己的时间戳。 -聚合不可变。想改评价就构造一个新的(`dataclasses.replace` 或 `Feedback.create`),没有 setter,没有"半更新"的中间态。`test_frozen` 盯着这条。 +### 3.5 `frozen=True` 意味着什么 -代价是仓储的 `save` 只能是整体替换语义,不能是字段级 patch——这正是端口签名 `save(feedback: Feedback)` 的原因。 +聚合不可变。想改评价就构造一个新的,没有 setter,没有"半更新"的中间态(`test_frozen` 盯着)。代价是仓储的 `save` 只能是整体替换语义——这正是端口签名 `save(feedback)` 的原因。 -### 4.5 一处与 schedule 的分歧:这里读了时钟 +## 4. Commands:写用例的具名载体 -`created_at` 的默认值是 `datetime.now(UTC)`——**领域在构造期读了系统时钟**。schedule 模块刻意避免这件事(`now` 一律由调用方显式传参,领域从不读时钟,测试天然确定)。 +`commands.py` 是规范 §3.1"写用例一律 command 化"的落地: -两个切片在这一点上不一致,如实记录:feedback 的时间只是一个记账戳,不参与任何规则判断(没有"超过 N 天的评价失效"这类逻辑),所以读时钟的代价只是测试里断言时间需要一点宽容(`test_created_at_is_tz_aware` 只断言"带时区"而非具体值)。schedule 的时间是**规则输入**(下次触发、租约过期),那里必须显式传参。 +| Command | Handler | 请求模型 | +|---|---|---| +| `RateRun` | `FeedbackService.rate_run` | `RateRunRequest` | +| `RetractRunRating` | `FeedbackService.retract_run_rating` | 无 body,端点内直接构造 | -如果哪天 feedback 出现依赖时间的规则,应当同时把 `created_at` 改成显式传参。 +命名链三种拼写互为变体(规范 §2.1),grep 任何一个就能找到用例全部三层。 + +**command 是哑数据**:不做业务校验——rating 合法性归聚合 `__post_init__`,结构校验归 router 的 api model。所以"非法 rating 先于未知 run 报错"的归因顺序由 handler 的构造顺序拥有,`test_command_is_dumb_data` 钉这条。 + +**查询刻意不 command 化**:`latest_per_run_in_thread` / `latest_for_runs` 收普通参数——command 表达改变状态的意图,包装读操作是纯样板。 + +**身份不进请求模型**:`RateRunRequest` 上没有也不许有 `user_id` 字段,它由服务端解析后经 `to_command(thread_id, run_id, user_id)` 注入(规范 §2.1 变形 ①)。 ## 5. 端口:领域对外的两个依赖 -`ports.py` 声明领域需要外界做什么,由外圈实现。 - | 端口 | 回答什么问题 | 方法数 | |---|---|---| | `FeedbackRepository` | 评价存在哪、怎么按所有者过滤、并发 upsert 冲突怎么表达 | 4 | @@ -144,48 +127,45 @@ VALID_FEEDBACK_TAGS = frozenset({ ### 5.1 两条约定,比签名更重要 -**① 所有者过滤是参数,不是异常。** 每个读方法都收 `user_id: str | None`:非 `None` 时限制到该用户的条目,`None` 表示不过滤(免鉴权模式)。别人的评价表现为"查不到",而不是抛权限错误。 +**① 读路径的所有者过滤是参数,不是异常。** 读方法收 `user_id: str | None`:非 `None` 时限制到该用户的条目,`None` 表示不过滤(免鉴权模式)。别人的评价表现为"查不到",而不是抛权限错误。 **② 并发 upsert 的败者必须翻译成领域错误。** 两个并发请求可以都没查到、都去插入,输的那个撞上唯一约束。适配器负责把 `IntegrityError` 翻译成 `DuplicateFeedbackError`,router 再映射成 HTTP 409 让客户端重试。旧代码在这里泄漏了驱动异常,结果是 500。 -### 5.2 两个刻意的缺席 +### 5.2 删除路径的 `None` 是等值匹配,不是"不过滤" -**没有 `find_one` / `get_by_id`。** 读路径只有批量方法,因为唯一的消费者是消息列表回显——它要的是"这一页所有 run 的当前评价",不是"某一条评价"。加一个单条读方法就要有人来回答"谁在用它"。 +`remove_for_run` 与读方法**刻意不对称**:所有权是**等值匹配**——非 `None` 只删该用户的条目,`None` 只匹配 NULL 所有者的条目(免鉴权模式写下的),**不是**"无视所有者删除"。删除绝不能跨所有者,端口 docstring 与契约用例 `test_remove_for_run_none_user_matches_only_null_owner`(两实现各跑一遍)共同钉死这条语义。 -**`RunLookup` 里没有 `user_id`。** 它只回答纯事实"run 属于哪个 thread",不掺授权。授权是两环链条,见 §6.2。 +### 5.3 两个刻意的缺席 -### 5.3 为什么 `RunLookup` 只有一个方法 +**没有 `find_one` / `get_by_id`。** 读路径只有批量方法,因为唯一的消费者是消息列表回显——它要的是"这一页所有 run 的当前评价"。加单条读方法就要有人回答"谁在用它"。 -feedback 需要知道的关于 run 的全部事情,就是"它属于哪个 thread"。而 run 上下文的仓储(`RunStore`)有 26 个方法。 - -依赖那一个方法而不是那 26 个,是接口隔离原则的教科书用法,收益是具体的:`domain/feedback/` 整个包里没有一行提到 `RunStore`、`runtime`、`persistence`。run 上下文换存储实现,feedback 领域一个字不用改。 - -代价是外圈要写一层防腐层,见 §7.2。 +**`RunLookup` 里没有 `user_id`。** 它只回答纯事实"run 属于哪个 thread",不掺授权——授权是两环链条,见 §6.2。feedback 需要知道的关于 run 的全部事情就是这一问,而 run 上下文的仓储有 26 个方法;依赖一个方法而不是 26 个,收益是 `domain/feedback/` 整个包里没有一行提到 `RunStore`。代价是外圈要写一层防腐层,见 §7.2。 ## 6. 应用服务:用例编排 -`service.py`,75 行。构造函数收两个端口,之后只见 Protocol 类型。 +构造函数收两个端口,之后只见 Protocol 类型。 ### 6.1 用例清单 -| 方法 | 支撑 | 领域错误 | -|---|---|---| -| `rate_run(...)` | `PUT /feedback` | `InvalidRatingError` / `InvalidTagError` / `RunNotFoundError` / `DuplicateFeedbackError` | -| `retract_run_rating(...)` | `DELETE /feedback` | — | -| `latest_per_run_in_thread(...)` | 消息列表全量路径 | — | -| `latest_for_runs(...)` | 消息列表分页路径 | — | +| 方法 | 输入 | 支撑 | 领域错误 | +|---|---|---|---| +| `rate_run(cmd)` | `RateRun` | `PUT /feedback` | `InvalidRatingError` / `InvalidTagError` / `RunNotFoundError` / `DuplicateFeedbackError` | +| `retract_run_rating(cmd)` | `RetractRunRating` | `DELETE /feedback` | — | +| `latest_per_run_in_thread(...)` | 普通参数 | 消息列表全量路径 | — | +| `latest_for_runs(...)` | 普通参数 | 消息列表分页路径 | — | -服务自身**不含业务规则**:rating 和 tags 的合法性归聚合,所有者过滤归仓储,它只负责"按什么顺序调用谁"。 +服务自身**不含业务规则**:rating 和 tags 的合法性归聚合,所有者匹配归仓储,它只负责"按什么顺序调用谁"。 ### 6.2 `rate_run`:顺序是设计,不是随意 ```python -feedback = Feedback.create(...) # ① 先构造:规则校验在此 -await self._require_run(thread_id, run_id) # ② 再查 run:引用完整性 -return await self._repository.save(feedback) # ③ 最后落库 +feedback = Feedback.create( # ① 先构造:规则校验在此 + run_id=cmd.run_id, thread_id=cmd.thread_id, rating=cmd.rating, ...) +await self._require_run(cmd.thread_id, cmd.run_id) # ② 再查 run:引用完整性 +return await self._repository.save(feedback) # ③ 最后落库 ``` -**① 在 ② 之前**,所以错误归因不受 IO 结果影响——非法 rating 配不存在的 run,报 `InvalidRatingError`。`test_invalid_rating_rejected_before_run_lookup` 就是钉这个顺序的。 +**① 在 ② 之前**,所以错误归因不受 IO 结果影响。`test_invalid_rating_rejected_before_run_lookup` 钉这个顺序。 **② 是引用完整性,不是授权。** 授权是两环链条: @@ -195,38 +175,32 @@ service 的 _require_run -> run 属于该 thread 两者合起来 -> 你拥有这个 run ``` -这解释了为什么 `RunLookup.thread_of` 签名里没有 `user_id`:第一环已经证明了 thread 归属,第二环只需回答纯事实。少了任何一环都不成立——单独的 `_require_run` 拦不住"用自己的 thread 配别人的 run"以外的攻击面,单独的 `owner_check` 拦不住跨 thread 的 run id。 +这解释了为什么 `RunLookup.thread_of` 签名里没有 `user_id`:第一环已经证明了 thread 归属,第二环只需回答纯事实。少了任何一环都不成立。 ### 6.3 读路径为什么有两个方法 -`latest_per_run_in_thread` 拉整个 thread,`latest_for_runs` 只拉指定的一批 run。后者是给消息列表分页端点用的——一页只需要这一页那几个 run 的角标,拉整个 thread 在长会话里是浪费。 - -两者都是**批量**方法,返回 `dict[run_id, Feedback]`,一次查询覆盖一页。前端按钮的高亮状态由此而来,没有独立的读端点。 +`latest_per_run_in_thread` 拉整个 thread,`latest_for_runs` 只拉指定的一批 run——后者给消息列表分页端点用,一页只需要这一页那几个 run 的角标。两者都是**批量**方法,返回 `dict[run_id, Feedback]`,一次查询覆盖一页(防 N+1,规范 §5.2 读链路的实例)。 ### 6.4 `retract` 为什么不查 run -写路径校验引用完整性,删路径不校验,这个不对称是有意的:删一条不存在的评价本来就返回 `False`(router 转成 404),多一次 `RunLookup` 调用不会改变任何结果,只是多一次 IO。 +写路径校验引用完整性,删路径不校验,这个不对称是有意的:删一条不存在的评价本来就返回 `False`(router 转成 404),多一次 `RunLookup` 调用不改变任何结果,只是多一次 IO。 -## 7. 适配器:两种形态 +## 7. 适配器与组合根 -两个从适配器住在 `app/adapters/feedback/`,一个端口一个文件。它们性质不同——判据和命名惯例见总纲 §5,这里讲各自的实现要点。 +两个从适配器住在 `app/adapters/feedback/`,一个端口一个文件;两形态判据见规范 §2。 ### 7.1 `feedback_repository.py`:自有持久化 `SqlFeedbackRepository` 只做两种翻译: -1. **领域对象 ↔ ORM 行**:`_to_domain` / `_to_row` 两个静态方法。`_to_row` 显式列出每个字段而不是 `**asdict()`——新加的列在被刻意映射之前保持私有。 +1. **领域对象 ↔ ORM 行**:`_to_domain(row)` / `_apply(row, feedback)`(规范 §2.1 变形 ③r/③w)。`_apply` 就地全字段赋值,**一份字段清单同时服务 insert 与 update**——新字段不可能"插入有值、更新静默丢失";代理主键 `feedback_id` 刻意不在其中(insert 构造行时定死,upsert 因此天然保留既有身份)。显式列字段而非 `**asdict()`:新列在被刻意映射前不进出数据库。 2. **技术异常 → 领域错误**:`IntegrityError → DuplicateFeedbackError`。 -外加一件跨数据库的脏活:`_tz_aware()`。SQLite 读回来的 `datetime` 丢了 tzinfo,而存进去的一律是 UTC,所以读路径统一补回 `UTC`。这是 `_to_domain` 存在的第二个理由——**读取归一化只发生在这一个地方**。`tests/test_persistence_timezone.py` 盯着它。 +外加一件跨数据库的脏活:`_tz_aware()`。SQLite 读回来的 `datetime` 丢了 tzinfo,而存进去的一律是 UTC,读路径统一补回——**读取归一化只发生在 `_to_domain` 这一个地方**,`tests/test_persistence_timezone.py` 盯着它。 session 生命周期 = 一个端口方法(每方法一个短事务)。没有跨方法的事务,也没有 unit-of-work——feedback 的用例都是单聚合操作,不需要。 -一处继承的微妙之处,这个类的 docstring 自己写着: - -> Explicit inheritance is a readability aid only: a missing method would still instantiate fine (Protocol bodies are inherited), so the contract test suite must cover every port method. - -`class SqlFeedbackRepository(FeedbackRepository)` 里的显式继承只是可读性提示。Protocol 的方法体是 `...`,继承过来就是"返回 `None` 的空实现",所以**少写或拼错一个方法不会报错,只会静默返回 `None`**。这不是理论风险,见 §11.2。 +类 docstring 自带一条继承警示(规范 §4 也有):显式继承 Protocol 只是可读性提示,拼错方法名不会报错、只会静默返回 `None`——契约套件必须覆盖每个端口方法并断言返回值,实证见 §11.1。 ### 7.2 `run_lookup.py`:防腐层 @@ -238,41 +212,25 @@ async def thread_of(self, run_id: str) -> str | None: return run.get("thread_id") if run else None ``` -把 26 个方法的 `RunStore` 收窄成 1 个问题,顺带把 `RunStore.get()` 返回的 `dict[str, Any]` 挡在领域外面。 - -`RunStore` 的类型注解走 `TYPE_CHECKING`,运行时零 import 开销——这个模块被组合根延迟 import,没必要在导入时拉起 `deerflow.runtime` 包。 - -文件里的 `TODO(hexagonal)` 写的是**触发条件**而不是抱怨: - -``` -TODO(hexagonal): this depends on ``RunStore``, an infrastructure -component, rather than on a contract published by the run context -- -that context has not been through a hexagonal slice yet. When it -publishes one (a DTO, not its aggregate and not its repository), -replace the body of this class. The ``RunLookup`` port does not move. -``` - -最后一句是这层设计买到的东西:上游重构时改动范围是一个文件,端口和领域一行不动。 - -**为什么这是正常形态而不是欠债**:feedback 是下游(Customer),run 是上游(Supplier)。上游没有发布正式契约之前,下游为它写一层防腐层是 DDD 的标准答案。真正需要警惕的是这层**没有被测试**,见 §11.1。 +把 26 个方法的 `RunStore` 收窄成 1 个问题,顺带把 `RunStore.get()` 返回的 `dict[str, Any]` 挡在领域外面。文件里的 `TODO(hexagonal)` 写的是触发条件(run 上下文发布 DTO 契约时替换类体,端口不动)——防腐层是上下游关系的正常形态,不是欠债。真正需要警惕的是这层**没有对真实 `RunStore` 的契约测试**,见 §11.2。 ### 7.3 组合根 -`app/gateway/deps.py::langgraph_runtime` 是适配器唯一的实例化点: +`app/composition.py::build_domain_services()` 是适配器唯一的实例化点——纯函数,收已建好的基础设施、返回装配结果,由 `deps.py::langgraph_runtime` 在启动时调用: ```python -app.state.feedback_service = FeedbackService( - repository=SqlFeedbackRepository(sf), - runs=RunStoreRunLookup(app.state.run_store), +return DomainServices( + feedback=FeedbackService( + repository=SqlFeedbackRepository(session_factory), + runs=RunStoreRunLookup(run_store), + ), ) ``` -`sf is None`(memory 后端)时 `feedback_service = None`,router 的依赖返回 503。这条规则目前只有一行注释在守——总纲 §7 待办 2 就是把这段装配抽成可单测的纯函数。 +`session_factory is None`(memory 后端)时服务为 `None`,router 的依赖返回 503——装配抽成纯函数正是为了让这条规则可被 `tests/test_composition.py` 断言,而不是靠 lifespan 里的一行注释。 ## 8. 一次点踩的旅程 -以 `PUT /api/threads/{tid}/runs/{rid}/feedback` 为主线,每一步都可以打开对应文件核对: - ```mermaid sequenceDiagram participant U as 浏览器 @@ -286,25 +244,18 @@ sequenceDiagram U->>R: PUT .../feedback (rating=-1) R->>R: @require_permission 校验 thread 归属 R->>R: get_current_user 解析用户 - R->>S: rate_run(tid, rid, rating=-1, ..., user_id) + R->>R: body.to_command(tid, rid, user_id) — 构造 RateRun + R->>S: rate_run(RateRun) S->>A: Feedback.create(...) — 构造期校验 rating/tags S->>L: (经 RunLookup) thread_of(rid) — 引用完整性 S->>I: (经 FeedbackRepository) save(feedback) - I->>DB: 短事务:按业务身份查 → 更新或插入 → commit + I->>DB: 短事务:按业务身份查 → _apply 到既有行或新行 → commit I-->>S: Feedback 领域对象 S-->>R: Feedback - R-->>U: 200(领域错误在此译为 400/404/409) + R-->>U: 200 FeedbackResponse.from_domain(领域错误在此译为 400/404/409) ``` -逐层职责: - -1. **Router**(`app/gateway/routers/feedback.py`)只做协议转换:解析请求与当前用户 → 调 service → 把领域错误映射为 HTTP 码(`InvalidRatingError→400`、`InvalidTagError→400`、`RunNotFoundError→404`、`DuplicateFeedbackError→409`)。不含任何业务判断。 -2. **Service**(`service.py`)编排用例,顺序见 §6.2。 -3. **聚合**(`model.py`)持有全部规则。回答"Feedback 的合法状态是什么",读这一个文件即可。 -4. **Adapters**(`app/adapters/feedback/`)只翻译不编排,见 §7。 -5. **组合根**(`deps.py`)决定谁实现哪个端口,见 §7.3。 - -**回显不走独立端点**:消息列表接口(`thread_runs.py`)经 service 的 `latest_per_run_in_thread` / `latest_for_runs` 批量取每个 run 的当前评价,嵌进消息数据返回——前端按钮高亮由此而来,与主流 Chat 产品一致。 +逐层职责:Router 只做协议转换(构造 command、渲染白名单响应、映射错误码);Service 编排(§6.2 的顺序);聚合持有全部规则;适配器只翻译不编排;组合根决定谁实现哪个端口。**回显不走独立端点**:消息列表接口经 `latest_per_run_in_thread` / `latest_for_runs` 批量取每个 run 的当前评价嵌进消息数据——前端按钮高亮由此而来。 ## 9. 测试分层 @@ -313,17 +264,15 @@ sequenceDiagram | 测试文件 | 层 | 规模 | 红了说明 | |---|---|---|---| | `test_feedback_domain.py` | 聚合 | 9 | 业务规则错 | -| `test_feedback_service.py` | 用例编排(双 fake 端口) | 9 | 编排顺序或错误映射错 | -| `test_feedback.py::FeedbackRepositoryContract` | 端口契约 × 2 实现 | 11 × 2 | 存储实现与端口语义漂移 | +| `test_feedback_service.py` | 用例编排(双 fake 端口) | 10 | 编排顺序或错误映射错 | +| `test_feedback.py::FeedbackRepositoryContract` | 端口契约 × 2 实现 | 12 × 2 | 存储实现与端口语义漂移 | +| `test_composition.py` | 组合根 | 2 | 装配规则错(memory → None) | | `test_persistence_timezone.py` | 适配器细节 | 1 | SQLite 时区归一化坏了 | -| `test_owner_isolation.py` | 跨用户隔离 | 2 | 所有者过滤坏了 | | `test_thread_messages_feedback.py` | 回显集成 | — | 消息列表拼装坏了 | -**契约测试的形状**值得学:`FeedbackRepositoryContract` 是一个不带 `Test` 前缀的基类(pytest 不会直接收集它),`TestSqlFeedbackRepository` 和 `TestInMemoryFeedbackRepository` 各自继承它并提供 `repo` fixture。一套语义用例,两套实现各跑一遍。 +**契约测试的形状**值得学:`FeedbackRepositoryContract` 是一个不带 `Test` 前缀的基类(pytest 不直接收集),`TestSqlFeedbackRepository` 和 `TestInMemoryFeedbackRepository` 各自继承并提供 `repo` fixture——一套语义用例,两套实现各跑一遍。**零 IO 的 fake 住在 `tests/feedback_fakes.py`**,不在测试文件里——契约套件和服务测试都能当普通模块 import。 -**零 IO 的 fake 住在 `tests/feedback_fakes.py`**,不在测试文件里——这样契约套件和服务测试都能当普通模块 import,不依赖 tests 目录恰好在 `sys.path` 上。 - -`test_feedback_service.py` 是总纲 §2「唯一检验」的活样本:整个用例在两个 dict fake 上端到端跑通,零 IO、零 HTTP。 +`test_feedback_service.py` 是规范 §1「唯一检验」的活样本:整个用例在两个 dict fake 上端到端跑通,零 IO、零 HTTP。 ## 10. 二次开发指引 @@ -333,16 +282,17 @@ sequenceDiagram 1. `model.py` — `Feedback` 加字段;如果有合法值约束,在 `__post_init__` 里加校验 2. `test_feedback_domain.py` — 先写红的测试(TDD 是这个仓库的硬要求) -3. ORM 层 — `deerflow/persistence/feedback/model.py` 加列,并在 `migrations/versions/` 加一个 alembic revision(**每个 ORM 变更都必须有 revision**,见 backend/AGENTS.md) -4. `feedback_repository.py` — `_to_domain` / `_to_row` 各加一行(这是"显式列字段"的代价,也是它的价值) -5. `tests/feedback_fakes.py` — fake 通常不用改(它存整个聚合) -6. `routers/feedback.py` — 要不要进请求/响应模型?**默认不进**,除非前端真的需要 +3. `commands.py` — 用户要能提交它才加(`RateRun` 加字段,router 的 `to_command` 跟着传);服务端派生的字段不进 command +4. ORM 层 — `deerflow/persistence/feedback/model.py` 加列,并在 `migrations/versions/` 加一个 alembic revision(**每个 ORM 变更都必须有 revision**,见 backend/AGENTS.md) +5. `feedback_repository.py` — `_to_domain` / `_apply` 各加一行(`_apply` 的一份清单覆盖 insert 与 update,不存在第三处要同步的写路径) +6. `tests/feedback_fakes.py` — fake 通常不用改(它存整个聚合) +7. `routers/feedback.py` — 要不要进请求/响应模型?**默认不进**,除非前端真的需要 端口签名不用动——`save(feedback)` 传的是整个聚合。 ### 10.2 加一个原因标签 -改 `model.py` 的 `VALID_FEEDBACK_TAGS`,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——`InvalidTagError` 会拦下来(这是有意的:白名单在领域侧才能保证分析口径统一)。 +改 `model.py` 的 `VALID_FEEDBACK_TAGS`,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——`InvalidTagError` 会拦下来(有意的:白名单在领域侧才能保证分析口径统一)。 ### 10.3 加一个用例 @@ -350,10 +300,10 @@ sequenceDiagram 1. 端口够用吗?`latest_per_run_in_thread` 可能已经够。够就不要加端口。 2. 不够则先在 `ports.py` 加方法并写清语义(docstring 是契约测试的依据) -3. `FeedbackRepositoryContract` 加用例——**两套实现都必须跑通** +3. `FeedbackRepositoryContract` 加用例——**两套实现都必须跑通,并断言返回值** 4. 两个实现各自实现 -5. `service.py` 加编排方法,`test_feedback_service.py` 用 fake 测 -6. router 加端点,只做协议转换 +5. 写用例则在 `commands.py` 加 command(命名链三拼写对齐);`service.py` 加方法,`test_feedback_service.py` 用 fake 测 +6. router 加端点,只做协议转换(有 body 则请求模型加 `to_command`) ### 10.4 换存储 @@ -361,58 +311,53 @@ sequenceDiagram ### 10.5 不要做的事 -- **不要在 router 里写业务判断。** rating 合法性、tags 白名单、引用完整性都有归属,router 只翻译协议。 -- **不要让端口签名出现 SQL / 表名 / HTTP 状态码。** 纯度测试拦 import,但拦不住命名——`save_row` 这种名字要靠 review。 -- **不要在领域里 import 适配器。** 想不到理由要这么做,但如果你觉得需要,说明用例编排放错层了。 -- **不要给 `RunLookup` 加方法来"顺便"拿 run 的别的信息。** 那是 run 上下文的数据,需要更多就说明该等它发布契约,或者你的用例应该住在别的上下文里。 -- **不要用 `**asdict()` 简化 `_to_row`。** 显式字段列表是一道闸门:新列在被刻意映射之前不会悄悄进出数据库。 +规范 §4 的规则清单全部适用;feedback 语境下最常犯的四条: + +- **不要在 router 里写业务判断。** rating 合法性、tags 白名单、引用完整性都有归属。 +- **不要给 `RunLookup` 加方法来"顺便"拿 run 的别的信息。** 需要更多就说明该等 run 上下文发布契约,或你的用例应该住在别的上下文。 +- **不要用 `**asdict()` 简化 `_apply` 或 command 解包。** 显式字段列表是闸门。 +- **不要让身份字段出现在请求模型上。** `user_id` 只能经 `to_command` 参数注入。 ## 11. 常见陷阱速查 -### 11.1 `RunLookup` 没有契约测试(已知缺口,最高优先级) +### 11.1 显式继承 Protocol 会把拼错的方法名变成静默 `None` -`FeedbackRepository` 有双实现契约套件,`RunLookup` 没有——`RunStoreRunLookup` 只在 `tests/feedback_fakes.py` 的 fake 层面被覆盖,**从未对着真实的 `RunStore` 跑过**。 +真实发生过:`latest_per_run_in_thread` 一度被写成 `latest_per_run_i_thread`(少一个 `n`)。因为适配器显式继承 Protocol,方法体 `...` 被继承,真方法退化成"返回 `None` 的空实现"——**没有 `AttributeError`**。抓到它的是契约套件里断言返回值的用例;`isinstance(repo, FeedbackRepository)` 抓不到(`runtime_checkable` 只查方法名存在,继承使名字总是存在)。所以"契约套件覆盖每个端口方法并断言返回值"不是可选项。 -失效路径很具体:`RunStore.get()` 返回 `dict[str, Any]`,适配器靠 `run.get("thread_id")` 取值。一旦那个 dict 的键改名,`thread_of` 对所有 run 返回 `None`、所有点踩变成 404,而测试全绿。 +### 11.2 `RunLookup` 没有对真实 `RunStore` 的契约测试(已知缺口,最高优先级) -防腐层的价值是把不受控的外部形状挡在门外,门本身没被测过,挡不挡得住是运气。 - -### 11.2 显式继承 Protocol 会把拼错的方法名变成静默 `None` - -真实发生过:`latest_per_run_in_thread` 一度被写成 `latest_per_run_i_thread`(少一个 `n`)。因为 `SqlFeedbackRepository` 显式继承 `FeedbackRepository`,Protocol 的方法体是 `...`,真方法退化成"返回 `None` 的空实现"——**没有 `AttributeError`**,只是所有调用返回 `None`。 - -抓到它的是契约套件里那些断言返回值的用例(`assert await repo.latest_per_run_in_thread(...) == {}` 得到 `None`)。 - -**注意 `test_satisfies_port` 抓不到这类错误**:`isinstance(repo, FeedbackRepository)` 对 `runtime_checkable` Protocol 只检查方法名存在,而继承使得名字总是存在。所以那条断言证明的是"没有忘记声明",不是"实现是对的"——**契约套件必须覆盖每个端口方法并断言返回值**,这不是可选项。 +`RunStoreRunLookup` 只在 fake 层面被覆盖。失效路径很具体:`RunStore.get()` 返回 `dict[str, Any]`,适配器靠 `run.get("thread_id")` 取值——键改名则 `thread_of` 对所有 run 返回 `None`、所有点踩变 404,而测试全绿。防腐层的价值是把不受控的外部形状挡在门外,门本身没被测过,挡不挡得住是运气。 ### 11.3 upsert 更新时不要换 `feedback_id` -按业务身份三元组查到已有行时,保留原 `feedback_id`。前端可能用它做列表 key,换掉会导致重新评价后组件重挂载。 +按业务身份三元组查到已有行时保留原 `feedback_id`——`_apply` 不含代理主键正是为此。前端可能用它做列表 key,换掉会导致组件重挂载。 ### 11.4 SQLite 读回的时间没有 tzinfo 一律经 `_to_domain` 的 `_tz_aware()` 补 UTC。绕过 `_to_domain` 自己组装 `Feedback` 就会漏掉这一步。 -### 11.5 免鉴权模式下 `user_id` 是 `None` +### 11.5 `user_id=None` 在读和删两侧语义不同 -它是合法值,不是"忘记传"。所有读方法的 `user_id: str | None` 里,`None` 的语义是"不按所有者过滤",不是"过滤 user_id 为 null 的行"。写路径的 `remove_for_run` 是例外——它用 `user_id` 做等值匹配,所以 `None` 只删 `user_id IS NULL` 的行。 +读路径 `None` = 不按所有者过滤;删除路径 `None` = **只匹配 NULL 所有者的条目**(等值匹配,见 §5.2)。混淆两者的后果是免鉴权与鉴权混合部署时删错行——契约用例已钉死正确语义。 ### 11.6 并发 upsert 的 409 不是 bug -两个并发请求都没查到、都插入,输的那个撞唯一约束 → `DuplicateFeedbackError` → HTTP 409。客户端重试即可。旧代码这里泄漏 `IntegrityError`,返回的是 500。 +两个并发请求都没查到、都插入,输的那个撞唯一约束 → `DuplicateFeedbackError` → HTTP 409,客户端重试即可。旧代码这里泄漏 `IntegrityError`,返回的是 500。 ## 12. 代码索引 | 关注点 | 文件 | |---|---| -| 聚合、不变量、领域错误 | `packages/harness/deerflow/domain/feedback/model.py` | +| 聚合、不变量、白名单常量 | `packages/harness/deerflow/domain/feedback/model.py` | +| 领域错误(一族一基类) | `packages/harness/deerflow/domain/feedback/exceptions.py` | +| 命令(写用例的输入载体) | `packages/harness/deerflow/domain/feedback/commands.py` | | 端口契约(语义写在 docstring 里) | `packages/harness/deerflow/domain/feedback/ports.py` | -| 用例编排 | `packages/harness/deerflow/domain/feedback/service.py` | +| 用例编排(写方法即 command handler) | `packages/harness/deerflow/domain/feedback/service.py` | | 上下文公开 API | `packages/harness/deerflow/domain/feedback/__init__.py` | -| SQL 适配器 | `backend/app/adapters/feedback/feedback_repository.py` | +| SQL 适配器(`_to_domain` / `_apply`) | `backend/app/adapters/feedback/feedback_repository.py` | | 防腐层适配器 | `backend/app/adapters/feedback/run_lookup.py` | -| HTTP 入口 + 错误映射 | `backend/app/gateway/routers/feedback.py` | -| 组合根 | `backend/app/gateway/deps.py::langgraph_runtime` | -| ORM 行 + 迁移 | `packages/harness/deerflow/persistence/feedback/model.py`、`persistence/migrations/versions/` | +| HTTP 入口 + api model + 错误映射 | `backend/app/gateway/routers/feedback.py` | +| 组合根 | `backend/app/composition.py::build_domain_services` | +| ORM 行 + 迁移 | `packages/harness/deerflow/persistence/feedback/model.py`、`migrations/versions/0011_feedback_drop_message_id.py` | | 零 IO fake | `backend/tests/feedback_fakes.py` | | 测试分层 | 见 §9 | diff --git a/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md b/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md index 4455395a0..0d1700cd3 100644 --- a/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md +++ b/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md @@ -1,207 +1,294 @@ -# DeerFlow 后端架构:六边形设计 +# DeerFlow 后端架构:六边形设计规范 -> 面向想理解 DeerFlow 后端架构的学习者。读完你将能回答:任何一段后端代码,为什么在它所在的位置。 +> 本文是**规范**:新业务模块必须按此结构落地,代码与规范冲突时以规范为准。全文只讲标准形态,不绑定具体模块——规则落到真实代码上的样子,见参考实现的模块文档:[FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) 与 [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)。编码规约见 `backend/AGENTS.md`。 > -> **本文只讲规则**,不讲某个模块的具体样子。想看规则落到真实代码上、想动手改一个模块,读对应的模块文档: -> -> - [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) —— 用户反馈模块,首个完成的切片,最简单的样板 -> - [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md) —— 定时任务模块,两个聚合 + 两个状态机 + 四道并发防线(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**) -> -> 编码规约见 `backend/AGENTS.md`。 +> 引用标记 [C] / [G] / [B] / [P] 的出处见 §7。 --- -## 1. 两个正交的边界 +## 1. 六边形架构是什么 -DeerFlow 后端由两个互相垂直的边界切分,理解它们是理解一切的前提。 +真名 **Ports & Adapters**(Cockburn, 2005 [C])。"六边形"只是画图的偶然。它的意图,原文一句话说尽: -**边界一:harness / app —— 回答"哪些代码可复用发布"。** +> "Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases." [C] +> +> (允许应用被用户、程序、自动化测试或批处理脚本**平等地**驱动,并且能在与最终运行期设备和数据库**隔离**的条件下开发与测试。) -- `packages/harness/deerflow/`(import 前缀 `deerflow.*`):可独立发布的 agent 框架包——agent 循环、工具、沙箱、MCP、技能、配置; -- `app/`(import 前缀 `app.*`):不发布的应用层——FastAPI Gateway 与 IM 渠道集成。 +![六边形架构定义图](assets/hexagonal_architecture.png) -依赖单向:app 可以 import deerflow,deerflow 永不 import app(CI 中 `tests/test_harness_boundary.py` 执法)。 +| 图中概念 | 定义 | 规范落点 | +|---|---|---| +| Domain Model · Aggregates | 聚合:一致性边界,不变量在构造期成立 | `domain//model` | +| Domain Model · Value Objects | 值对象:无身份、按值比较;解析校验过的领域概念 | 同上 | +| Domain Model · Domain Errors | 领域自己的失败词汇——入口拿它们映射协议码,而不是让领域认识 409 | `exceptions.py` | +| Application Service(input port) | 用例编排的入口面,自身不含业务规则 | `service.py` | +| Ports | **领域自己声明、自己拥有**的技术中立接口,画在六边形的边上——它是六边形的一部分 | `ports.py` | +| Driving / Primary Adapters | 驱动应用的入口。Cockburn 点名 http 应用、批处理、**自动化测试**皆是 driver——"测试与真实入口平等"是定义的一部分 | 入口 + 时钟 + 回调 + service 测试 | +| Driven / Secondary Adapters | 实现端口的外部集成:自有持久化 与 防腐层 两形态(§2) | `app/adapters//` | +| External Service / Other Context | 对一个六边形而言,**同进程的邻居上下文也是外部世界**——这是防腐层存在的原因 | 防腐层的对接对象 | -**边界二:内圈 / 外圈 —— 回答"业务规则与技术细节谁依赖谁"。** +**全图的题眼是两种箭头**:实线 "calls" 是调用方向(入口调进来,业务也经端口调出去);虚线 "implements" 是依赖方向(适配器实现领域声明的接口)。调用可以双向穿越边界,**依赖永远指向内**——两者解耦即"依赖反转"的准确含义([B]:"the domain is the core of the application and doesn't depend on any other module")。 -这是六边形架构引入的维度:**业务核心(内圈)不依赖任何基础设施(外圈);基础设施实现业务声明的接口**。两个边界叠加后的切分规则一句话: +**唯一检验**:业务逻辑能否在没有 HTTP、没有数据库的测试里(全部换成 fake)完整运行——参考实现的 service 测试套件就是这个检验的常态化:整个用例在零 IO 的 dict fake 上端到端跑通。 -> **domain(模型 + 端口 + 应用服务)归 harness;adapters + 入口 + 组合根归 app。** +## 2. 标准结构 -历史上仓库只有边界一,于是持久化实现以"框架能力"的名义沉进了 harness,业务规则以"应用胶水"的名义散进了 router——两个维度被一条边界承担。六边形重构补上的就是第二个维度。 +本仓库先有一条自己的边界:**harness(`deerflow.*`,可发布框架包)/ app(`app.*`,不发布应用层)**,依赖单向 app → harness。六边形叠加其上的切分规则一句话: -## 2. 六边形架构究竟是什么 +> **domain(模型 + 命令 + 端口 + 应用服务)归 harness;adapters + 入口 + 组合根归 app。** -真名是 **Ports & Adapters**(Cockburn, 2005)。"六边形"只是画图的偶然,六条边没有任何含义。它的全部内容是一个观念转变加一条规则: +结构直接采用 AWS Prescriptive Guidance 的三文件夹划分——"entrypoints (primary adapters), domain (domain and interfaces), and adapters (secondary adapters)" [B]——每个业务模块的规定形态: -> **应用只有"内外"之分,没有"上下"之分。** 数据库和浏览器没有本质区别——它们都是站在边界外、想与业务核心对话的外部世界。传统分层图把数据库画成业务的"地基",这是错觉;六边形把它掰起来,与 UI 摆在同一圈上,降格为业务的一个"插件"。 +``` +packages/harness/deerflow/domain// # 内圈(对应 AWS domain/ 七件套 [B]) +├── model.py 或 model/ # 聚合·值对象(model/:"entities, value objects, and domain services") +├── exceptions.py # 领域错误(exceptions/:"the known errors defined within the domain"; +│ # 与 model 平级——AWS 树中 exceptions/ 是七件套的独立成员) +├── commands.py # 命令(commands/:"command objects that define the information +│ # required to perform an operation on the domain";见 §3.1) +├── ports.py # 端口(ports/:"abstractions through which the domain communicates +│ # with databases, APIs, or other external components") +│ # ★ ports 是 domain 的子目录,不是与之平级的第三层——抽成独立层 +│ # 会让领域依赖外部包才能声明自己的需求,恰好破坏依赖倒置 +├── service.py # 应用服务(command_handlers/:"methods or classes that run commands +│ # on the domain";写方法即 handler,命名依据见 §3.1) +└── (events.py) # 领域事件——按 §3.2 的触发条件引入,默认不建 -**唯一规则**:依赖箭头永远从外指向内。核心声明接口(port),外部世界各自带转换头(adapter)来插。调用可以由内向外发起(业务调仓储),但依赖方向始终向内(仓储实现的是业务声明的接口)——调用方向与依赖方向解耦,这就是"依赖反转"的准确含义。 +backend/app/adapters// # 从适配器(AWS adapters/ [B]) +├── <端口名snake_case>.py # 一个端口一个文件;两种形态见下 +backend/app/gateway/routers// # 入口(AWS entrypoints/ [B];目录名沿用既有 routers, +│ # 等多数模块六边形化后一次性更名) +├── router.py # 协议转换 + 领域错误→HTTP 单表映射 +└── models.py # api model:"defines the interface the primary adapter requires to + # communicate with clients" [B] —— 主适配器自己的模型,不是领域 + # 对象的视图;入向 to_command、出向 from_domain,见 §2.1 +backend/app/composition.py # 组合根:适配器唯一实例化点(AWS 未定义此概念, + # 本仓库自有——纯函数,装配规则可被单测断言) +``` -**唯一检验**:业务逻辑能否在没有 HTTP、没有数据库的测试里(全部换成 fake)完整运行。能,就实现了;不能,无论目录叫什么名字都没实现。DeerFlow 的 `tests/test_feedback_service.py` 就是这个检验的活样本:整个 feedback 用例在零 IO 的 dict fake 上端到端跑通。 +**文件命名规则**: + +- **单复数**:装同类多件的文件用复数(`ports.py` / `commands.py` / `exceptions.py` / `events.py`);装单一整体概念或单个类的用单数(`model` = the domain model 这个整体,AWS 目录树与 cosmicpython 同为单数;`service.py` 里恰好一个 Application Service 类)。名从形态,不照搬 Django 的 `models.py`——那是 ORM 语境。 +- **错误类名保留 PEP 8 的 `Error` 后缀**(如 `XxxNotFoundError`),文件名叫 `exceptions.py` 对齐 AWS 概念名——两者不冲突,`requests/exceptions.py` 里放 `HTTPError` 正是先例;`Exception` 后缀是 Java/C# 惯例,与标准库和本仓库全部现存错误类相悖,禁止引入。 +- 一个上下文一个错误基类(`Error`),入口的单表映射可按基类兜底。 + +**从适配器的两种形态**,判据两问——表归本上下文所有吗?自己写 SQL 吗: + +| | 两问皆是:自有持久化 | 两问皆否:防腐层(ACL) | +|---|---|---| +| docstring 首行标记 | `Secondary adapter (owned persistence)` | `Secondary adapter (anti-corruption layer)` | +| 类名前缀 | `Sql`(我自己写 SQL) | 被包装者的名字(读作"我借道那个组件") | +| 职责 | 领域对象 ↔ ORM 行显式互译;技术异常 → 领域错误 | 把上游宽接口收窄成本上下文的一个问题 | + +技术维度不进文件名(换存储时 `sql_` 前缀就得改名,而端口没变);防腐层的 TODO 写**触发条件**而非抱怨——上游发布契约(一个 DTO,不是它的聚合或仓储)时替换类体,端口不动。**跨上下文只能拿 DTO**,永远不依赖别人的聚合或仓储。 + +**ORM 行的归属**是一条有意规则而非欠债:表定义随共享的 engine/alembic migrations 基建统一居住在 harness `persistence/` 下,**适配器是它唯一的读写方**;迁出的触发条件是共享基建的所属模块自身六边形化。 + +### 2.1 转换链:数据的四次变形 + +**线上格式(wire)**:HTTP body 中实际传输的原始 JSON——转换链的最外端形态,类型系统之外的世界(没有 tuple 只有 array、没有 datetime 只有字符串,客户端想塞什么字段都能塞)。wire 上的字节就是 API 契约本身。 + +一个写请求穿过六边形时,数据恰好变形四次(① ② ③w ④);读回程经 ③r 重建。每次变形有**唯一的 owner 和规定的方法名**——这是全文档最机械、也最值得机械执行的部分: + +| # | 变形 | Owner | 方法 | 规则 | +|---|---|---|---|---| +| ① | wire → Command | 请求模型 | `to_command(path_params..., user_id)` | body 归模型字段;路径参数与**服务端解析的身份**经参数注入——**身份字段禁止出现在请求模型上**;wire 类型 → 领域类型(如 `list` → `tuple`)在此完成;无 body 的用例在端点内直接构造 command,不造空请求模型 | +| ② | Command → 聚合 | handler(service 写方法) | 显式逐字段调聚合工厂 `Aggregate.create(...)` | 禁止 `**asdict(cmd)`——显式字段列表是闸门;禁止聚合与 command 互相 import(两者只在 service 相遇);**工厂参数 = 聚合字段(减去工厂生成的代理主键)**:标量传标量、多个参数共同表达一个领域概念时聚成值对象传入,永不收 command——聚合的构造面向多个来源(handler、适配器重建、测试),不绑定任何用例的输入形态 | +| ③w | 聚合 → ORM 行 | 自有持久化适配器 | `_apply(row, aggregate)`(就地) | **一份显式字段清单服务 insert 与 update 两条写路径**——新字段不可能"插入有值、更新静默丢失";代理主键不进 `_apply`(在 insert 构造行时定死,upsert 保留既有身份归端口契约)。例外:CAS 型写方法(§4)分字段所有权,**刻意不走**整聚合映射 | +| ③r | ORM 行 → 聚合 | 自有持久化适配器 | `_to_domain(row)` | 显式字段列表;读取归一化(如补时区)只发生在此处;重建也过 `__post_init__`(坏行读取即爆);技术异常在适配器译成领域错误 | +| ④ | 聚合 → wire | 响应模型 | `from_domain(aggregate)` classmethod | 白名单渲染,服务端字段(租约、身份内部形态…)不进 wire;禁止模块级散转换函数 | + +**命名链**:一个写用例一个名字,三种拼写互为变体——command `PascalCase` 祈使动词短语(无 `Command` 后缀,模块路径已是语境)↔ handler 方法 `snake_case` ↔ 请求模型 `Request`。grep 任何一个拼写就能找到用例的全部三层。 + +静态结构(类图,通用角色): ```mermaid -flowchart LR - subgraph app["app · 官方宿主(六边形外圈)"] - direction TB - PA["primary adapters
gateway/routers · channels · scheduler"] - SA["secondary adapters
app/adapters/feedback"] - CR["deps.py · 组合根"] +classDiagram + class UseCaseRequest { + <> + +仅 body 字段 + +to_command(path_params, user_id) Command + } + class ResourceResponse { + <> + +from_domain(aggregate)$ + } + class Command { + <> + +路径参数·user_id·payload + } + class ApplicationService { + <> + +use_case(cmd) Aggregate + +query(params) 批量 + } + class Aggregate { + <> + +create()$ + +__post_init__() 校验 + } + class OutputPort { + <> + +save(aggregate) + } + class SqlAdapter { + <> + -_apply(row, aggregate) + -_to_domain(row) + } + class OrmRow + + UseCaseRequest ..> Command : ① to_command + ApplicationService ..> Command : 消费 + ApplicationService ..> Aggregate : ② create + ApplicationService --> OutputPort : 调用 + SqlAdapter ..|> OutputPort : 实现 + SqlAdapter ..> OrmRow : ③w _apply / ③r _to_domain + ResourceResponse ..> Aggregate : ④ from_domain + note for Command "Command 与 Aggregate 互不 import\n唯一汇点是 Service" +``` + +## 3. Commands 与 Events + +AWS 的 domain 七件套里有 `commands/` 与 `events/` [B]。commands 是本仓库的标准配备;events 采用轻量形态 + 明确的升格触发条件——不是省略,是设计决策,何时升格写在这里。 + +### 3.1 Commands:写用例一律 command 化,查询不 + +标准形态是每个用例一个 command 对象 + 每 command 一个 handler——[G] 指出这是单一职责与开闭原则的应用方式。本仓库的规定: + +- **写用例一律 command 化**:每个改变状态的用例对应 `commands.py` 里的一个 frozen dataclass,handler 即 service 方法;**查询保持普通参数**——command 表达改变状态的意图,包装读操作是纯样板(CQRS 的最浅形态:读写异形)。 +- **command 是哑数据**:不做业务校验——值规则归聚合 `__post_init__`,结构校验归入口的 api model,所以错误归因顺序(构造聚合先于任何 IO)仍由 handler 的构造顺序拥有。 +- **构造点在 api model**:`body.to_command(...)`(§2.1 变形 ①)——wire 形状拥有"翻译成领域词汇"这半边。 +- **部分更新的提示**:update 类用例(`None` = 未提供)command 化时需要 `Unset` sentinel 表达三态,勿把 `None` 的两种含义混进一个字段。 +- **文件为何叫 `service.py` 而非 `command_handlers.py`**:"Application Service" 是六边形/DDD 的一级正统术语,AWS 的 `command_handlers/` 是它的一种实现风格(函数式 handler + 注册表分发),不是概念本身的名字——名字跟着选定的形态走:本仓库的形态是"handler 即 service 方法",且该类还持有不收 command 的查询方法,叫 `command_handlers.py` 会错报一半内容(cosmicpython 同样先叫 `services.py`,到引入 message bus 那章才改名 `handlers.py`)。**演进分界**:当 §3.2 的 events 升格、组合根引入统一 dispatcher 时,写路径的自然形态变为独立 handler 函数(command handler 与 event handler 同居 `handlers.py`,按类型分发),`Service` 类在写侧消解、读侧留成 `queries.py`——在那之前,一个 Service 类聚合读写用例是更少样板、类型可追踪的形态。它的贫血风险由既有规则守住:"service 自身不含业务规则"(§4)。 + +### 3.2 Events:业务事实默认走入站适配器,第二订阅方出现时升格为事件 + +标准形态是领域行为完成后发出事件("Define events that the domain objects emit after they complete a behavior" [B]),供其他模块订阅。本仓库的对应设计: + +- **轻量形态**:一个业务事实只有一个消费者时,用**点对点回调 + 入站适配器**表达——入站适配器负责过滤(与本上下文无关的事件压根产生不出领域 DTO,service 因此不需要守卫子句)与翻译(运行时类型 → 领域词汇),再调用用例。组合根只装这一个监听者,领域不产生事件对象。 +- **升格触发条件**:同一业务事实出现**第二个订阅方**。判例:要给某个已有回调链的事实加通知——在监听者里加分支(入站适配器开始编排)或让 service 调通知端口(本上下文被迫认识通知领域)都是错误答案——正确答案是此刻引入事件。 +- **升格路径**: + 1. 事件类型放 `domain//events.py`(frozen dataclass,领域词汇),由**应用服务**在用例完成处发出——聚合保持纯函数式(返回新状态),不自带事件收集器; + 2. 组合根装配一个进程内同步 dispatcher(`dict[事件类型, list[订阅者]]` 即可起步),替换单一 hook;订阅者住各自上下文的入站适配器; + 3. 既有入站适配器的过滤与翻译职责不变——它翻译出的领域 DTO 驱动用例,用例完成后发领域事件;跨进程投递(AWS 语境的 "routed to other microservices")是第三阶段,触发条件是真的拆了服务。 + +## 4. 规则清单 + +每条附执法手段;无机械执法的靠 review,标 ⚠。 + +| 规则 | 执法 | +|---|---| +| harness 永不 import `app.*` | `tests/test_harness_boundary.py` | +| `domain/` 永不 import sqlalchemy / fastapi / pydantic / app / harness 基础设施 | `tests/test_harness_domain_purity.py`(AST) | +| 不变量在 `__post_init__` 校验,工厂与直接构造走同一条路("创建即一致",绕不过去) | 域测试(直接构造也校验的用例模式) | +| `now` 与运营阈值显式传入(`now=` 参数、policy 值对象注入),领域不读时钟与配置 | 纯度测试拦 import;⚠ `datetime.now` 靠 review | +| 端口签名技术中立:不出现 SQL、表名、HTTP 状态码、`Mapping[str, Any]`、运行时类型 | ⚠ review;`Mapping` 出现即领域在处理传输/存储格式 | +| 事务边界在端口方法内部(`async with session_factory()`);session 不进路由签名 | ⚠ review;per-request session 会破坏冲突翻译点与非 HTTP 入口 | +| 技术异常在适配器译成领域错误,翻译点唯一;防腐层只许端口契约声明的异常逃逸 | 契约测试 + service 测试 | +| 领域错误 → HTTP 码单表映射;未分类错误落 500,不默认 4xx | router 测试 | +| 转换链四次变形的 owner 与方法名固定(§2.1),身份字段禁止出现在请求模型上 | ⚠ review | +| 契约测试覆盖每个端口方法**并断言返回值**——`isinstance(repo, Port)` 抓不到拼错的方法名(Protocol 继承使名字总是存在) | 各上下文契约套件 × 双实现 | +| CAS 不得表达成 `save(aggregate)`——读改写会重新引入 CAS 要关的竞态 | 契约测试 | +| 响应模型是白名单不是 dump;服务端字段不进 wire | api model 显式字段 | +| 越权一律表现为"不存在"(None / False / 404),不抛权限错误 | 契约测试 + owner isolation 测试 | + +测试分层与架构分层一一对应:域测试红 = 规则错;service 测试红 = 编排错;契约测试红 = 存储实现错。契约测试全绿**不代表**可以多实例并发——原子性归实现不归契约,由真数据库的 race 测试单独负责。 + +## 5. 调用关系:读与写的标准链路 + +### 5.1 写链路 + +```mermaid +sequenceDiagram + participant C as Client + participant R as Router + API Model + participant S as Application Service + participant A as Aggregate + participant P as 防腐层
(经 output port) + participant AD as SQL Adapter
(经 output port) + participant DB as DB + + C->>R: PUT /resource {body} + R->>R: ① body.to_command(path, user_id) + Note over R: 身份服务端解析,禁止进 body + R->>S: use_case(cmd) + S->>A: ② Aggregate.create(...) + Note over S,A: 构造期校验——规则先于任何 IO + S->>P: 引用完整性 / 前置事实 + S->>AD: save(aggregate) + AD->>DB: ③w _apply(row) → 短事务 + DB-->>AD: row + AD-->>S: ③r _to_domain → Aggregate + S-->>R: Aggregate + R-->>C: ④ 200 Response.from_domain(...) + Note over R: 白名单渲染 + + alt 领域错误 + S--)R: DomainError + R-->>C: 单表映射 → 4xx(未分类落 500) end - subgraph harness["deerflow-harness(内圈 + 运行时)"] - direction TB - SV["domain/*/service.py
(input ports)"] - PO["domain/*/ports.py
(output ports)"] - DM["domain/*/model.py"] - RT["runtime/(agent loop)"] - end - DB[("SQLite / Postgres")] - PA -->|调用| SV - SV --> PO - SV --> DM - SA -->|实现| PO - CR -.->|启动时装配注入| SA - SA --> DB - style DM fill:#fff3cc - style PO fill:#e2f7e2 - style SV fill:#d8ecff ``` -**与 AWS 官方指南的对应。** DeerFlow 的分层直接采用 AWS Prescriptive Guidance《Building hexagonal architectures on AWS》的划分:应用分成三个文件夹——入口(主适配器)、领域(领域**与接口**)、适配器(从适配器): +要点各归其位:编排顺序本身是设计——先构造聚合(零 IO 校验先行),错误归因不受 IO 结果影响;事务收在端口方法内部,路由不知道 session 存在;技术异常在适配器译成领域错误后才穿出边界。 -| AWS 概念 | AWS 建议的位置 | DeerFlow 对应 | -|---|---|---| -| 入口 / primary adapters | `entrypoints/` | `app/gateway/routers/`、`app/channels/`、scheduler | -| 领域 + 接口 | `domain/`(`ports/` 是它的**子目录**) | `packages/harness/deerflow/domain/<模块>/`,`ports.py` 在包内 | -| 从适配器 / secondary adapters | `adapters/` | `app/adapters/<上下文>/` | +### 5.2 读链路 -**端口属于领域,不是独立一层**——AWS 明确把 `ports/` 放在 `domain/` 之下,描述为"领域借以与数据库、API 或其他外部组件通信的抽象"。把端口抽成与领域平级的第三层是常见误读:那会让领域反过来依赖一个外部包才能声明自己的需求,恰好破坏依赖倒置。 +```mermaid +sequenceDiagram + participant C as Client + participant R as Router + participant S as Application Service + participant AD as SQL Adapter
(经 output port) + participant DB as DB -DeerFlow 的两处偏离,都是有意的:`entrypoints/` 沿用仓库既有的 `app/gateway/routers/`(改名收益不抵影响面);**组合根 AWS 未定义**——那份指南没有这个概念,DeerFlow 的组合根(`app/gateway/deps.py::langgraph_runtime`)是自己的工程判断,实例见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §7.3。 - -## 3. 内圈的三个构件 - -每个已迁移的业务模块在 `packages/harness/deerflow/domain/<模块>/` 下由三个文件组成,纪律各不同: - -| 文件 | 职责 | 纪律 | -|---|---|---| -| `model.py` | **聚合**:业务事实与不变量 | 零依赖(仅标准库);不知道存储和 HTTP 的存在;不变量在构造期校验("创建即一致") | -| `ports.py` | **端口**:领域声明的接口(`typing.Protocol`) | 技术中立——签名里不允许出现 SQL、表名、文件路径等词汇 | -| `service.py` | **应用服务**:用例编排 | 内圈里唯一调用 output port 的构件;`user_id` 显式传参;自身不含业务规则 | - -一个关键理解:"service 调用 port"是合法的——**port 是 domain 自己声明、自己拥有的接口**,调用自己的抽象不构成对外圈的依赖。运行时注入的实现来自外圈(`app/adapters/`),但 service 只见 Protocol 类型。 - -方向要分清:**output port 是"注入进来"**(组合根把 SQL adapter 塞进 service 构造函数),**input port 是"暴露出去"**(service 本身就是 input port 实现体,router 从 `app.state` 拿到后调用)。 - -三个文件不是硬性文件数,是三种纪律:模块简单时 `model.py` 是单文件(feedback),复杂到有多个聚合和值对象时它是一个包(schedule 的 `model/`)。想看这三层在真实代码里长什么样、以及一个请求怎么穿过它们,读 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §3–§8。 - -## 4. 从适配器的两种形态 - -同一个上下文的从适配器看起来同级,性质可能完全不同。以 feedback 的两个为例——`SqlFeedbackRepository` 自己写 SQL,`RunStoreRunLookup` 一行 SQL 也没有。分辨靠两个问题: - -1. **这些表归我这个上下文所有吗?** -2. **我自己写 SQL、知道表结构吗?** - -| | 两问皆是 | 两问皆否 | -|---|---|---| -| 名称 | **自有持久化** | **防腐层**(ACL) | -| 例子 | `SqlFeedbackRepository` | `RunStoreRunLookup` | -| 文件 | `feedback_repository.py` | `run_lookup.py` | -| 触达 | `feedback` 表(feedback 上下文自有) | `runs` 表(run 上下文所有) | -| 手段 | 自己的 ORM 查询与短事务 | 调用 run 上下文已有的 `RunStore` | -| 类名前缀 | `Sql` | 被包装者的名字(`RunStore`) | -| docstring 首行标记 | `Secondary adapter (owned persistence)` | `Secondary adapter (anti-corruption layer)` | - -**命名惯例**:文件名取端口名的 snake_case,性质与技术**不进文件名**——分别由类名前缀和 docstring 首行的固定标记承载。`Sql` 前缀读作"我自己写 SQL",`RunStore` / `ThreadStore` 这类前缀读作"我借道那个组件"。 - -想找出全部防腐层,grep 标记即可: - -```bash -grep -rl "anti-corruption layer" app/adapters/ + C->>R: GET /...(一页数据) + R->>S: query(scope_id, keys, user_id) + Note over R,S: 查询不 command 化——普通参数 + S->>AD: 批量读 + AD->>DB: 单次查询覆盖整页(防 N+1) + DB-->>AD: rows + AD-->>S: dict[key, Aggregate] + Note over AD: _to_domain 逐行重建——
坏行读取即爆(单行)或跳过并记日志(列表) + S-->>R: 领域对象批量 + R-->>C: from_domain 白名单渲染,嵌入宿主响应 + Note over R: 越权数据表现为"查不到",不是权限错误 ``` -**为什么不用 `sql_` / `acl_` 文件名前缀**(这个方案被评估过并否决): +读侧三条规则在此汇齐:查询收普通参数(§3.1);批量方法按页返回、不设 N+1 形态的单条端点;所有者过滤是仓储参数而非异常。 -1. 前缀是**实现属性**而非身份属性。文件的身份是"实现哪个端口";换存储时 `sql_` 就得改名,但端口没变、import 路径理应不动——技术选择泄漏到结构上,正是六边形要避免的。 -2. 冗余:`from app.adapters.feedback.sql_feedback_repository import SqlFeedbackRepository` 里 "sql" 出现两次。 -3. `acl` 在本仓库会被读成 access control list(`app/gateway/authz.py` 就在隔壁)。而且 ACL 不是一种技术,是一种**关系**——`RunStoreRunLookup` 里的 `RunStore` 已经把关系说清楚了。 +### 5.3 多驱动源 -**前缀真正有价值的时机**:同一端口在生产代码里有多个实现并存。目前 `FeedbackRepository` 只有一个生产实现(内存版只存在于 `tests/feedback_fakes.py`)。到那天再引入 `sql_` / `memory_`,届时改名有正当理由。 +写链路的 driver 不只 HTTP:时钟入口(poller)与运行时回调入口(inbound adapter)走完全相同的 `Service → Ports → Adapters` 路径,只是 ① 的形态不同——时钟入口没有 wire 形状,直接构造调用;回调入口先过滤再翻译成领域 DTO(§3.2)。真实系统中三种驱动源共存的宏观图与派发闭环,见 [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)。 -**防腐层不是欠债,是上下游关系的正常形态。** feedback 是下游(Customer),run 是上游(Supplier);上游还没有发布正式契约之前,下游为它写一层防腐层正是 DDD 给的标准答案。但要在代码里写清楚它何时会变——**TODO 应描述触发条件,而不是抱怨现状**: +**本节的结论**:调用双向穿越边界(入口调进来、业务调出去、回调再调进来),依赖从不——六边形内的代码 import 不到链路右侧的任何东西,由 §4 的 AST 测试执法而非自觉。 -`app/adapters/feedback/run_lookup.py` 里的实际写法(代码注释统一用英文,与仓库其余部分一致): +## 6. 现状与待办 -```python - TODO(hexagonal): this depends on ``RunStore``, an infrastructure - component, rather than on a contract published by the run context -- - that context has not been through a hexagonal slice yet. When it - publishes one (a DTO, not its aggregate and not its repository), - replace the body of this class. The ``RunLookup`` port does not move. -``` +| 模块 | 状态 | +|---|---| +| **Feedback** | ✅ 参考实现(`domain/feedback/` + `app/adapters/feedback/`) | +| **Scheduling** | ✅ 参考实现(`domain/schedule/` + `app/adapters/schedule/`);旧代码已停用待删 | +| Run / ThreadMeta / RunEvent / Channel 等 | 旧模式,待迁移——阅读时勿以其为模板,新模块一律照 §2 | -最后一句是这层设计买到的东西:上游重构时,改动范围是一个文件,端口与领域一行不动。 +待办,按优先级: -## 5. 规则如何被守住 +| # | 待办 | +|---|---| +| 1 | 补防腐层对真实上游组件的契约测试(防腐层的门本身没被测过) | +| 2 | 删除 schedule 旧代码(`app/scheduler/service.py`、`gateway/routers/scheduled_tasks.py`、旧 `sql.py` 仓储部分) | +| 3 | feedback 依赖注入对齐 `Annotated[Service, Depends(...)]` 别名形态 | +| 4 | schedule 写用例按 §3.1 command 化(含部分更新的 `Unset` sentinel) | -规则不自动执法就会退化,DeerFlow 用三层机械手段守住边界: +## 7. 引用出处 -| 手段 | 位置 | 守住什么 | -|---|---|---| -| AST 边界测试 | `tests/test_harness_boundary.py` | harness 永不 import `app.*` | -| AST 纯度测试 | `tests/test_harness_domain_purity.py` | `domain/` 永不 import sqlalchemy / fastapi / pydantic / app / harness 基础设施模块——"内圈零依赖"的机器可验证形式 | -| 契约测试 | `tests/test_feedback.py` | `FeedbackRepositoryContract` 一套用例,由 `TestSqlFeedbackRepository` 与 `TestInMemoryFeedbackRepository` 各跑一遍——port 语义与实现不漂移;这也是"全 fake 可运行"检验的常态化 | +引用 AWS / Cockburn 须给页面级出处;JS 渲染页(`welcome.html` 等)不作引用锚点。 -测试分层与架构分层一一对应,失败定位因此清晰:域测试红 = 业务规则错;service 测试红 = 编排错;契约测试红 = 存储实现错。具体模块的测试分层表见各切片文档(如 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §9)。 - -**契约套件必须断言返回值,不能只断言"端口被满足"。** 一个显式继承 `Protocol` 的适配器,拼错方法名不会抛 `AttributeError`——Protocol 的方法体是 `...`,真方法退化成"返回 `None` 的空实现",而 `isinstance(repo, SomePort)` 依然为真(继承使得方法名总是存在)。这不是理论风险,feedback 切片真实踩过,见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §11.2。 - -## 6. 现状地图:哪些已迁移、哪些还没有 - -六边形是渐进迁移,当前状态: - -| 模块 | 状态 | 代码位置 | 模块文档 | -|---|---|---|---| -| **Feedback** | ✅ 已迁移(首个垂直切片,后续模块的样板) | `domain/feedback/` + `app/adapters/feedback/` | [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) | -| **Scheduling** | 🚧 内圈完成、外圈进行中(另一分支) | `domain/schedule/` + `app/adapters/schedule/` | [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md) | -| Run / ThreadMeta / RunEvent | 旧模式(有抽象基类 + 双实现,待领域化) | `runtime/*/store/`、`persistence/` | — | -| Channel / 配置写路径等 | 旧模式(待迁移) | `persistence/*/sql.py`、`gateway/routers/` | — | - -阅读旧模式代码时请注意:它们代表**迁移前的形态**(仓储返回裸 dict、领域规则散落、哨兵式用户解析),不要以它们为新代码的模板——新模块一律复制 Feedback 的形状(上述纯度测试会拦截倒退)。 - -**跨模块的收尾待办**,按优先级: - -| # | 待办 | 为什么 | -|---|---|---| -| 1 | 补 `RunStoreRunLookup` 对真实 `RunStore` 的契约测试 | 唯一有实质风险的一项,见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §11.1 | -| 2 | 装配抽成纯函数 `app/composition.py::build_domain_services()` | 组合根现在与启动流程混在一个函数里,无法单测;"memory 后端 → 503"这条规则目前只有一行注释在守 | -| 3 | 依赖注入改用 `Annotated[FeedbackService, Depends(get_feedback_service)]` 别名 | 测试可用 `dependency_overrides` 替换 service,不必再直接摸 `app.state` | -| 4 | 端口 `thread_of` 是否收窄成 `belongs_to_thread(run_id, thread_id) -> bool` | 判断内聚进端口,并与 schedule 的 `ThreadLookup.exists_for_user` 对称(未决) | - -第 3 项注入的仍是**应用级单例 service**,不是 per-request session——事务边界留在端口方法内部,路由不该知道 session 的存在。 - -## 7. 术语表 - -| 术语 | 含义 | DeerFlow 对应 | -|---|---|---| -| Domain(领域核心) | 纯业务规则,零基础设施依赖 | `domain/feedback/model.py` | -| Port(端口) | 领域声明的技术中立接口,**位于 domain 包内** | `FeedbackRepository`、`RunLookup`(Protocol) | -| Input port | 用例接口,被入口调用 | `FeedbackService.rate_run(...)` | -| Output port | 领域对外依赖,被基础设施实现 | `FeedbackRepository.save(feedback)` | -| Primary adapter(入口) | 外部协议 → 用例调用;AWS 称 `entrypoints/` | `gateway/routers/`、`channels/`、scheduler | -| Secondary adapter · 自有持久化 | 实现 output port,自己拥有表并写 SQL | `SqlFeedbackRepository` | -| Secondary adapter · 防腐层 | 实现 output port,把别的上下文的宽接口收窄 | `RunStoreRunLookup` | -| 组合根 | 适配器唯一的实例化地 | `app/gateway/deps.py::langgraph_runtime`(计划抽出 `build_domain_services()`) | -| 聚合 | 一致性边界,不变量构造期成立 | `Feedback` | -| 契约测试 | 多实现共用一套语义用例 | `FeedbackRepositoryContract` | - -## 8. 延伸阅读 - -- 想基于此架构做扩展(加字段、加用例、换存储、加入口)→ 二次开发指南; -- 想了解写代码时的判定规则(什么放哪、什么禁止)→ `backend/AGENTS.md` 的分层规约章节; -- AWS Prescriptive Guidance,《Building hexagonal architectures on AWS》—— - [Best practices](https://docs.aws.amazon.com/prescriptive-guidance/latest/hexagonal-architectures/best-practices.html) - 给出本文 §2 采用的三文件夹结构, - [Structure a Python project in hexagonal architecture using AWS Lambda](https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/structure-a-python-project-in-hexagonal-architecture-using-aws-lambda.html) - 是同一结构的 Python 落地示例; -- Cockburn 的 Ports & Adapters 原文(2005),"六边形"这个名字的出处。 +- **[C]** Cockburn, *Hexagonal Architecture*(2005)——Intent 原句与 driver 类别清单: + +- **[G]** AWS Prescriptive Guidance, *Building hexagonal architectures on AWS* — Introduction: + +- **[B]** 同指南 *Best practices*——三文件夹结构与 domain 七件套定义: + +- **[P]** *Structure a Python project in hexagonal architecture using AWS Lambda*——同一结构的 Python 落地示例: + diff --git a/backend/docs/README.md b/backend/docs/README.md index 30d5d39e0..409502b83 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -7,7 +7,7 @@ This directory contains detailed documentation for the DeerFlow backend. | Document | Description | |----------|-------------| | [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview | -| [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md) | 六边形(Ports & Adapters)分层总纲:规则、AWS 官方结构对应、两类从适配器、边界如何被测试守住 | +| [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md) | 六边形(Ports & Adapters)分层规范:标准结构(AWS 三文件夹 + domain 七件套)、Commands/Events 设计、规则清单与执法、调用关系 | | [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) | 用户反馈模块设计:首个完成的六边形切片,聚合/端口/适配器逐层走读与二次开发指引 | | [API.md](API.md) | Complete API reference | | [AUTH_DESIGN.md](AUTH_DESIGN.md) | User authentication, CSRF, platform-trust (IM / Internal Auth), and per-user isolation | diff --git a/backend/docs/assets/hexagonal_architecture.png b/backend/docs/assets/hexagonal_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..5d7c4d2a4b6cb26e892f6dab78d1ee0078c54a08 GIT binary patch literal 1339805 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