Smart Note - 把 EaseOps 消息送进 Smart Note
# 前言
流水线失败的红灯只在 EaseOps 网页上亮,而人一天里眼睛都在 Smart Note 上。 这个功能的目标只有一句话:让「有事发生」主动送上门,但不制造第二份真相。
# 一、为什么放在 Smart Note 里
消息系统最难的部分从来不是"怎么推",而是"推了有没有人看见"。
EaseOps 消息天然落在它自己的网页里:流水线失败的红灯会在控制台亮,但控制台是被动打开的——你只有想起去看的时候才会看。而 Smart Note 是日常里一直开着的那个窗口:写方案、记会议、剪藏、随手记,一天里眼睛大部分时间停在它上面,桌面端还常驻托盘与贴边圆钮。
把消息送到这里,等于把送达位置从"需要主动访问的系统"换成"人已经在的地方"。这个位置变化带来的收益,比任何推送技术的选择都大:
- 不用多开一个入口。 不用为这件事多留一个浏览器标签、多装一个客户端、多记一套登录态。消息和正在写的东西在同一个窗口旁边。
- 常驻即到达。 网页关掉就什么都不知道;桌面应用跟着系统起来,贴边圆钮、托盘、Dock 至少有一处亮着。
- 时效敏感的消息才有时效。 流水线失败、部署中止这类消息的价值以分钟计,晚半小时看见和没看见差不多。
- 消息能直接接上下一步。 点开看详情与链接;看完把结论记进当天的笔记,消息与工作记录落在同一处——这是 Smart Note 独有的便利,换成 IM 得再搬一次。
也正因为如此,它不该长成第二个 IM。这里只做"送上门 + 点进去看":不落库、不做搜索与归档;已读也只是转手调对端接口,本地不维护状态。要长期归档与协作,EaseOps 自己的通知中心才是对的地方。Smart Note 是门铃,不是第二个信箱。
# 二、先想清楚:问题不是"怎么推送"
接到需求时的原始描述是三条:右上角一个带未读数的图标、点开是列表和详情、消息走 WebSocket 且支持多来源。
如果按这个描述直接写,很容易落进三个陷阱:
- 建一张本地消息表。既然要列表、要已读、要未读角标,顺手就存了。存下去之后你要面对:去重(同一条推两遍)、裁剪(保留多少条)、已读状态与对端不一致、断网时的空态、以及最烦的——用户在两台设备上都点过已读。
- 自己做未读加减。收到一条
+1,标记已读-1。看起来天经地义,实际要求"每一帧都是新消息且不重推",漏一帧、重放一帧,角标就长期偏了,而且偏了没人能修回来。 - 跨来源合并列表。多来源看起来应该是一张统一收件箱,但一合并,来源的字段语义就打架(一个系统的
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
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)' }
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 行前端(抽屉、列表、详情、提示卡与提示条)。
# 六、可以搬走的五条
- 一份真相。 只要有权威接口,就别在前端做加减。宁可多打两次请求,也不要一个长期慢慢偏的数字。
- 事件是信号,不是数据。 帧里只信 id,内容回查。这让你换后端字段时不会连带改提示逻辑。
- 配置而非代码。 字段名和路径写进 YAML,加一个系统从"复制一遍代码"变成"抄一段配置"。但配置的语义要小——取值 + 一个表达式,不要再长出模板语言。
- 可观测性先写。 "没有反应"是这类集成最贵的故障态;每帧一条日志的成本,比上线后隔着屏幕猜低一个数量级。
- 先验证协议,再设计路径。 绕路通常不是因为难,而是因为假设没验证。二十行探针能问清的事,不要用一周的实现去猜。
最后一句是本项目反复验证过的:每一个看起来很"设计"的决定,背后都有一次具体的、能复述给用户的故障。 反过来也成立——没有具体故障支撑的设计,多半是为了好看。