Smart Note - 把 EaseOps 消息送进 Smart Note

2026/10/25 探索消息

# 前言

流水线失败的红灯只在 EaseOps 网页上亮,而人一天里眼睛都在 Smart Note 上。 这个功能的目标只有一句话:让「有事发生」主动送上门,但不制造第二份真相。

# 一、为什么放在 Smart Note 里

消息系统最难的部分从来不是"怎么推",而是"推了有没有人看见"。

EaseOps 消息天然落在它自己的网页里:流水线失败的红灯会在控制台亮,但控制台是被动打开的——你只有想起去看的时候才会看。而 Smart Note 是日常里一直开着的那个窗口:写方案、记会议、剪藏、随手记,一天里眼睛大部分时间停在它上面,桌面端还常驻托盘与贴边圆钮。

把消息送到这里,等于把送达位置从"需要主动访问的系统"换成"人已经在的地方"。这个位置变化带来的收益,比任何推送技术的选择都大:

  • 不用多开一个入口。 不用为这件事多留一个浏览器标签、多装一个客户端、多记一套登录态。消息和正在写的东西在同一个窗口旁边。
  • 常驻即到达。 网页关掉就什么都不知道;桌面应用跟着系统起来,贴边圆钮、托盘、Dock 至少有一处亮着。
  • 时效敏感的消息才有时效。 流水线失败、部署中止这类消息的价值以分钟计,晚半小时看见和没看见差不多。
  • 消息能直接接上下一步。 点开看详情与链接;看完把结论记进当天的笔记,消息与工作记录落在同一处——这是 Smart Note 独有的便利,换成 IM 得再搬一次。

也正因为如此,它不该长成第二个 IM。这里只做"送上门 + 点进去看":不落库、不做搜索与归档;已读也只是转手调对端接口,本地不维护状态。要长期归档与协作,EaseOps 自己的通知中心才是对的地方。Smart Note 是门铃,不是第二个信箱。

# 二、先想清楚:问题不是"怎么推送"

接到需求时的原始描述是三条:右上角一个带未读数的图标、点开是列表和详情、消息走 WebSocket 且支持多来源。

如果按这个描述直接写,很容易落进三个陷阱:

  1. 建一张本地消息表。既然要列表、要已读、要未读角标,顺手就存了。存下去之后你要面对:去重(同一条推两遍)、裁剪(保留多少条)、已读状态与对端不一致、断网时的空态、以及最烦的——用户在两台设备上都点过已读。
  2. 自己做未读加减。收到一条 +1,标记已读 -1。看起来天经地义,实际要求"每一帧都是新消息且不重推",漏一帧、重放一帧,角标就长期偏了,而且偏了没人能修回来。
  3. 跨来源合并列表。多来源看起来应该是一张统一收件箱,但一合并,来源的字段语义就打架(一个系统的 id 是 itemId,另一个是 messageId),排序也无法解释,点击定位更没有落点。

最后落下来的原则是三条,后面所有设计都是从这三条推出来的:

  • 消息不是笔记,所有权在对端。 桌面端是视口,不是仓库。列表、详情、标记已读全部实时调对端接口,本地零存储。
  • 未读数只有一个来源。 只认对端的 inbox-unread-count 接口;事件到达后回查,不做任何本地加减。
  • 事件只是信号,不是数据。 帧告诉你有事发生、可能是哪一条;内容一律回查,因为帧里的字段不可信。

代价写在明面上:断网即空、没有搜索、没有"全部已读"(对端没这个接口)。换来的是整个"缓存一致性"问题域直接消失。

# 三、边界:凭据不出壳层,渲染只在壳层

前端(Wails 里的 webview)不直接发请求,全部走 Go 侧代理方法:

ListMessages(sourceKey string, page int) (MessagePageDTO, error)
GetMessageDetail(sourceKey, id string) (MessageDTO, error)
MarkMessageRead(sourceKey, id string) error
GetMessageUnread() ([]SourceUnreadDTO, error)
OpenExternalURL(url string) error
1
2
3
4
5

三个直接收益:

  • 没有 CORS。Wails 的前端是 wails:// 源,直连对端必然撞预检;Go 侧发请求没这问题。
  • token 停在壳层。配置文件在 ~/.smart-note/messages.yaml(0600),前端永远看不到凭据。
  • 统一结构。不管对端字段怎么起名,跨过绑定边界的已经是 {title, level, ts, read, attrs, links}。

