写不出绕开总线的代码:结构优先、门禁兜底
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
以后写不出绕开总线的副作用:靠结构让它不可表达,门禁只负责防止有人把能力重新带回来。总线本身见 event-model;它加入的门禁家族见 mechanical-guardrails;它扩展的分层见 backend-domain-modules。
结构:请求路径拿不到副作用能力
classDiagram
direction LR
class UsecaseDeps {
«request path · usecase / ops / routes»
+Recorder events
+Jobs enqueue
+Repos …
no mail port / supplier write
}
class SubscriberDeps {
«job path · internal/<domain>/subscriber»
+mail.Sender
+Repos …
}
class SideEffectPorts {
«internal/infra/sideeffect/**»
mail · Sender
supplier · supplier.invoke job
}
class WebhookDelivery {
«infra/events · run only by webhook.deliver»
sign · send · classify
}
class QueryPorts {
«sync queries, allowed on the request path, sent once»
Calendar free_busy
OAuth / Captcha / model listing
}
UsecaseDeps ..> QueryPorts
SubscriberDeps ..> SideEffectPorts
SubscriberDeps ..> WebhookDelivery
UsecaseDeps ..x SideEffectPorts : forbidden by gate
- 所有“对外造成影响”的端口集中在
internal/infra/sideeffect/**:目前是mail(发信)和supplier(持久的supplier.invoke任务)。新增一种副作用,就在这个树下加端口,自动受管。 - 发 webhook 不是用例够得着的端口:它在
internal/infra/events里,只在webhook.deliver任务中运行。 - 用例(
usecase、ops、routes)的依赖里只有Recorder和Jobs;要发邮件只能Record一个事件或入队一个任务,由subscriber包里的 handler 去发。 - 需要当场得到结果的同步查询(日历忙闲、OAuth、验证码、模型列表)不是副作用端口,请求路径照常可用,且只发一次;见 retry-has-one-owner。按端口所在的树区分,没有排除清单。
门禁
其中六条作为 event-bus-gates 跑在后端的 make lint 里;check-periodic-via-scheduler.sh 是单独的 lint 目标。
| 门禁 | 禁止什么 | 并入 / 新增 |
|---|---|---|
check-side-effects-behind-bus.sh |
除 internal/<域>/subscriber、internal/infra/**、cmd/server/** 以外的地方 import internal/infra/sideeffect/** |
新增;并把 subscriber 加进 check-domain-layering 的层序,位于 usecase 和 facade 之间(subscriber 可用 usecase/repo,反过来不行) |
check-no-bare-goroutine.sh |
go 语句出现在 internal/infra/** 与 cmd/server/** 之外(测试除外) |
新增 |
check-retry-only-in-jobs.sh |
retry.Do 出现在 internal/infra/jobs 之外 |
新增(替掉 mail_retry 里的口头约定) |
check-periodic-via-scheduler.sh |
time.NewTicker / time.Tick 出现在 internal/infra/jobs/** 之外 |
已有,已更新:调度器换成 jobs.Periodic |
check-queue-behind-port.sh |
在 internal/infra/jobs/river 之外 import github.com/riverqueue/**(测试也算) |
新增 |
check-table-event-policy.sh |
schema.sql 里任何 CREATE TABLE 没有声明 -- events: emit 或 -- events: none (原因);声明 emit 但没有触发器 |
新增;58 张表全部标注,只有 corpus_notes 发事件。是必填声明不是排除清单 |
check-tx-only-via-pgstore.sh |
.Begin(、BeginTx( 或 BeginFunc( 出现在 internal/infra/pgstore 之外;开事务只有 pgstore.InTx,事务以显式参数传递(repo.With(tx)),不进 ctx |
新增;见 code-structure |
相关:队列门禁把 River 关在 queue-behind-ports 后面;重试门禁落实 retry-has-one-owner;goroutine 门禁落实 concurrency-control;表门禁让每张表决定自己是不是 two-sources-of-events 里的来源。
不留常驻自测。 owner 的规矩是门禁就是门禁:旁边不放自测脚本。七条门禁上线前,各在临时副本里植入的违规样本上证红过一次。
goroutine 门禁上线前清掉的违规点
| 文件 | 去向 |
|---|---|
routes/admin/obsidian.go |
重建 goroutine 删除;逐篇的触发器事件负责索引(P1) |
plugin/adapters/invoke_background.go |
删除;后台 supplier 调用改为持久的 supplier.invoke 任务(P4) |
routes/hostdesk/hostdesk.go、agentcore/hostops.go |
accept 循环移进 hostsocket(ListenWith 自己起循环) |
plugin/mount/mounted_warm.go |
改用 detach.Go,由它拥有 goroutine 并吸收 panic |
运行时与测试层的兜底
Record一个没声明的事件类型 → 返回ErrUndeclaredType,不是静默写进去。订阅的通配匹配不到任何已声明类型 → 启动失败。- 幂等性由注册表驱动的一条 UT 覆盖(
cmd/server/wire):遍历所有已注册订阅方,每个都投递同一事件两次并断言效果只发生一次。遇到不知道怎么驱动的订阅方就失败,没有清单要维护。 - 事件类型注册表 UT:类型没声明、命名不对、没有主体模式、或
Exposure停在零值却没列为 internal,都会失败。 - schema 一致性 UT(
TestMigrationsAddNothingToASchemaSQLDatabase)断言迁移不会给schema.sql建出的库再加任何东西。