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