From 57ba19b877b99aa900b0082a84b081158f5e3b19 Mon Sep 17 00:00:00 2001 From: rayhpeng Date: Tue, 28 Jul 2026 17:34:30 +0800 Subject: [PATCH] docs(hexagonal): split the module walkthrough out of the layering guide HEXAGONAL_ARCHITECTURE_zh.md now carries rules only: the two orthogonal boundaries, the AWS three-folder mapping, the two kinds of secondary adapter, and how the boundaries are mechanically enforced. The feedback walkthrough, its known gaps, and its todo list move to a module document, so the guide stays readable as more modules are migrated. Two corrections to the guide, both of which would have misled a reader: - Ports belong inside `domain/`, not beside it. AWS places `ports/` as a subdirectory of `domain/` and describes the domain folder as "domain and interfaces"; lifting ports into a third top-level layer would make the domain depend on an outside package to declare its own needs. The guide now states this explicitly, since the opposite reading is common. - The walkthrough had the service calling RunLookup before building the aggregate. The code does the reverse, and the order matters: validation runs before any port call, so an invalid rating on a nonexistent run is reported as InvalidRatingError rather than RunNotFoundError. The same section also called that check authorization; it is referential integrity, and authorization is the router's owner_check plus this check taken together -- which is why the port takes no user_id. FEEDBACK_DESIGN_zh.md is new and follows the SCHEDULE_DESIGN_zh.md shape: the aggregate and its invariants, both ports and the conventions that matter more than their signatures, the four use cases, both adapters, the walkthrough, the test layering, an extension guide, and a pitfall list. Three things it records that were not written down anywhere: - The aggregate reads the system clock in its default factory, which schedule deliberately avoids. Acceptable while the timestamp is only a bookkeeping stamp and feeds no rule; noted with the condition that would force a change. - A repository that explicitly inherits its Protocol turns a misspelled method into a silent None, because the inherited body is `...`. Hit for real during the move. isinstance() cannot detect it, so asserting "the port is satisfied" is not a substitute for asserting return values. - RunLookup has no contract test against a real RunStore. A renamed key in the dict RunStore.get() returns would turn every rating into a 404 with the suite still green. README.md indexes both under Quick Links, next to ARCHITECTURE.md. --- backend/docs/FEEDBACK_DESIGN_zh.md | 418 ++++++++++++++++++++++ backend/docs/HEXAGONAL_ARCHITECTURE_zh.md | 207 +++++++++++ backend/docs/README.md | 5 + 3 files changed, 630 insertions(+) create mode 100644 backend/docs/FEEDBACK_DESIGN_zh.md create mode 100644 backend/docs/HEXAGONAL_ARCHITECTURE_zh.md diff --git a/backend/docs/FEEDBACK_DESIGN_zh.md b/backend/docs/FEEDBACK_DESIGN_zh.md new file mode 100644 index 000000000..a4cc662e1 --- /dev/null +++ b/backend/docs/FEEDBACK_DESIGN_zh.md @@ -0,0 +1,418 @@ +# 用户反馈(Feedback)模块设计 + +> 面向想读懂或二次开发这个模块的人。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。 +> +> 本文假定你已读过 [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md)——那里讲六边形的规则,这里讲规则在这个模块上的具体样子。术语(端口、适配器、聚合、防腐层)不再重复解释。 +> +> 姊妹文档:[SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)——定时任务模块,同样的结构但复杂度高一个量级(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**)。 + +--- + +## 1. 这个模块做什么 + +给一次 agent 执行(run)点赞或点踩,可以附原因标签和文字评论。 + +产品层面的三条决定,解释了后面所有设计: + +| 决定 | 后果 | +|---|---| +| **一个用户对一个 run 只有一个当前评价** | 写入是 upsert("我现在的看法是 X"),不是 append;聚合身份是 `(thread_id, run_id, user_id)` 三元组 | +| **再点一次当前按钮 = 撤回** | 有 DELETE 用例,且撤回不存在的评价不是错误 | +| **评价回显嵌在消息列表里** | 没有独立的读端点;读路径为分页和全量各准备了一个批量方法 | + +对应的 HTTP 面(`app/gateway/routers/feedback.py`): + +``` +PUT /api/threads/{tid}/runs/{rid}/feedback 设置当前评价(幂等) +DELETE /api/threads/{tid}/runs/{rid}/feedback 撤回 +``` + +## 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) +``` + +与 schedule 模块不同,这里**没有新旧并存**——旧的 `FeedbackRepository`(散在 `persistence/` 下的那一版)已经删除,没有兼容层,没有双写。 + +已知的收尾待办见总纲 §7,其中优先级最高的一项是本文 §11 的第一个陷阱。 + +## 3. 内圈全景 + +``` +domain/feedback/ +├── __init__.py 上下文的公开 API:领域对象 + 服务(端口刻意不在这里导出) +├── model.py Feedback(聚合根)· 5 个领域错误 · 2 个白名单常量 +├── ports.py 2 个 Protocol:FeedbackRepository · RunLookup +└── service.py FeedbackService —— 用例编排(input port) +``` + +`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 字段与身份 + +```python +@dataclass(frozen=True) +class Feedback: + feedback_id: str # 代理主键,工厂生成的 uuid4 + run_id: str # ┐ + 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 不变。 + +`user_id` 可以是 `None`——那是免鉴权模式(`database.backend` 无认证部署)。所有读方法的 `user_id` 参数因此都是 `str | None`,`None` 表示不按所有者过滤。 + +`message_id` 把评价收窄到 run 里的某一条消息而不是整个 run,可选。 + +### 4.2 创建即一致 + +```python +def __post_init__(self): + if self.rating not in VALID_RATINGS: # (-1, 1) + raise InvalidRatingError(...) + unknown = set(self.tags) - VALID_FEEDBACK_TAGS + if unknown: + raise InvalidTagError(...) +``` + +校验在 `__post_init__`,不在工厂里,所以**绕不过去**:`Feedback.create(...)` 和 `Feedback(...)` 直接构造走同一条路(`test_direct_construction_also_validates` 盯着这条)。适配器从数据库读出来重建对象时也会过这一遍——一行手工改坏的数据在读取时就会炸,而不是流到前端。 + +推论一条:**非法输入永远在任何 IO 之前报错**。`FeedbackService.rate_run` 先构造聚合再查 run(见 §6.2),所以一个非法 rating 配一个不存在的 run,报的是 `InvalidRatingError` 而不是 `RunNotFoundError`。 + +### 4.3 tags 是语言中立的 slug + +```python +VALID_FEEDBACK_TAGS = frozenset({ + "incorrect", "not_as_expected", "slow", "style_tone", "safety_legal", "other", +}) +``` + +白名单在领域里,翻译在前端。存储和分析只见 slug,所以中文界面和英文界面提交的"回答不对"能聚合到一起。 + +加标签要动这个 frozenset,见 §10.2。 + +### 4.4 `frozen=True` 意味着什么 + +聚合不可变。想改评价就构造一个新的(`dataclasses.replace` 或 `Feedback.create`),没有 setter,没有"半更新"的中间态。`test_frozen` 盯着这条。 + +代价是仓储的 `save` 只能是整体替换语义,不能是字段级 patch——这正是端口签名 `save(feedback: Feedback)` 的原因。 + +### 4.5 一处与 schedule 的分歧:这里读了时钟 + +`created_at` 的默认值是 `datetime.now(UTC)`——**领域在构造期读了系统时钟**。schedule 模块刻意避免这件事(`now` 一律由调用方显式传参,领域从不读时钟,测试天然确定)。 + +两个切片在这一点上不一致,如实记录:feedback 的时间只是一个记账戳,不参与任何规则判断(没有"超过 N 天的评价失效"这类逻辑),所以读时钟的代价只是测试里断言时间需要一点宽容(`test_created_at_is_tz_aware` 只断言"带时区"而非具体值)。schedule 的时间是**规则输入**(下次触发、租约过期),那里必须显式传参。 + +如果哪天 feedback 出现依赖时间的规则,应当同时把 `created_at` 改成显式传参。 + +## 5. 端口:领域对外的两个依赖 + +`ports.py` 声明领域需要外界做什么,由外圈实现。 + +| 端口 | 回答什么问题 | 方法数 | +|---|---|---| +| `FeedbackRepository` | 评价存在哪、怎么按所有者过滤、并发 upsert 冲突怎么表达 | 4 | +| `RunLookup` | 这个 run 属于哪个 thread | 1 | + +### 5.1 两条约定,比签名更重要 + +**① 所有者过滤是参数,不是异常。** 每个读方法都收 `user_id: str | None`:非 `None` 时限制到该用户的条目,`None` 表示不过滤(免鉴权模式)。别人的评价表现为"查不到",而不是抛权限错误。 + +**② 并发 upsert 的败者必须翻译成领域错误。** 两个并发请求可以都没查到、都去插入,输的那个撞上唯一约束。适配器负责把 `IntegrityError` 翻译成 `DuplicateFeedbackError`,router 再映射成 HTTP 409 让客户端重试。旧代码在这里泄漏了驱动异常,结果是 500。 + +### 5.2 两个刻意的缺席 + +**没有 `find_one` / `get_by_id`。** 读路径只有批量方法,因为唯一的消费者是消息列表回显——它要的是"这一页所有 run 的当前评价",不是"某一条评价"。加一个单条读方法就要有人来回答"谁在用它"。 + +**`RunLookup` 里没有 `user_id`。** 它只回答纯事实"run 属于哪个 thread",不掺授权。授权是两环链条,见 §6.2。 + +### 5.3 为什么 `RunLookup` 只有一个方法 + +feedback 需要知道的关于 run 的全部事情,就是"它属于哪个 thread"。而 run 上下文的仓储(`RunStore`)有 26 个方法。 + +依赖那一个方法而不是那 26 个,是接口隔离原则的教科书用法,收益是具体的:`domain/feedback/` 整个包里没有一行提到 `RunStore`、`runtime`、`persistence`。run 上下文换存储实现,feedback 领域一个字不用改。 + +代价是外圈要写一层防腐层,见 §7.2。 + +## 6. 应用服务:用例编排 + +`service.py`,75 行。构造函数收两个端口,之后只见 Protocol 类型。 + +### 6.1 用例清单 + +| 方法 | 支撑 | 领域错误 | +|---|---|---| +| `rate_run(...)` | `PUT /feedback` | `InvalidRatingError` / `InvalidTagError` / `RunNotFoundError` / `DuplicateFeedbackError` | +| `retract_run_rating(...)` | `DELETE /feedback` | — | +| `latest_per_run_in_thread(...)` | 消息列表全量路径 | — | +| `latest_for_runs(...)` | 消息列表分页路径 | — | + +服务自身**不含业务规则**: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) # ③ 最后落库 +``` + +**① 在 ② 之前**,所以错误归因不受 IO 结果影响——非法 rating 配不存在的 run,报 `InvalidRatingError`。`test_invalid_rating_rejected_before_run_lookup` 就是钉这个顺序的。 + +**② 是引用完整性,不是授权。** 授权是两环链条: + +``` +router 的 @require_permission(..., owner_check=True) -> 你拥有这个 thread +service 的 _require_run -> run 属于该 thread + 两者合起来 -> 你拥有这个 run +``` + +这解释了为什么 `RunLookup.thread_of` 签名里没有 `user_id`:第一环已经证明了 thread 归属,第二环只需回答纯事实。少了任何一环都不成立——单独的 `_require_run` 拦不住"用自己的 thread 配别人的 run"以外的攻击面,单独的 `owner_check` 拦不住跨 thread 的 run id。 + +### 6.3 读路径为什么有两个方法 + +`latest_per_run_in_thread` 拉整个 thread,`latest_for_runs` 只拉指定的一批 run。后者是给消息列表分页端点用的——一页只需要这一页那几个 run 的角标,拉整个 thread 在长会话里是浪费。 + +两者都是**批量**方法,返回 `dict[run_id, Feedback]`,一次查询覆盖一页。前端按钮的高亮状态由此而来,没有独立的读端点。 + +### 6.4 `retract` 为什么不查 run + +写路径校验引用完整性,删路径不校验,这个不对称是有意的:删一条不存在的评价本来就返回 `False`(router 转成 404),多一次 `RunLookup` 调用不会改变任何结果,只是多一次 IO。 + +## 7. 适配器:两种形态 + +两个从适配器住在 `app/adapters/feedback/`,一个端口一个文件。它们性质不同——判据和命名惯例见总纲 §5,这里讲各自的实现要点。 + +### 7.1 `feedback_repository.py`:自有持久化 + +`SqlFeedbackRepository` 只做两种翻译: + +1. **领域对象 ↔ ORM 行**:`_to_domain` / `_to_row` 两个静态方法。`_to_row` 显式列出每个字段而不是 `**asdict()`——新加的列在被刻意映射之前保持私有。 +2. **技术异常 → 领域错误**:`IntegrityError → DuplicateFeedbackError`。 + +外加一件跨数据库的脏活:`_tz_aware()`。SQLite 读回来的 `datetime` 丢了 tzinfo,而存进去的一律是 UTC,所以读路径统一补回 `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。 + +### 7.2 `run_lookup.py`:防腐层 + +`RunStoreRunLookup` 全部实现就是三行: + +```python +async def thread_of(self, run_id: str) -> str | None: + run = await self._run_store.get(run_id) + 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。 + +### 7.3 组合根 + +`app/gateway/deps.py::langgraph_runtime` 是适配器唯一的实例化点: + +```python +app.state.feedback_service = FeedbackService( + repository=SqlFeedbackRepository(sf), + runs=RunStoreRunLookup(app.state.run_store), +) +``` + +`sf is None`(memory 后端)时 `feedback_service = None`,router 的依赖返回 503。这条规则目前只有一行注释在守——总纲 §7 待办 2 就是把这段装配抽成可单测的纯函数。 + +## 8. 一次点踩的旅程 + +以 `PUT /api/threads/{tid}/runs/{rid}/feedback` 为主线,每一步都可以打开对应文件核对: + +```mermaid +sequenceDiagram + participant U as 浏览器 + participant R as routers/feedback.py
(primary adapter) + participant S as FeedbackService
(domain/feedback/service.py) + participant A as Feedback 聚合
(domain/feedback/model.py) + participant L as RunStoreRunLookup
(防腐层适配器) + participant I as SqlFeedbackRepository
(自有持久化适配器) + participant DB as SQLite/PG + + 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) + S->>A: Feedback.create(...) — 构造期校验 rating/tags + S->>L: (经 RunLookup) thread_of(rid) — 引用完整性 + S->>I: (经 FeedbackRepository) save(feedback) + I->>DB: 短事务:按业务身份查 → 更新或插入 → commit + I-->>S: Feedback 领域对象 + S-->>R: Feedback + R-->>U: 200(领域错误在此译为 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 产品一致。 + +## 9. 测试分层 + +分层与架构一一对应,失败定位因此清晰: + +| 测试文件 | 层 | 规模 | 红了说明 | +|---|---|---|---| +| `test_feedback_domain.py` | 聚合 | 9 | 业务规则错 | +| `test_feedback_service.py` | 用例编排(双 fake 端口) | 9 | 编排顺序或错误映射错 | +| `test_feedback.py::FeedbackRepositoryContract` | 端口契约 × 2 实现 | 11 × 2 | 存储实现与端口语义漂移 | +| `test_persistence_timezone.py` | 适配器细节 | 1 | SQLite 时区归一化坏了 | +| `test_owner_isolation.py` | 跨用户隔离 | 2 | 所有者过滤坏了 | +| `test_thread_messages_feedback.py` | 回显集成 | — | 消息列表拼装坏了 | + +**契约测试的形状**值得学:`FeedbackRepositoryContract` 是一个不带 `Test` 前缀的基类(pytest 不会直接收集它),`TestSqlFeedbackRepository` 和 `TestInMemoryFeedbackRepository` 各自继承它并提供 `repo` fixture。一套语义用例,两套实现各跑一遍。 + +**零 IO 的 fake 住在 `tests/feedback_fakes.py`**,不在测试文件里——这样契约套件和服务测试都能当普通模块 import,不依赖 tests 目录恰好在 `sys.path` 上。 + +`test_feedback_service.py` 是总纲 §2「唯一检验」的活样本:整个用例在两个 dict fake 上端到端跑通,零 IO、零 HTTP。 + +## 10. 二次开发指引 + +### 10.1 给评价加一个字段 + +以加 `model_name`(记录被评价的 run 用了哪个模型)为例,改动顺序: + +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` — 要不要进请求/响应模型?**默认不进**,除非前端真的需要 + +端口签名不用动——`save(feedback)` 传的是整个聚合。 + +### 10.2 加一个原因标签 + +改 `model.py` 的 `VALID_FEEDBACK_TAGS`,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——`InvalidTagError` 会拦下来(这是有意的:白名单在领域侧才能保证分析口径统一)。 + +### 10.3 加一个用例 + +比如"导出某 thread 的全部评价": + +1. 端口够用吗?`latest_per_run_in_thread` 可能已经够。够就不要加端口。 +2. 不够则先在 `ports.py` 加方法并写清语义(docstring 是契约测试的依据) +3. `FeedbackRepositoryContract` 加用例——**两套实现都必须跑通** +4. 两个实现各自实现 +5. `service.py` 加编排方法,`test_feedback_service.py` 用 fake 测 +6. router 加端点,只做协议转换 + +### 10.4 换存储 + +实现 `FeedbackRepository` 的四个方法,在契约套件里加一个继承 `FeedbackRepositoryContract` 的测试类,改组合根一行。领域和 service 一行不动——这是六边形买到的东西,`test_harness_domain_purity.py` 保证它不退化。 + +### 10.5 不要做的事 + +- **不要在 router 里写业务判断。** rating 合法性、tags 白名单、引用完整性都有归属,router 只翻译协议。 +- **不要让端口签名出现 SQL / 表名 / HTTP 状态码。** 纯度测试拦 import,但拦不住命名——`save_row` 这种名字要靠 review。 +- **不要在领域里 import 适配器。** 想不到理由要这么做,但如果你觉得需要,说明用例编排放错层了。 +- **不要给 `RunLookup` 加方法来"顺便"拿 run 的别的信息。** 那是 run 上下文的数据,需要更多就说明该等它发布契约,或者你的用例应该住在别的上下文里。 +- **不要用 `**asdict()` 简化 `_to_row`。** 显式字段列表是一道闸门:新列在被刻意映射之前不会悄悄进出数据库。 + +## 11. 常见陷阱速查 + +### 11.1 `RunLookup` 没有契约测试(已知缺口,最高优先级) + +`FeedbackRepository` 有双实现契约套件,`RunLookup` 没有——`RunStoreRunLookup` 只在 `tests/feedback_fakes.py` 的 fake 层面被覆盖,**从未对着真实的 `RunStore` 跑过**。 + +失效路径很具体:`RunStore.get()` 返回 `dict[str, Any]`,适配器靠 `run.get("thread_id")` 取值。一旦那个 dict 的键改名,`thread_of` 对所有 run 返回 `None`、所有点踩变成 404,而测试全绿。 + +防腐层的价值是把不受控的外部形状挡在门外,门本身没被测过,挡不挡得住是运气。 + +### 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 只检查方法名存在,而继承使得名字总是存在。所以那条断言证明的是"没有忘记声明",不是"实现是对的"——**契约套件必须覆盖每个端口方法并断言返回值**,这不是可选项。 + +### 11.3 upsert 更新时不要换 `feedback_id` + +按业务身份三元组查到已有行时,保留原 `feedback_id`。前端可能用它做列表 key,换掉会导致重新评价后组件重挂载。 + +### 11.4 SQLite 读回的时间没有 tzinfo + +一律经 `_to_domain` 的 `_tz_aware()` 补 UTC。绕过 `_to_domain` 自己组装 `Feedback` 就会漏掉这一步。 + +### 11.5 免鉴权模式下 `user_id` 是 `None` + +它是合法值,不是"忘记传"。所有读方法的 `user_id: str | None` 里,`None` 的语义是"不按所有者过滤",不是"过滤 user_id 为 null 的行"。写路径的 `remove_for_run` 是例外——它用 `user_id` 做等值匹配,所以 `None` 只删 `user_id IS NULL` 的行。 + +### 11.6 并发 upsert 的 409 不是 bug + +两个并发请求都没查到、都插入,输的那个撞唯一约束 → `DuplicateFeedbackError` → HTTP 409。客户端重试即可。旧代码这里泄漏 `IntegrityError`,返回的是 500。 + +## 12. 代码索引 + +| 关注点 | 文件 | +|---|---| +| 聚合、不变量、领域错误 | `packages/harness/deerflow/domain/feedback/model.py` | +| 端口契约(语义写在 docstring 里) | `packages/harness/deerflow/domain/feedback/ports.py` | +| 用例编排 | `packages/harness/deerflow/domain/feedback/service.py` | +| 上下文公开 API | `packages/harness/deerflow/domain/feedback/__init__.py` | +| SQL 适配器 | `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/` | +| 零 IO fake | `backend/tests/feedback_fakes.py` | +| 测试分层 | 见 §9 | diff --git a/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md b/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md new file mode 100644 index 000000000..4455395a0 --- /dev/null +++ b/backend/docs/HEXAGONAL_ARCHITECTURE_zh.md @@ -0,0 +1,207 @@ +# DeerFlow 后端架构:六边形设计 + +> 面向想理解 DeerFlow 后端架构的学习者。读完你将能回答:任何一段后端代码,为什么在它所在的位置。 +> +> **本文只讲规则**,不讲某个模块的具体样子。想看规则落到真实代码上、想动手改一个模块,读对应的模块文档: +> +> - [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) —— 用户反馈模块,首个完成的切片,最简单的样板 +> - [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md) —— 定时任务模块,两个聚合 + 两个状态机 + 四道并发防线(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**) +> +> 编码规约见 `backend/AGENTS.md`。 + +--- + +## 1. 两个正交的边界 + +DeerFlow 后端由两个互相垂直的边界切分,理解它们是理解一切的前提。 + +**边界一:harness / app —— 回答"哪些代码可复用发布"。** + +- `packages/harness/deerflow/`(import 前缀 `deerflow.*`):可独立发布的 agent 框架包——agent 循环、工具、沙箱、MCP、技能、配置; +- `app/`(import 前缀 `app.*`):不发布的应用层——FastAPI Gateway 与 IM 渠道集成。 + +依赖单向:app 可以 import deerflow,deerflow 永不 import app(CI 中 `tests/test_harness_boundary.py` 执法)。 + +**边界二:内圈 / 外圈 —— 回答"业务规则与技术细节谁依赖谁"。** + +这是六边形架构引入的维度:**业务核心(内圈)不依赖任何基础设施(外圈);基础设施实现业务声明的接口**。两个边界叠加后的切分规则一句话: + +> **domain(模型 + 端口 + 应用服务)归 harness;adapters + 入口 + 组合根归 app。** + +历史上仓库只有边界一,于是持久化实现以"框架能力"的名义沉进了 harness,业务规则以"应用胶水"的名义散进了 router——两个维度被一条边界承担。六边形重构补上的就是第二个维度。 + +## 2. 六边形架构究竟是什么 + +真名是 **Ports & Adapters**(Cockburn, 2005)。"六边形"只是画图的偶然,六条边没有任何含义。它的全部内容是一个观念转变加一条规则: + +> **应用只有"内外"之分,没有"上下"之分。** 数据库和浏览器没有本质区别——它们都是站在边界外、想与业务核心对话的外部世界。传统分层图把数据库画成业务的"地基",这是错觉;六边形把它掰起来,与 UI 摆在同一圈上,降格为业务的一个"插件"。 + +**唯一规则**:依赖箭头永远从外指向内。核心声明接口(port),外部世界各自带转换头(adapter)来插。调用可以由内向外发起(业务调仓储),但依赖方向始终向内(仓储实现的是业务声明的接口)——调用方向与依赖方向解耦,这就是"依赖反转"的准确含义。 + +**唯一检验**:业务逻辑能否在没有 HTTP、没有数据库的测试里(全部换成 fake)完整运行。能,就实现了;不能,无论目录叫什么名字都没实现。DeerFlow 的 `tests/test_feedback_service.py` 就是这个检验的活样本:整个 feedback 用例在零 IO 的 dict fake 上端到端跑通。 + +```mermaid +flowchart LR + subgraph app["app · 官方宿主(六边形外圈)"] + direction TB + PA["primary adapters
gateway/routers · channels · scheduler"] + SA["secondary adapters
app/adapters/feedback"] + CR["deps.py · 组合根"] + 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》的划分:应用分成三个文件夹——入口(主适配器)、领域(领域**与接口**)、适配器(从适配器): + +| 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/<上下文>/` | + +**端口属于领域,不是独立一层**——AWS 明确把 `ports/` 放在 `domain/` 之下,描述为"领域借以与数据库、API 或其他外部组件通信的抽象"。把端口抽成与领域平级的第三层是常见误读:那会让领域反过来依赖一个外部包才能声明自己的需求,恰好破坏依赖倒置。 + +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/ +``` + +**为什么不用 `sql_` / `acl_` 文件名前缀**(这个方案被评估过并否决): + +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` 已经把关系说清楚了。 + +**前缀真正有价值的时机**:同一端口在生产代码里有多个实现并存。目前 `FeedbackRepository` 只有一个生产实现(内存版只存在于 `tests/feedback_fakes.py`)。到那天再引入 `sql_` / `memory_`,届时改名有正当理由。 + +**防腐层不是欠债,是上下游关系的正常形态。** feedback 是下游(Customer),run 是上游(Supplier);上游还没有发布正式契约之前,下游为它写一层防腐层正是 DDD 给的标准答案。但要在代码里写清楚它何时会变——**TODO 应描述触发条件,而不是抱怨现状**: + +`app/adapters/feedback/run_lookup.py` 里的实际写法(代码注释统一用英文,与仓库其余部分一致): + +```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. +``` + +最后一句是这层设计买到的东西:上游重构时,改动范围是一个文件,端口与领域一行不动。 + +## 5. 规则如何被守住 + +规则不自动执法就会退化,DeerFlow 用三层机械手段守住边界: + +| 手段 | 位置 | 守住什么 | +|---|---|---| +| 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 可运行"检验的常态化 | + +测试分层与架构分层一一对应,失败定位因此清晰:域测试红 = 业务规则错;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),"六边形"这个名字的出处。 diff --git a/backend/docs/README.md b/backend/docs/README.md index c3c334d5e..30d5d39e0 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -7,6 +7,8 @@ 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 官方结构对应、两类从适配器、边界如何被测试守住 | +| [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 | | [SSO.md](SSO.md) | OIDC / SSO single sign-on | @@ -39,6 +41,7 @@ This directory contains detailed documentation for the DeerFlow backend. 2. **Configuring the system?** See [CONFIGURATION.md](CONFIGURATION.md) 3. **Understanding the architecture?** Read [ARCHITECTURE.md](ARCHITECTURE.md) 4. **Building integrations?** Check [API.md](API.md) for API reference +5. **Wondering why the layers are split the way they are?** Read [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md) for the rules, then [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) for a worked example ## Document Organization @@ -46,6 +49,8 @@ This directory contains detailed documentation for the DeerFlow backend. docs/ ├── README.md # This file ├── ARCHITECTURE.md # System architecture +├── HEXAGONAL_ARCHITECTURE_zh.md # Hexagonal layering rules (zh) +├── FEEDBACK_DESIGN_zh.md # Feedback module design (zh) — first hexagonal slice ├── API.md # API reference ├── AUTH_DESIGN.md # User authentication and isolation design ├── CONFIGURATION.md # Configuration guide