realtime-hub 简档(2026-10-11 立项拍板)
状态:M1–M3 已落地(2026-10-12,见下方 M3 小节)。拍板记录见
docs/backlog.zh.md待办 #1;架构定位见docs/architecture/overview.zh.md。
目标
统一实时推送通道:站内通知即时送达(替代 web 端 30s 轮询的延迟与空转)、 后续承载在线状态 / 已读回执 / 私信等实时语义。移动端本地推送(expo) 不受影响;远程推送接桥列为远期项。
现状与约束
- 网关代理带不动 WS:api-gateway 各 upstream 转发是自研缓冲式实现 (读 body → 重建请求 →
http.Client往返,internal/proxy/upstream.go), 无 Hijack。M1/M2 客户端直连 hub;M3 已补/ws原始字节透传隧道 (Hijack + 双向 splice,internal/realtime),客户端默认走网关, 直连(VITE_REALTIME_HUB_URL)保留。 - 端口:8893(hub WS/HTTP);9091-9097 已被各服务 /metrics 占用,hub 指标面 M1 不开。
- 鉴权:与各服务同款 HS256 JWT(共享
JWT_SECRET,claims 结构同utils.JWTClaims)。浏览器 WS 无法带自定义 header,握手 token 走 query(本地部署口径,访问日志注意脱敏);Origin 不设限(JWT 是唯一 准入闸,令牌不随跨域自动携带)。 - 单实例内存 registry 起步;水平扩展(Redis pub/sub)M3 已落地 (
RedisAddr配置启用,尽力而为 + 回环剔除)。
接口设计
| 端点 | 方法 | 说明 |
|---|---|---|
/health | GET | 探活,200 |
/ws?token=<jwt> | GET(Upgrade) | 握手校验 JWT,失败回 401 拒绝(Upgrade 前,不产生半开连接);成功后进 registry(userId → 多连接) |
/internal/publish | POST | 服务间投递。体 {"user_ids":[..],"event":{"type":..,"payload":..}};头 X-Internal-Token 必须匹配配置(未配置=403 拒绝),投递失败不影响调用方主链路 |
- 帧格式:JSON 文本帧
{"type":"notification","data":{...}};服务端 ping/pong 保活(30s ping / 读超时 60s,pong 刷新)。 - 背压:每连接有界发送队列(16),溢出丢弃并计数记日志,发布方永不阻塞。
- 事件类型 M1 只定义
notification;后续类型增量注册,向后兼容。
分期
M1 骨架(已完成,commit c8f978e)
services/realtime-hub 独立 Go 模块(module path github.com/tappi/tappi/services/realtime-hub,Go 1.26,websocket 库 github.com/gorilla/websocket):config / main / hub registry / ws handler / publish handler;go.work 纳入(CI 按 go.work 动态发现,自动 覆盖 -race);compose 加服务与 JWT_SECRET/REALTIME_INTERNAL_TOKEN 注入;服务 README。
M2 接入(已完成,2026-10-11)
- community:通知创建(like/comment/reply/follow 四触发点)后 fire-and-forget publish,写失败仅记日志(与通知写同款契约)。
- web:通知中心接 WS 实时 +1;断线自动重连(指数退避),降级回 30s 轮询(保留现有轮询代码作为兜底,hub 不可达不损功能)。
M2 落地记录
- community:
internal/realtime发布器(Url 空 = no-op,3s 超时 goroutine, 失败仅 logx),Realtime配置段(REALTIME_HUB_URL/REALTIME_INTERNAL_TOKEN环境变量);四触发点通知落库成功后推帧,落库失败不推(防幽灵红点)。 - web:
src/lib/realtime.ts(指数退避重连 1s→30s 封顶,静默降级),App 铃铛 帧到达即重拉未读数;VITE_REALTIME_HUB_URL 曾进 compose build args,M3 隧道落地后已移除(web 默认走网关,直连变量保留)。 - 端到端冒烟(compose 实测):注册 u1/u2 → u1 发帖 → u1 挂 WS → u2 点赞 → u1 收到
{"type":"notification","data":{...like_post...}}帧;停 hub 后 点赞仍 200,publish 失败仅 error 日志。
M3 远期 → 已全部落地(2026-10-12 用户令连续推进)
网关 WS 隧道、已读回执广播、Redis pub/sub 水平扩展、移动端远程推送 接桥、语音/组队信令(对应 overview.md 的 realtime-hub 愿景)。
M3 余项落地记录(2026-10-12)
- 网关 WS 隧道(
1d38531):网关/ws原始字节透传——Hijack 客户端 连接 + 重写请求行/Host(其余头原样透传,Connection/Upgrade/Sec-WebSocket-* 即握手本体不剥离)+ 双向 splice;hub 不可达 Hijack 前回 502;JWT 校验 仍在 hub,网关补 X-Forwarded-For。Upstreams.RealtimeHub未配置则不 注册 /ws(hub 可选部署)。测试覆盖 go-zero 全量中间件链(WithCode/ Timeout 包装层 Hijack 委托实测)与裸 writer 两路。顺带修真 bug: hub gorilla 默认 CheckOrigin 只放行同源,浏览器跨源(:4173 → :8893) 握手必 403,M2 web 浏览器路径一直在静默降级轮询(curl 冒烟测不出); 现准入闸只有 JWT query(令牌不随跨域自动携带,Origin 校验无安全增量)。 web 默认ws://localhost:8800走隧道(VITE_REALTIME_HUB_URL可切直连)。 - 已读回执(
3c06d95):community mark-all-read 成功后向本人全部 连接广播notification.read帧(重播幂等无害),多端徽标即时归零; 写库失败不发布。 - Redis 扩展(
83d2fa5):RedisAddr空 = 单实例(默认,行为不变); 配置即启用 pub/sub relay(频道realtime-hub:events,信封带 node id, 回环剔除;发布/订阅断连尽力而为只记日志;坏地址启动期 fail fast)。 两个非显然坑已堵:go-redis v9 订阅惰性建立(Receive等确认,否则 早期消息漏收);node id 含随机后缀(同进程多 relay 测试不互认回环)。 在线状态/watch 沿事件仍为实例本地真值(不跨实例同步)。 - 推送桥(
a4b7ca9):PushWebhook空不推;单实例模式零投递用户 POST webhook(X-Push-Token,user_ids 去重合并单请求,fire-and-forget 3s 超时),厂商协议(APNs/FCM)由桥接服务接——凭据为外部依赖,接缝 先行。多实例下本地零投递 ≠ 离线,不推(分布式推送决策留部署面)。 顺手收紧:publish 重复 user_ids 只投一次(原逐次扇出)。 - 私信消费(
f08e19d/960541c,2026-10-12 巡检点火批次):community 落库后Publish("message", [收件人], {message_id,from_id,from_name,content,created_at}), 会话已读回执Publish("message.read", [本人], {unread_count,peer_id})——hub 与网关零改动(帧通道/隧道/推送桥现成),web 消息页与信封徽标实时刷新。 - 语音信令(
acdc8ac):上行{"action":"signal","to":..,"room":.., "data":{..}}无状态转发(offer/answer/ICE 透传,hub 不解析不存房间), 目标全部连接收、发端连接排除(本端多设备自信号不回环);Relay 模式 跨实例兜底广播;目标离线静默丢弃(重试归端上协商层);尺寸上限即 上行读限 4096。
M3-1 在线状态(已完成,2026-10-11 拍板开工)
单实例内存档,不依赖网关隧道与 Redis。
设计口径:
- 在线定义:userId 活连接 ≥1;事件只在转换沿触发(首连/末断), 多端上下线不重复刷帧。
- 三个面:REST 服务间查询
GET /internal/presence?user_ids=1,2,3(X-Internal-Token,fail closed 同 publish,ids 缺失/非法 400); WS watch 推送(上行{"action":"watch|unwatch","user_ids":[..]}, 转换沿 → watcher 收{"type":"presence","data":{user_id,online,at}}); WS query 快照(上行{"action":"query","user_ids":[..]},只回请求 连接——终端拿不到内部令牌,拉取走 WS 既有 JWT 闸)。 - 内存有界:单客户端 watch 上限 200(超限静默截断)、单次 ids 上限 200;畸形上行静默忽略不断连;hub 不存社会关系(watch 谁由客户端 声明,隐私过滤在产品层)。
- 无新配置、无新依赖;顺手修掉 SendToUsers 在 RLock 下 drops++ 的潜在竞态(改 atomic)。
落地记录:hub 增 since/watchers 表与 Watch/Unwatch/Presence/ replyPresence(转换沿事件锁外投递);handler 增 presence 端点与 路由;新增 hub 6 测 + handler 2 测(含 A watch 7 → 7 连收 online、 REST 快照一致、畸形上行存活、7 断收 offline 端到端);顺手修掉 既有 fanout 测试的入表时序竞态(Dial 返回 ≠ 服务端 Join 已执行, publish 前轮询连接数)。
验收口径
- M1:
go test ./... -race绿;集成——两客户端同连、publish 后各收 一帧、无效 JWT 拒断、publish 缺/错 token 403;docker compose build realtime-hub && up后/health200;CI 全绿。 - M2:web 铃铛收到实时 +1(端到端手动口径 + 集成测试);停 hub 后 web 自动降级轮询不断功能;web 三连门禁绿。
- M3:逐项立项时再定。
铁律沿用
非交互;禁 tag/release、禁 force push;push 前 fetch+rebase origin/main; 每增量测试全绿才提交推送;commit 英文短句;只 add 自己改的文件;Go module path github.com/tappi/tappi 永不改;只推 main。