写不出绕开总线的代码:结构优先、门禁兜底

更新于 · 在 sijie.xyz 查看原条目 ↗

状态: 已在 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 建出的库再加任何东西。