Files
kefu_cloud/server/internal/ws/bus.go
T
yml2213 10d2e2ed3d 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 通过
2026-09-05 14:39:56 +08:00

171 lines
4.6 KiB
Go

package ws
import (
"context"
"encoding/json"
"log"
"os"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// busScope 广播信封的路由范围。
const (
busScopeSession = "session" // 会话相关(坐席 + 该会话访客)
busScopeTenantStaff = "tenant_staff" // 租户内工作人员
busScopeVisitor = "visitor" // 指定会话访客
busScopeSessionStaff = "session_staff" // 可查看会话的工作人员(不含访客)
busScopeDisconnect = "disconnect" // 断开指定用户所有连接
)
// busEnvelope 跨节点广播信封:scope + 路由参数 + 原始 WS 消息。
type busEnvelope struct {
Node string `json:"node,omitempty"`
Scope string `json:"scope"`
TenantID uint `json:"tenant_id"`
SessionID uint `json:"session_id,omitempty"`
AgentID *uint `json:"agent_id,omitempty"`
UserID uint `json:"user_id,omitempty"`
Text string `json:"text,omitempty"`
Message json.RawMessage `json:"message,omitempty"`
}
// EventBus 跨节点广播总线(Redis Pub/Sub 实现)。
// 单实例部署无需设置;多副本部署通过 REDIS_ADDR 启用后,
// Hub 的所有广播改为发布到总线,由各节点订阅后在本进程内投递。
type EventBus interface {
Publish(env *busEnvelope) error
Subscribe(dispatch func(env *busEnvelope))
Close() error
}
// RedisBus 基于 Redis Pub/Sub 的 EventBus。
type RedisBus struct {
client *redis.Client
channel string
nodeID string
ctx context.Context
cancel context.CancelFunc
closeMu sync.Once
}
// NewRedisBus 创建 Redis 广播总线(不立即建连,首次命令时惰性连接)。
func NewRedisBus(addr, password string, db int) *RedisBus {
ctx, cancel := context.WithCancel(context.Background())
nodeID, _ := os.Hostname()
return &RedisBus{
client: redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
}),
channel: "kefu:ws:broadcast",
nodeID: nodeID,
ctx: ctx,
cancel: cancel,
}
}
// Publish 发布信封;Redis 不可用时返回错误,由调用方决定本地兜底。
func (b *RedisBus) Publish(env *busEnvelope) error {
if env.Node == "" {
env.Node = b.nodeID
}
payload, err := json.Marshal(env)
if err != nil {
return err
}
return b.client.Publish(b.ctx, b.channel, payload).Err()
}
// Subscribe 阻塞订阅总线,收到信封后回调 dispatch(含本节点发出的消息,
// 由 dispatch 统一本进程投递,避免双发)。
func (b *RedisBus) Subscribe(dispatch func(env *busEnvelope)) {
for {
select {
case <-b.ctx.Done():
return
default:
}
sub := b.client.Subscribe(b.ctx, b.channel)
// 等待订阅确认失败(Redis 未就绪)时退避重试
if _, err := sub.Receive(b.ctx); err != nil {
_ = sub.Close()
select {
case <-b.ctx.Done():
return
case <-time.After(2 * time.Second):
continue
}
}
ch := sub.Channel()
for msg := range ch {
var env busEnvelope
if json.Unmarshal([]byte(msg.Payload), &env) != nil || env.Scope == "" {
continue
}
dispatch(&env)
}
_ = sub.Close()
// Channel 关闭(连接断开)后重建订阅
select {
case <-b.ctx.Done():
return
case <-time.After(time.Second):
}
}
}
// Close 释放订阅与连接。
func (b *RedisBus) Close() error {
var err error
b.closeMu.Do(func() {
b.cancel()
err = b.client.Close()
})
return err
}
// SetEventBus 挂载跨节点广播总线(须在 Hub.Run 与首个连接建立前调用)。
func (h *Hub) SetEventBus(bus EventBus) {
h.mu.Lock()
h.bus = bus
h.mu.Unlock()
go bus.Subscribe(h.dispatch)
}
// dispatch 总线信封 → 本进程投递。
func (h *Hub) dispatch(env *busEnvelope) {
switch env.Scope {
case busScopeSession:
h.deliverToSession(env.TenantID, env.SessionID, env.AgentID, env.Message)
case busScopeTenantStaff:
h.deliverToTenantStaff(env.TenantID, env.Message)
case busScopeVisitor:
h.deliverToVisitor(env.TenantID, env.SessionID, env.Message)
case busScopeSessionStaff:
h.deliverToSessionStaff(env.TenantID, env.AgentID, env.Message)
case busScopeDisconnect:
h.disconnectUserLocal(env.UserID, env.Text)
default:
log.Printf("ws: 未知广播 scope=%q", env.Scope)
}
}
// publishOrDeliver 优先走总线;总线不可用时兜底本进程投递,保证单点功能不回退。
func (h *Hub) publishOrDeliver(env *busEnvelope, deliver func()) {
h.mu.RLock()
bus := h.bus
h.mu.RUnlock()
if bus == nil {
deliver()
return
}
if err := bus.Publish(env); err != nil {
log.Printf("ws: 广播发布失败(scope=%s),本进程兜底: %v", env.Scope, err)
deliver()
}
}