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.
25 KiB
用户反馈(Feedback)模块设计
面向想读懂或二次开发这个模块的人。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。
本文假定你已读过 HEXAGONAL_ARCHITECTURE_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 字段与身份
@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 创建即一致
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
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:顺序是设计,不是随意
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 只做两种翻译:
- 领域对象 ↔ ORM 行:
_to_domain/_to_row两个静态方法。_to_row显式列出每个字段而不是**asdict()——新加的列在被刻意映射之前保持私有。 - 技术异常 → 领域错误:
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 全部实现就是三行:
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 是适配器唯一的实例化点:
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 为主线,每一步都可以打开对应文件核对:
sequenceDiagram
participant U as 浏览器
participant R as routers/feedback.py<br/>(primary adapter)
participant S as FeedbackService<br/>(domain/feedback/service.py)
participant A as Feedback 聚合<br/>(domain/feedback/model.py)
participant L as RunStoreRunLookup<br/>(防腐层适配器)
participant I as SqlFeedbackRepository<br/>(自有持久化适配器)
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)
逐层职责:
- Router(
app/gateway/routers/feedback.py)只做协议转换:解析请求与当前用户 → 调 service → 把领域错误映射为 HTTP 码(InvalidRatingError→400、InvalidTagError→400、RunNotFoundError→404、DuplicateFeedbackError→409)。不含任何业务判断。 - Service(
service.py)编排用例,顺序见 §6.2。 - 聚合(
model.py)持有全部规则。回答"Feedback 的合法状态是什么",读这一个文件即可。 - Adapters(
app/adapters/feedback/)只翻译不编排,见 §7。 - 组合根(
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 用了哪个模型)为例,改动顺序:
model.py—Feedback加字段;如果有合法值约束,在__post_init__里加校验test_feedback_domain.py— 先写红的测试(TDD 是这个仓库的硬要求)- ORM 层 —
deerflow/persistence/feedback/model.py加列,并在migrations/versions/加一个 alembic revision(每个 ORM 变更都必须有 revision,见 backend/AGENTS.md) feedback_repository.py—_to_domain/_to_row各加一行(这是"显式列字段"的代价,也是它的价值)tests/feedback_fakes.py— fake 通常不用改(它存整个聚合)routers/feedback.py— 要不要进请求/响应模型?默认不进,除非前端真的需要
端口签名不用动——save(feedback) 传的是整个聚合。
10.2 加一个原因标签
改 model.py 的 VALID_FEEDBACK_TAGS,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——InvalidTagError 会拦下来(这是有意的:白名单在领域侧才能保证分析口径统一)。
10.3 加一个用例
比如"导出某 thread 的全部评价":
- 端口够用吗?
latest_per_run_in_thread可能已经够。够就不要加端口。 - 不够则先在
ports.py加方法并写清语义(docstring 是契约测试的依据) FeedbackRepositoryContract加用例——两套实现都必须跑通- 两个实现各自实现
service.py加编排方法,test_feedback_service.py用 fake 测- 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 |