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:
@@ -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("总线不可用时应兜底本进程投递")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user