feat: SSE Last-Event-ID 补投 + 连接状态指示 + 限速器 DB 化

## SSE Last-Event-ID 补投

EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了——
所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。

改为 per-user 事件环形缓冲区(500 条,~100KB/用户,20 在线 ≈ 2MB):
每次 Broadcast/SendToUser/SendToAgent 同时写入对应用户的缓冲区;
AddClient 时取 Last-Event-ID 头,找到该 ID 的位置后从下一条回放。
找不到 ID 说明事件已被覆盖(缓冲区溢出),从头回放全部。

事件 ID 用全局递增序列号(非 UUID),EventSource 的 Last-Event-ID
就是靠这个 ID 记住断点的。

新增测试:缓冲区回放、溢出行为、并发安全(10 goroutine × 200 次 push)、
端到端重连验证(SendToUser → 带 Last-Event-ID 的 AddClient → 补投)。

## 连接状态指示器

Sidebar 用户头像右下角的小圆点:绿=已连接,黄=连接中,橙=重连中,红=断开。
NarrowNav 底栏也有(移动端)。

SSE 模块新增 onSSEStatus/getSSEStatus 接口,onerror/onopen 驱动状态变化。
状态点用 absolute 定位在头像边缘,不遮挡文字。

## 限速器 DB 化(解决多实例部署时的计数漂移)

原实现:LoginLimiter 与 sessionRateLimiter 都是进程内内存计数器。
多实例部署时各自独立计数,等效上限变成 N 倍。

改为 rate_limits 表(bucket + ts),两个限速器共享同一套基础设施:
- LoginLimiter:bucket="login:<username>",COUNT(*) >= 5 → 锁定 5 分钟
- sessionRateLimiter:bucket="session:<agent_name>",COUNT(*) >= 20/h → 拒绝

判断与写入在同一个 BEGIN IMMEDIATE 事务里——SQLite 的 IMMEDIATE
在事务开始时获取 RESERVED 锁,防并发写事务同时进入 COMMIT 阶段。
实测 80 并发下恰好放行 20 次(旧内存版同样通过,但 DB 版才能多实例共享)。

DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
新建 rate_limits 表迁移(SQLite + PG 两版)。
This commit is contained in:
2026-09-02 14:33:41 +08:00
parent 07e6b789b2
commit 9d4718a412
18 changed files with 760 additions and 243 deletions

View File

@ -0,0 +1,108 @@
package sse
import (
"net/http/httptest"
"strings"
"testing"
)
// 一个可 Flush 的 ResponseWriter,供集成测试用。
type flushWriter struct {
*httptest.ResponseRecorder
flushed chan bool
}
func (f *flushWriter) Flush() {
select {
case f.flushed <- true:
default:
}
}
// 端到端验证:Manager 完整走一遍「事件入缓冲区 → 新连接带 Last-Event-ID 重连 → 补投」。
// 这是生产里最关键的可靠性路径 —— 断线期间收的邮件,重连后必须能看到。
func TestManagerReplayOnReconnect(t *testing.T) {
m := &Manager{
clients: make(map[string]*Client),
eventBuffer: make(map[string]*eventRing),
}
// 1) 用户 alice 发来一封信(无人连接也入缓冲区)
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m1"})
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m2"})
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m3"})
ring := m.eventBuffer["u:alice"]
if ring == nil {
t.Fatal("alice 的缓冲区应该已创建")
}
// 2) 带 Last-Event-ID=1 重连,应补投 m2、m3(跳过 m1)
req := httptest.NewRequest("GET", "/events/stream", nil)
req.Header.Set("Last-Event-ID", "1")
w := &flushWriter{httptest.NewRecorder(), make(chan bool, 10)}
client := m.AddClient(w, req, "", "alice")
if client == nil {
t.Fatal("AddClient 返回 nil(flushWriter 应支持 Flush)")
}
defer m.RemoveClient(client.ID)
body := w.Body.String()
if !strings.Contains(body, `"mail_id":"m2"`) {
t.Error("重连后应补投 m2,实际 body:", body)
}
if !strings.Contains(body, `"mail_id":"m3"`) {
t.Error("重连后应补投 m3,实际 body:", body)
}
if strings.Contains(body, `"mail_id":"m1"`) {
t.Error("已确认的 m1 不应重放(Last-Event-ID=1),实际 body:", body)
}
// 3) 连接期间新来一封信,实时推送
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m4"})
body = w.Body.String()
if !strings.Contains(body, `"mail_id":"m4"`) {
t.Error("在线连接应实时收到 m4,实际 body:", body)
}
}
// 序列号全局递增,两条不同事件不同 ID。
func TestEventIDMonotonic(t *testing.T) {
m := &Manager{
clients: make(map[string]*Client),
eventBuffer: make(map[string]*eventRing),
}
a := m.nextEventID()
b := m.nextEventID()
if a == b {
t.Fatalf("两个连续事件 ID 相同: %q", a)
}
if a > b {
t.Fatalf("事件 ID 应递增: %q > %q", a, b)
}
}
// 确保事件 ID 写进了 SSE 帧(EventSource 靠 id: 行记住位置)
func TestSendWritesIDField(t *testing.T) {
fw := &flushWriter{httptest.NewRecorder(), make(chan bool, 5)}
c := &Client{
ID: "c1",
UserName: "alice",
Res: fw,
Flusher: fw,
done: make(chan struct{}),
}
c.SendWithID("42", "new_mail", map[string]string{"x": "y"})
body := fw.Body.String()
if !strings.Contains(body, "id: 42\n") {
t.Error("帧里应有 id: 42 行,实际:", body)
}
if !strings.Contains(body, "event: new_mail") {
t.Error("帧里应有 event: new_mail,实际:", body)
}
if !strings.Contains(body, `data: {"x":"y"}`) {
t.Error("帧里应有 data,实际:", body)
}
}

