- 新增 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 通过
153 lines
4.3 KiB
Go
153 lines
4.3 KiB
Go
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("总线不可用时应兜底本进程投递")
|
||
}
|
||
}
|