feat(server): WebSocket 广播支持 Redis Pub/Sub 跨节点扇出(REDIS_ADDR 可选开关)

- 新增 ws.EventBus 接口与 RedisBus 实现;Hub 五类广播改为发布信封、各节点订阅后本地投递
- 单实例不设 REDIS_ADDR 保持进程内直投;Redis 不可用时自动兜底本地投递
- config 新增 REDIS_ADDR/REDIS_PASSWORD/REDIS_DB;dev compose 增加 --profile redis (6380)
- prod compose 与 .env.prod.example 增加多副本配置说明;README 更新已知边界
- 已用双实例 + 真实 Redis 实测跨节点推送;新增总线单测(含发布失败兜底),-race 通过
This commit is contained in:
yml2213
2026-09-05 14:39:56 +08:00
parent 8b9f3d710f
commit 10d2e2ed3d
11 changed files with 430 additions and 6 deletions
+152
View File
@@ -0,0 +1,152 @@
package ws
import (
"errors"
"sync"
"testing"
"time"
)
type fakeBus struct {
mu sync.Mutex
published []*busEnvelope
fail bool
}
func (f *fakeBus) Publish(env *busEnvelope) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail {
return errors.New("redis down")
}
f.published = append(f.published, env)
return nil
}
func (f *fakeBus) Subscribe(dispatch func(env *busEnvelope)) {}
func (f *fakeBus) Close() error { return nil }
func (f *fakeBus) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.published)
}
func TestBroadcastPublishesToBusInsteadOfLocalDelivery(t *testing.T) {
hub := NewHub()
go hub.Run()
bus := &fakeBus{}
hub.mu.Lock()
hub.bus = bus
hub.mu.Unlock()
sessionID := uint(12)
agentID := uint(7)
assigned := &Client{TenantID: 1, UserID: agentID, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)}
visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &sessionID, Send: make(chan []byte, 1)}
for _, client := range []*Client{assigned, visitor} {
hub.register <- client
}
hub.BroadcastToSession(1, sessionID, &agentID, []byte(`{"type":"message"}`))
if bus.count() != 1 {
t.Fatalf("应向总线发布 1 条信封,got=%d", bus.count())
}
// 发布模式下本进程不直接投递(由订阅回调统一投递,避免双发)
select {
case <-assigned.Send:
t.Fatal("发布模式不应直接投递本地客户端")
case <-time.After(100 * time.Millisecond):
}
// 订阅回调 dispatch 后应在本进程按范围投递
hub.dispatch(&busEnvelope{Scope: busScopeSession, TenantID: 1, SessionID: sessionID, AgentID: &agentID, Message: []byte(`{"type":"message"}`)})
for _, client := range []*Client{assigned, visitor} {
select {
case <-client.Send:
case <-time.After(time.Second):
t.Fatalf("dispatch 后客户端未收到消息:%+v", client)
}
}
}
func TestDispatchRoutesAllScopes(t *testing.T) {
hub := NewHub()
go hub.Run()
sessionID := uint(41)
agentID := uint(3)
assigned := &Client{TenantID: 1, UserID: agentID, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)}
supervisor := &Client{TenantID: 1, UserID: 4, Role: "supervisor", Kind: "agent", Send: make(chan []byte, 1)}
visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &sessionID, Send: make(chan []byte, 1)}
other := &Client{TenantID: 2, Kind: "agent", UserID: 9, Role: "agent", Send: make(chan []byte, 1)}
for _, client := range []*Client{assigned, supervisor, visitor, other} {
hub.register <- client
}
// tenant_staff
hub.dispatch(&busEnvelope{Scope: busScopeTenantStaff, TenantID: 1, Message: []byte(`{"type":"session_updated","session_id":41}`)})
for _, client := range []*Client{assigned, supervisor} {
select {
case <-client.Send:
case <-time.After(time.Second):
t.Fatalf("租户工作人员应收到 session_updated:%+v", client)
}
}
select {
case <-visitor.Send:
t.Fatal("访客不应收到 tenant_staff 广播")
case <-other.Send:
t.Fatal("跨租户客户端不应收到广播")
case <-time.After(100 * time.Millisecond):
}
// visitor
hub.dispatch(&busEnvelope{Scope: busScopeVisitor, TenantID: 1, SessionID: sessionID, Message: []byte(`{"type":"typing"}`)})
select {
case <-visitor.Send:
case <-time.After(time.Second):
t.Fatal("会话访客应收到消息")
}
// disconnect:跨节点踢下线(踢 supervisor,避免 assigned/visitor 被断开)
hub.dispatch(&busEnvelope{Scope: busScopeDisconnect, UserID: 4, Text: "账号已停用"})
deadline := time.After(time.Second)
for {
select {
case <-supervisor.Send:
// 先收到 kicked 通知,继续等连接关闭
case <-deadline:
t.Fatal("被踢客户端未收到任何消息")
default:
}
hub.mu.RLock()
_, stillOnline := hub.clients[supervisor]
hub.mu.RUnlock()
if !stillOnline {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestPublishFailureFallsBackToLocalDelivery(t *testing.T) {
hub := NewHub()
go hub.Run()
bus := &fakeBus{fail: true}
hub.mu.Lock()
hub.bus = bus
hub.mu.Unlock()
visitorSession := uint(51)
visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &visitorSession, Send: make(chan []byte, 1)}
hub.register <- visitor
hub.BroadcastToVisitor(1, visitorSession, []byte(`{"type":"typing"}`))
select {
case <-visitor.Send:
case <-time.After(time.Second):
t.Fatal("总线不可用时应兜底本进程投递")
}
}