View File

@ -10,6 +10,91 @@ import (
"github.com/google/uuid"
)
// eventRing 是单用户事件的有界环形缓冲区。
//
// EventSource 断线重连时自带 Last-Event-ID 头:服务端据此回放断线期间的事件。
// 没有它,重连后永远看不到断线期间收到的邮件 —— 而这正是实时协作的体验核心。
//
// 缓冲区大小 500 条:一条事件约 200B(typical),500 条 ≈ 100KB/用户。
// 20 个在线用户 ≈ 2MB,远低于 OOM 风险。
type eventRing struct {
mu sync.Mutex
events []StoredEvent
cap int
head int // 下一次写入的位置
full bool
}
// StoredEvent 是缓冲区中的单条事件。
type StoredEvent struct {
ID string // 自增序列号,EventSource 的 Last-Event-ID 值
EventType string
Data []byte
Timestamp time.Time
}
func newEventRing(cap int) *eventRing {
return &eventRing{events: make([]StoredEvent, cap), cap: cap}
}
// push 追加一条事件到缓冲区。满了就覆盖最旧的。
func (r *eventRing) push(evt StoredEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.events[r.head] = evt
r.head = (r.head + 1) % r.cap
if r.head == 0 && !r.full {
r.full = true
}
}
// replay 从 afterID 之后的所有事件回放给 ResponseWriter。
// afterID 为空时:缓冲区未满不回放(首次连接无历史);满了也不回放
// (首次连接的 EventSource 不传 Last-Event-ID)。
// afterID 非空时:找到该 ID 的位置,从下一条开始回放。
func (r *eventRing) replay(afterID string, flush http.Flusher, res http.ResponseWriter) {
r.mu.Lock()
defer r.mu.Unlock()
if afterID == "" {
return // 首次连接,不回放
}
start := -1
total := r.cap
for i := 0; i < r.cap; i++ {
idx := (r.head + i) % r.cap
if r.events[idx].ID == afterID {
start = (idx + 1) % r.cap
break
}
}
if start == -1 {
// afterID 不在缓冲区里(已被覆盖或从未存在),
// 回放缓冲区里所有事件 —— 宁可重复也不丢失
start = 0
if !r.full {
total = r.head
}
} else {
// 从 start 开始到 head 结束
total = r.head - start
if total < 0 {
total += r.cap
}
}
for i := 0; i < total; i++ {
idx := (start + i) % r.cap
evt := &r.events[idx]
if evt.ID == "" {
continue
}
fmt.Fprintf(res, "id: %s\nevent: %s\ndata: %s\n\n", evt.ID, evt.EventType, evt.Data)
}
flush.Flush()
}
// Client 是一个 SSE 连接客户端
type Client struct {
ID string
@ -24,14 +109,56 @@ type Client struct {
type Manager struct {
mu sync.RWMutex
clients map[string]*Client
// eventBuffer:per-user/agent 的事件环形缓冲区,供 Last-Event-ID 回放。
// Key 是 userName(人类)或 agentName(Agent),二者共享一个 map。
// 不是连接级别的 —— 同一用户断线重连后仍能从同一个缓冲区拿到断线期间的事件。
eventBuffer map[string]*eventRing
bufMu sync.RWMutex
seqCounter uint64 // 全局递增序列号,用作事件 ID
seqMu sync.Mutex
}
// Default 是全局 SSE 管理器
var Default = &Manager{
clients: make(map[string]*Client),
clients: make(map[string]*Client),
eventBuffer: make(map[string]*eventRing),
}
const eventBufferCap = 500 // 每用户最多保留 500 条事件
// nextEventID 生成下一个全局递增的事件 ID
func (m *Manager) nextEventID() string {
m.seqMu.Lock()
defer m.seqMu.Unlock()
m.seqCounter++
return fmt.Sprintf("%d", m.seqCounter)
}
// getOrCreateRing 获取或创建用户的环形缓冲区
func (m *Manager) getOrCreateRing(key string) *eventRing {
if key == "" {
return nil
}
m.bufMu.RLock()
ring, ok := m.eventBuffer[key]
m.bufMu.RUnlock()
if ok {
return ring
}
m.bufMu.Lock()
defer m.bufMu.Unlock()
// double-check
if ring, ok = m.eventBuffer[key]; ok {
return ring
}
ring = newEventRing(eventBufferCap)
m.eventBuffer[key] = ring
return ring
}
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client {
func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName, userName string) *Client {
flusher, ok := res.(http.Flusher)
if !ok {
return nil
@ -53,20 +180,40 @@ func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string)
res.Header().Set("Connection", "keep-alive")
res.Header().Set("X-Accel-Buffering", "no")
// Last-Event-ID 回放:EventSource 断线重连时自带这个头,
// 服务端据此把断线期间的事件补上 —— 否则重连后永远看不到那段时间的邮件。
lastID := r.Header.Get("Last-Event-ID")
key := m.bufferKey(userName, agentName)
if ring := m.getOrCreateRing(key); ring != nil && lastID != "" {
ring.replay(lastID, flusher, res)
}
m.mu.Lock()
m.clients[id] = client
m.mu.Unlock()
// 发送连接确认
client.Send("connected", map[string]string{"id": id})
// 发送连接确认(带 id 让客户端知道自己的 ID)
evtID := m.nextEventID()
client.SendWithID(evtID, "connected", map[string]string{"id": id})
// 启动心跳
go m.heartbeat(client)
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName)
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q) lastID=%q\n", id, agentName, userName, lastID)
return client
}
// bufferKey 返回缓冲区 key:优先 userName(人类),其次 agentName(Agent)
func (m *Manager) bufferKey(userName, agentName string) string {
if userName != "" {
return "u:" + userName
}
if agentName != "" {
return "a:" + agentName
}
return ""
}
// RemoveClient 移除一个客户端
func (m *Manager) RemoveClient(id string) {
m.mu.Lock()
@ -83,12 +230,20 @@ func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
if agentName == "" {
return
}
// 写入缓冲区
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
if ring := m.getOrCreateRing(m.bufferKey("", agentName)); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == agentName {
c.Send(eventType, data)
c.SendWithID(evtID, eventType, data)
}
}
}
@ -98,39 +253,68 @@ func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
if userName == "" {
return
}
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
if ring := m.getOrCreateRing(m.bufferKey(userName, "")); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.UserName == userName {
c.Send(eventType, data)
c.SendWithID(evtID, eventType, data)
}
}
}
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent)
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
if name == "" {
return
}
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
// 同时写两个缓冲区(人类或 Agent,或两者都有)
if ring := m.getOrCreateRing(m.bufferKey(name, "")); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
if ring := m.getOrCreateRing(m.bufferKey("", name)); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == name || c.UserName == name {
c.Send(eventType, data)
c.SendWithID(evtID, eventType, data)
}
}
}
// Broadcast 向所有客户端广播事件
// Broadcast 向所有客户端广播事件(心跳、系统通知等)
func (m *Manager) Broadcast(eventType string, data interface{}) {
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
// 广播写入所有用户的缓冲区(确保任何用户重连都能回放)
m.bufMu.RLock()
for key, ring := range m.eventBuffer {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
_ = key // key 仅用于日志,此处不需
}
m.bufMu.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
c.Send(eventType, data)
c.SendWithID(evtID, eventType, data)
}
}
@ -141,9 +325,9 @@ func (m *Manager) ClientCount() int {
return len(m.clients)
}
// Send 向单个客户端发送事件
// Send 向单个客户端发送事件(无 ID)
func (c *Client) Send(eventType string, data interface{}) {
defer func() { recover() }() // 防止向已关闭的连接写入 panic
defer func() { recover() }()
jsonData, err := json.Marshal(data)
if err != nil {
@ -154,6 +338,19 @@ func (c *Client) Send(eventType string, data interface{}) {
c.Flusher.Flush()
}
// SendWithID 向单个客户端发送带 ID 的事件
func (c *Client) SendWithID(id, eventType string, data interface{}) {
defer func() { recover() }()
jsonData, err := json.Marshal(data)
if err != nil {
return
}
fmt.Fprintf(c.Res, "id: %s\nevent: %s\ndata: %s\n\n", id, eventType, jsonData)
c.Flusher.Flush()
}
// heartbeat 定期发送心跳保活
func (m *Manager) heartbeat(client *Client) {
ticker := time.NewTicker(30 * time.Second)
@ -164,7 +361,6 @@ func (m *Manager) heartbeat(client *Client) {
case <-client.done:
return
case <-ticker.C:
// SSE 注释行作为心跳
defer func() { recover() }()
fmt.Fprintf(client.Res, ": heartbeat\n\n")
client.Flusher.Flush()

View File

@ -0,0 +1,100 @@
package sse
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestEventRingPushReplay(t *testing.T) {
ring := newEventRing(5)
// 推 3 条
for i := 1; i <= 3; i++ {
ring.push(StoredEvent{
ID: string(rune('0' + i)),
EventType: "test",
Data: []byte(`{"n":` + string(rune('0'+i)) + `}`),
Timestamp: time.Now(),
})
}
// 空 afterID → 首次连接,不回放(缓冲区未满)
rec := httptest.NewRecorder()
ring.replay("", rec, rec)
if rec.Body.Len() > 0 {
t.Error("首次连接不应回放事件,实际:", rec.Body.String())
}
// 有 afterID → 从下一条开始回放
rec2 := httptest.NewRecorder()
ring.replay("1", rec2, rec2)
body := rec2.Body.String()
if !strings.Contains(body, "id: 2") {
t.Error("afterID=1 应该回放 id:2,实际:", body)
}
if !strings.Contains(body, "id: 3") {
t.Error("afterID=1 应该回放 id:3,实际:", body)
}
if strings.Contains(body, "id: 1") {
t.Error("afterID=1 不应回放 id:1,实际:", body)
}
// 不存在的 afterID → 从头回放全部
rec3 := httptest.NewRecorder()
ring.replay("999", rec3, rec3)
body3 := rec3.Body.String()
if !strings.Contains(body3, "id: 1") {
t.Error("不存在的 afterID 应从头回放,实际:", body3)
}
}
func TestEventRingOverflow(t *testing.T) {
ring := newEventRing(3)
// 推 5 条(超过容量 3,最旧的 2 条被覆盖)
for i := 1; i <= 5; i++ {
ring.push(StoredEvent{
ID: string(rune('0' + i)),
EventType: "test",
Data: []byte(`{}`),
Timestamp: time.Now(),
})
}
if !ring.full {
t.Fatal("推了 5 条进容量 3 的缓冲区,应该已满")
}
// afterID=2 已被覆盖 → 找不到位置,从头回放全部
rec := httptest.NewRecorder()
ring.replay("2", rec, rec)
body := rec.Body.String()
if !strings.Contains(body, "id: 3") || !strings.Contains(body, "id: 5") {
t.Error("缓冲区溢出后应能回放可用范围,实际:", body)
}
}
func TestEventRingConcurrent(t *testing.T) {
ring := newEventRing(100)
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 200; j++ {
ring.push(StoredEvent{
ID: "evt",
EventType: "test",
Data: []byte(`{}`),
Timestamp: time.Now(),
})
}
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
// 只验证不 panic,不验证内容(并发下顺序无意义)
}