chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
108
server/internal/sse/e2e_test.go
Normal file
108
server/internal/sse/e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
369
server/internal/sse/manager.go
Normal file
369
server/internal/sse/manager.go
Normal file
@ -0,0 +1,369 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
AgentName string // 非空 = Agent 侧连接
|
||||
UserName string // 非空 = 已登录人类用户的前端连接
|
||||
Res http.ResponseWriter
|
||||
Flusher http.Flusher
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Manager 管理所有 SSE 客户端连接
|
||||
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),
|
||||
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, r *http.Request, agentName, userName string) *Client {
|
||||
flusher, ok := res.(http.Flusher)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := uuid.New().String()[:8]
|
||||
client := &Client{
|
||||
ID: id,
|
||||
AgentName: agentName,
|
||||
UserName: userName,
|
||||
Res: res,
|
||||
Flusher: flusher,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.Header().Set("Content-Type", "text/event-stream")
|
||||
res.Header().Set("Cache-Control", "no-cache")
|
||||
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()
|
||||
|
||||
// 发送连接确认(带 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) 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()
|
||||
if c, ok := m.clients[id]; ok {
|
||||
close(c.done)
|
||||
delete(m.clients, id)
|
||||
fmt.Printf("[SSE] Client disconnected: %s\n", id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendToAgent 向指定 Agent 名的所有客户端推送事件
|
||||
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.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 向指定人类用户的所有前端连接推送事件
|
||||
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.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToRecipient 根据收件人名同时尝试 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.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCount 返回当前连接数
|
||||
func (m *Manager) ClientCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.clients)
|
||||
}
|
||||
|
||||
// Send 向单个客户端发送事件(无 ID)
|
||||
func (c *Client) Send(eventType string, data interface{}) {
|
||||
defer func() { recover() }()
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
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)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
defer func() { recover() }()
|
||||
fmt.Fprintf(client.Res, ": heartbeat\n\n")
|
||||
client.Flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
100
server/internal/sse/manager_test.go
Normal file
100
server/internal/sse/manager_test.go
Normal 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,不验证内容(并发下顺序无意义)
|
||||
}
|
||||
Reference in New Issue
Block a user