链接打开也是壳层的事:仅 http / https 交给系统浏览器,其余 scheme 直接拒。正文里的 <a> 全被拦截改写,webview 自己不会跳走——这条不做,点一个相对链接就会把整个应用页面替换掉。

# 四、适配层:配置驱动,而不是"接一个系统写一套代码"

EaseOps 的字段长这样:列表项 itemId / createdAt / message.title / projectName,详情在 data.message.body,未读数分 projectCount + platformCount。

如果把这些名字写进 Go 代码,第二个系统(比如 GitLab 或内部工单)进来就要复制一遍结构体、一遍分页、一遍错误处理。所以做成一层声明式的适配规则:取值用 gjson,变换用表达式。

- key: devops
  basic_auth: { username: <AK id>, password: <AK secret> }
  ws:
    url: "wss://devops.itart.cn/ws/v1/user-notify"
    type_field: event          # 注意不是 type:type 恒为 "event"
    events:
      inbox_message: { action: notify, id: { from: data.messageId } }
      inbox_read:    { action: refresh }
  api:
    list:
      path: /api/v1/inbox-items
      items: data.items
      map:
        id:    { from: itemId, required: true }
        title:
          expr: 'join(" ", bracket(json.projectName), json.message.title)'
        read:  { from: readAt, expr: 'value != nil' }
    count:
      path: /api/v1/inbox-unread-count
      unread:
        - { from: data.projectCount,  expr: 'int(value)' }
        - { from: data.platformCount, expr: 'int(value)' }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

这层配置解决三件事:

  • 字段名不进代码。 换一个系统只换 YAML:路径、字段、分页参数、鉴权方式都写在配置里。
  • 变换不进代码。 拼项目名前缀、判空、类型转换、枚举映射都由一条取值 + 一个表达式表达,不用为每个系统写一遍 DTO 转换。
  • 一个来源写坏不拖累别的。 配置按来源隔离加载与告警:某个来源的表达式或路径写错,它自己在诊断面板里亮红灯,其余来源照常收发。

配置的语义要小 —— 取值 + 一个表达式就够,不要再往上长出模板语言和循环。

# 五、代价与边界

做了 没做(以及原因)
配置驱动多来源,一条 YAML 接一个新系统 配置的图形化编辑与热加载(改完重启,够用)
应用内提示卡 + 角标 + 托盘 / 圆钮 / Dock 可点击的系统通知(需 UserNotifications + 签名打包)
HTML 正文经 DOMPurify 白名单渲染 Markdown 渲染、消息搜索
按源隔离失败,坏源不拖累好源 本地缓存、离线可读、跨源统一视图
单条已读 "全部已读"(对端没有 readAll 接口)

规模上大概 2.5k 行 Go(配置加载 / 映射引擎 / HTTP 代理 / WS 集线器)+ 1.3k 行 Go 测试(25 个消息相关单测:gjson 路径、表达式拼标题、判空、int 强转、业务码判定、帧路由、分页推导)+ 约 3k 行前端(抽屉、列表、详情、提示卡与提示条)。

# 六、可以搬走的五条

  1. 一份真相。 只要有权威接口,就别在前端做加减。宁可多打两次请求,也不要一个长期慢慢偏的数字。
  2. 事件是信号,不是数据。 帧里只信 id,内容回查。这让你换后端字段时不会连带改提示逻辑。
  3. 配置而非代码。 字段名和路径写进 YAML,加一个系统从"复制一遍代码"变成"抄一段配置"。但配置的语义要小——取值 + 一个表达式,不要再长出模板语言。
  4. 可观测性先写。 "没有反应"是这类集成最贵的故障态;每帧一条日志的成本,比上线后隔着屏幕猜低一个数量级。
  5. 先验证协议,再设计路径。 绕路通常不是因为难,而是因为假设没验证。二十行探针能问清的事,不要用一周的实现去猜。

最后一句是本项目反复验证过的:每一个看起来很"设计"的决定,背后都有一次具体的、能复述给用户的故障。 反过来也成立——没有具体故障支撑的设计,多半是为了好看。