业务收拢清单:101 项
上级:events
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
全仓库扫描(backend、app、sdk、builder、im-bridge、infra/plugins、updater)得到 101 项手接的“写完 X 顺手做 Y”,每一项都有且只有一个去向。标记:E 发事件 + 订阅方,J 持久任务,P River 周期任务,W 开放为 webhook 事件类型,K 保持现状。行号对应盘点原稿(基于 origin/main 6eb8c0a64)。“实现”一栏写每项最终落到了哪里。分期见 events-roadmap。
要搬的:写后副作用
| # | 做什么 | 原位置 | 原来怎么坏 | 去向 | 实现 |
|---|
| 1 | 访客申请 → 给 owner 发邮件 | access_requests.go:59 → request_notify.go:44 | 请求被整个重试过程堵住;失败即丢;Redis 限流静默丢弃 | E access_request.created + J | owner.notify(owner/subscriber/mail.go);每小时 5 封的突发上限改成 Postgres 名额,仍是有意丢弃 |
| 2 | 批准申请 → 发带码邮件 + 标记已回复 | access_approval.go:86-89 | 邮件发了但状态没写上则不一致 | E + J | access_request.approval_mail 与签发码同一事务入队;发送后才写“已回复” |
| 4 | 换邮箱确认邮件 | email_change.go:138 | 发送失败留下悬空的 pending 行 | J | owner.email_confirmation;链接令牌在发送时才生成 |
| 5 | 预约 → 通知 owner | booker-mcp.js:458 → invoke_background.go:53 | 游离 goroutine,重启即丢 | E booking.created + J | booking.record host op 在一个事务里写 booking.* 和一行 booking_notices;owner.notify 发送后删掉它 |
| 7 | 预约落库失败后删日历事件(补偿) | booker-mcp.js:309,337 | 删失败留下孤儿日程,没人重试 | J | 持久的 supplier.invoke |
| 8–10 | Meili upsert / 删除 / 发布后重建 | corpus_crud.go、subjectivity.go、corpus.go:139、output.go:61、seo.go:126,140 | 漏调、拖慢请求、进程内 dirty | E corpus.note.changed + J | corpus_notes 触发器 + corpus.index(P1) |
| 11 | Obsidian 导入后全量重建 | obsidian.go:115,133 | goroutine,重启即丢 | J | 逐篇的触发器事件;goroutine 删除(P1) |
| 14 | 构建完成 → 首页自动发布 | builds.go:242 | 失败只记日志不重试 | E microsite.build.settled + J | owner.SettleBuild 在一个事务里写构建行、事件和 pg_notify('standmeet_build_settled', owner);订阅方 microsite.homepage_publish |
| 15 | 构建完成 → 重算素材引用 | builds.go:247,256 | 同上 | E + J | 订阅方 microsite.asset_refs |
| 16 | 构建完成 → 唤醒预览长轮询 | builds.go:193 → buildnotify | 进程内广播,多副本失效 | E(LISTEN/NOTIFY) | 按 owner 分键的 pgstore.Listener;版本号是该 owner 最近一次构建完成的持久毫秒时间戳;infra/buildnotify 删除 |
要搬的:后台 goroutine、周期任务、请求内外呼
| # | 做什么 | 原位置 | 原来怎么坏 | 去向 | 实现 |
|---|
| 22 | InvokeBackground(后台 supplier 调用 + retry) | invoke_background.go:49-65 | 重启即丢、无死信、单进程 | J supplier.invoke | internal/infra/sideeffect/supplier;invoke_background.go 和 notifyPolicy 删除(P4) |
| 23 | Obsidian 重建 goroutine | obsidian.go:135 | 同 #11 | J | 删除(P1) |
| 30 | 周期任务调度器本身 | periodic.go:47-92 | 每副本重复跑,状态在内存 | P | River 周期任务(选主、启动即跑);进程内调度器删除(P1) |
| 31 | Meili 8 秒对账 | corpus_index_periodic.go:40 | 靠进程内标记 | 删除 | 已删除(P1) |
| 32–36 | 公共对话清理、gas 补充、简历草稿清理、流量保留、用量清理 | 各 *_periodic.go | 每副本重复跑 | P | 以数据声明,跑在 River 上(P1) |
| 38 | 启动时 Meili 建索引 + 全量回填 | wire/search_index.go:22 | 拖慢启动 | J | 启动时入队 corpus.reindex(P1) |
| 39, 62 | 招聘源抓取(请求内串行抓全部源) | sources_write.go:133、jobfetch.go:149 | 慢,可能撞 30 秒写超时 | 每源一个 J + E jobs.fetched | fetch 队列上的 jobs.fetch_source;jobs.fetch_result。不做定时抓取:job-loop.md 否决了每日自动抓取(P4) |
| 63 | 出站邮件(请求内 retry.Do) | mail_retry.go:49、outbound_sender.go:103 | 请求被退避堵住 | J(覆盖 #1 #2 #4) | 邮件端口 internal/infra/sideeffect/mail;internal/infra/retry 删除(P4) |
| 98 | 邮件限流 | mailthrottle.go:82 | 静默丢弃 | 限流命中改为 snooze | 每个收件人每小时 30 封,用完 snooze 到下个窗口(P4) |
| 101 | monitor 面板的后台任务列表 | jobreg_registry.go:24 | 重启清零 | 改读 River 任务表 | JobRegistry 删除;instance.jobs 读 River(tasks-panel)(P1) |
客户端轮询 → 事件推送(Phase 5,未做)
| # | 轮询 | 位置 | 间隔 | 去向 |
|---|
| 50 | 微站列表长轮询 | use-microsites.ts:127 | 挂起请求 | SSE |
| 51 | 轮询构建直到完成 | use-microsites.ts:290 | 1.5s | SSE |
| 52 | 侧栏申请角标 | use-sidebar-badges.ts:39 | 60s | SSE |
| 54 | Google 日历 OAuth 后等连接 | use-gcal.ts:161 | 1s × 15 | SSE |
| 56 | SDK 掉线后恢复回合 | agent-adapters.ts:186 | 1s × 6 | SSE |
开放为 webhook 的事件类型(全部 thin,默认不订阅)
盘点列了 23 行,声明出来是 37 个类型,加上 corpus.note.changed 和 webhook.test,共 39 个,全部 Webhook 可见(event-model)。投递机制见 webhooks。
| # | 事件 | 期 |
|---|
| 75–76 | access_request.created / .approved / .status_changed | P2 |
| 77–78 | code.issued / .revoked / .redeemed | P2 |
| 79–82 | conversation.started / .message / .pruned、ghost.accepted | P2 |
| 83 | booking.created / .cancelled / .rescheduled | P4 |
| 84–85 | application.committed、jobs.fetched | P4 |
| 86–88 | writing.published / .unpublished、corpus.note.changed、vault.imported | P2 |
| 89–90 | microsite.build.settled、page.promoted_live / .rolled_back / .unpublished、microsite.store.doc_inserted | P4 |
| 91–93 | api_key.issued / .revoked、supplier.connected / .disconnected / .activated、block.installed / .failed | P2 |
| 94–97 | gas.exhausted / .refilled、instance.upgrade_requested、owner.login / .email_changed / .recovery_requested、ip_ban.added | P2 |
保持现状(K)
| 类别 | 项 |
|---|
| 写入只在同一库、且本身就是事实来源 | #12 交叉引用重建、#13 导入回执、#17 block 失败记录、#19 推理用量、#20 对话 / 卡片 / ghost、#21 已见招聘 id |
| 进程管道和服务器 | #24–#29 |
| 必须每个节点本地跑的沙箱清理 | #37 |
| updater 的文件信号与轮询(它故意不碰数据库) | #44–#46 |
| Telegram 长轮询与 im-bridge 内存会话 | #48–#49 |
| 访客必须当场拿到结果的外呼(现在只发一次,请求内不重试) | #64 日历、#66 媒体抓取、#67 模型列表、#68 规格校验、#71 OAuth、#72 验证码、#73 系统探测、#74 推理 |
| 会话与缓存失效 | #99–#100 |
| 升级后等重启(服务器正在重启) | #55 |
| 访问监控(已决定不进总线) | #18 |
存疑项的定案(按建议)
| # | 项 | 定案 | 理由 |
|---|
| 3 | 恢复短语邮件 | K,同步 | 用户在登录页等结果,必须当场告诉发没发出去 |
| 6 | 访客预约确认邮件 | K,同步 | 访客要当场得到确认;补偿删除改走 J(#7) |
| 41–43 | 构建队列(手写 SKIP LOCKED + 租约) | K 队列本身;完成后的钩子走 E | builder 是跨进程的 Node sidecar,现在就是持久队列,搬到 River 收益小 |
| 47 | im-bridge 15 秒轮询配置 | K,P5 再考虑 SSE | 配置极少变化 |
| 53 | 系统信息 1 秒刷新 | K | 实时指标,天生是轮询 |
| 57 | 访客工具“下次轮询出现” | P5 前先查清调用方 | 盘点没找到客户端调用点 |
| 59–61 | 投递提交 / 草稿 / 公开报告的 PDF 渲染 | K,同步 | 用户要的就是这份 PDF;投递故意“先渲染再提交” |
| 69 | 市场安装 | K | 安装结果当场要看 |
| 18 | 访问流量记录 | K | 已决定 |
| 75–97 | webhook 暴露面 | 全部可订阅,默认不订阅,一律 thin | 都是 owner 自己实例的事实;安全类事件恰好适合告警 |
顺手修掉的(不在计划里)
- SDK hydration:
BlockWidget 和 use-chat-session 在首次渲染时读 localStorage(预渲染的微站上报 React #418);改为挂载后再读。
plugin/mount 的 knownToolSpecs 数据竞争;Meili 的 Index 每次调用拿新的索引句柄;任务详情在切换行后还作用在旧任务上。
- 三处“wiki 对、代码错”:
schema.sql 缺 visit_event / visit_viewer(schema 一致性 UT 发现的);drafts_edit.go 里一条过时的 Typst 注释;手动升级文案让用户设置一个不存在的 STANDMEET_REDEPLOY_HOOK(8 种语言)。
被搬走的操作里有 4 个原来有调用方在等结果,它们的完成信号见 completion-hooks 与 async-response-contract。