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
+37 -2
View File
@@ -87,6 +87,8 @@ type Hub struct {
register chan *Client
unregister chan *Client
mu sync.RWMutex
// bus 跨节点广播总线;nil 表示单实例模式(进程内直接投递)
bus EventBus
}
var DefaultHub = NewHub()
@@ -127,8 +129,14 @@ func (h *Hub) Run() {
}
}
// DisconnectUser 断开指定用户的所有 WebSocket 连接并推送通知。
// DisconnectUser 断开指定用户在所有节点上的 WebSocket 连接并推送通知。
func (h *Hub) DisconnectUser(userID uint, message string) {
h.publishOrDeliver(&busEnvelope{Scope: busScopeDisconnect, UserID: userID, Text: message}, func() {
h.disconnectUserLocal(userID, message)
})
}
func (h *Hub) disconnectUserLocal(userID uint, message string) {
h.mu.Lock()
defer h.mu.Unlock()
for client := range h.clients {
@@ -235,6 +243,14 @@ func (h *Hub) send(client *Client, message []byte) {
// BroadcastToSession 仅把消息推送给当前客服、主管/管理员及该会话的访客。
func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, message []byte) {
h.publishOrDeliver(&busEnvelope{
Scope: busScopeSession, TenantID: tenantID, SessionID: sessionID, AgentID: agentID, Message: message,
}, func() {
h.deliverToSession(tenantID, sessionID, agentID, message)
})
}
func (h *Hub) deliverToSession(tenantID, sessionID uint, agentID *uint, message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -258,13 +274,20 @@ func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, messag
// BroadcastToTenantStaff 只通知租户内的工作人员,不向访客泄露其他会话事件。
func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) {
h.publishOrDeliver(&busEnvelope{Scope: busScopeTenantStaff, TenantID: tenantID, Message: message}, func() {
h.deliverToTenantStaff(tenantID, message)
})
}
func (h *Hub) deliverToTenantStaff(tenantID uint, message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
var event Event
_ = json.Unmarshal(message, &event)
var session *model.Session
if event.SessionID != 0 {
// 单元测试未初始化数据库时跳过会话查询(与 canReceiveSessionEvent 的处理一致)
if model.DB != nil && event.SessionID != 0 {
var current model.Session
if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, tenantID).First(&current).Error; err == nil {
session = &current
@@ -280,6 +303,12 @@ func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) {
}
func (h *Hub) BroadcastToVisitor(tenantID, sessionID uint, message []byte) {
h.publishOrDeliver(&busEnvelope{Scope: busScopeVisitor, TenantID: tenantID, SessionID: sessionID, Message: message}, func() {
h.deliverToVisitor(tenantID, sessionID, message)
})
}
func (h *Hub) deliverToVisitor(tenantID, sessionID uint, message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -292,6 +321,12 @@ func (h *Hub) BroadcastToVisitor(tenantID, sessionID uint, message []byte) {
// BroadcastToSessionStaff 仅推送给可查看该会话的工作人员(不含访客)。
func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []byte) {
h.publishOrDeliver(&busEnvelope{Scope: busScopeSessionStaff, TenantID: tenantID, AgentID: agentID, Message: message}, func() {
h.deliverToSessionStaff(tenantID, agentID, message)
})
}
func (h *Hub) deliverToSessionStaff(tenantID uint, agentID *uint, message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()