feat(ws): implement WebSocket cluster mode with Redis Pub/Sub
Introduce a new WebSocket messaging architecture that supports both standalone and cluster modes. This allows for horizontal scaling of WebSocket servers by using Redis Pub/Sub to synchronize messages across multiple instances. Key changes: - Added `ws.MessagePublisher` interface to abstract message distribution. - Implemented `ws.Bus` to handle cluster-mode messaging via Redis. - Added `ws.OnlineTracker` to manage user online status across the cluster. - Refactored multiple services (Chat, Group, Push, Call, etc.) to use the new `MessagePublisher` instead of a concrete `ws.Hub`. - Added WebSocket configuration options (mode, instance ID, channel, TTL, heartbeat) to `config.yaml` and `config.go`. - Updated dependency injection with Wire to support the new publisher and Redis client. - Improved logging by replacing standard `log` with `zap` in several service components.
This commit is contained in:
@@ -121,7 +121,12 @@ jobs:
|
|||||||
-e APP_JPUSH_PRODUCTION=true \
|
-e APP_JPUSH_PRODUCTION=true \
|
||||||
-e APP_SENSITIVE_ENABLED=true \
|
-e APP_SENSITIVE_ENABLED=true \
|
||||||
-e APP_REPORT_AUTO_HIDE_THRESHOLD=3 \
|
-e APP_REPORT_AUTO_HIDE_THRESHOLD=3 \
|
||||||
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
-e APP_WEBSOCKET_MODE=standalone \
|
||||||
|
-e APP_WEBSOCKET_CLUSTER_INSTANCE_ID= \
|
||||||
|
-e APP_WEBSOCKET_CLUSTER_MSG_CHANNEL=ws:msg \
|
||||||
|
-e APP_WEBSOCKET_CLUSTER_ONLINE_TTL=60 \
|
||||||
|
-e APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL=20 \
|
||||||
|
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
||||||
${IMAGE}
|
${IMAGE}
|
||||||
|
|
||||||
echo "=== Waiting for startup ==="
|
echo "=== Waiting for startup ==="
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type App struct {
|
|||||||
HotRankWorker *service.HotRankWorker
|
HotRankWorker *service.HotRankWorker
|
||||||
GRPCServer *runner.Server
|
GRPCServer *runner.Server
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
WsHub *ws.Hub
|
Publisher ws.MessagePublisher
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ func NewApp(
|
|||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
hotRankWorker *service.HotRankWorker,
|
hotRankWorker *service.HotRankWorker,
|
||||||
grpcServer *runner.Server,
|
grpcServer *runner.Server,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
@@ -47,7 +47,7 @@ func NewApp(
|
|||||||
PushService: pushService,
|
PushService: pushService,
|
||||||
HotRankWorker: hotRankWorker,
|
HotRankWorker: hotRankWorker,
|
||||||
GRPCServer: grpcServer,
|
GRPCServer: grpcServer,
|
||||||
WsHub: wsHub,
|
Publisher: publisher,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,9 +102,14 @@ func (a *App) Stop(ctx context.Context) error {
|
|||||||
a.Logger.Info("Shutting down server")
|
a.Logger.Info("Shutting down server")
|
||||||
|
|
||||||
// 1. 关闭所有 WebSocket 连接
|
// 1. 关闭所有 WebSocket 连接
|
||||||
if a.WsHub != nil {
|
if a.Publisher != nil {
|
||||||
a.Logger.Info("Closing WebSocket connections")
|
a.Logger.Info("Closing WebSocket connections")
|
||||||
a.WsHub.CloseAllConnections()
|
switch p := a.Publisher.(type) {
|
||||||
|
case *ws.Bus:
|
||||||
|
p.Close()
|
||||||
|
case *ws.Hub:
|
||||||
|
p.CloseAllConnections()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 停止 HTTP 服务器
|
// 3. 停止 HTTP 服务器
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
@@ -31,12 +31,12 @@ func InitializeApp() (*App, error) {
|
|||||||
pushRecordRepository := repository.NewPushRecordRepository(db)
|
pushRecordRepository := repository.NewPushRecordRepository(db)
|
||||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||||
messageRepository := repository.NewMessageRepository(db)
|
messageRepository := repository.NewMessageRepository(db)
|
||||||
hub := wire.ProvideWSHub()
|
client := wire.ProvideRedisClient(config)
|
||||||
|
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
||||||
logger := wire.ProvideLogger()
|
logger := wire.ProvideLogger()
|
||||||
jpushClient := wire.ProvideJPushClient(config, logger)
|
jpushClient := wire.ProvideJPushClient(config, logger)
|
||||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub, jpushClient)
|
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient)
|
||||||
postRepository := repository.NewPostRepository(db)
|
postRepository := repository.NewPostRepository(db)
|
||||||
client := wire.ProvideRedisClient(config)
|
|
||||||
cache := wire.ProvideCache(config, client)
|
cache := wire.ProvideCache(config, client)
|
||||||
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
||||||
emailClient := wire.ProvideEmailClient(config)
|
emailClient := wire.ProvideEmailClient(config)
|
||||||
@@ -66,10 +66,10 @@ func InitializeApp() (*App, error) {
|
|||||||
transactionManager := wire.ProvideTransactionManager(db)
|
transactionManager := wire.ProvideTransactionManager(db)
|
||||||
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
||||||
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
||||||
channelRepository := repository.NewChannelRepository(db)
|
|
||||||
channelService := wire.ProvideChannelService(channelRepository, cache)
|
|
||||||
postRefRepository := repository.NewPostRefRepository(db)
|
postRefRepository := repository.NewPostRefRepository(db)
|
||||||
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
|
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
|
||||||
|
channelRepository := repository.NewChannelRepository(db)
|
||||||
|
channelService := wire.ProvideChannelService(channelRepository, cache)
|
||||||
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
|
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
|
||||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
|
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
|
||||||
commentHandler := handler.NewCommentHandler(commentService)
|
commentHandler := handler.NewCommentHandler(commentService)
|
||||||
@@ -78,12 +78,12 @@ func InitializeApp() (*App, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
uploadService := wire.ProvideUploadService(s3Client, userService)
|
||||||
chatService := wire.ProvideChatService(messageRepository, userRepository, hub, cache, uploadService, pushService)
|
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService)
|
||||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||||
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, hub, cache)
|
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
||||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
|
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, messagePublisher)
|
||||||
notificationRepository := repository.NewNotificationRepository(db)
|
notificationRepository := repository.NewNotificationRepository(db)
|
||||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
@@ -121,14 +121,14 @@ func InitializeApp() (*App, error) {
|
|||||||
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
||||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
||||||
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, hub, jwtService, userService, userActivityService, logService)
|
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, messagePublisher, jwtService, userService, userActivityService, logService)
|
||||||
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||||
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
||||||
materialFileRepository := repository.NewMaterialFileRepository(db)
|
materialFileRepository := repository.NewMaterialFileRepository(db)
|
||||||
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
||||||
materialHandler := handler.NewMaterialHandler(materialService)
|
materialHandler := handler.NewMaterialHandler(materialService)
|
||||||
callRepository := repository.NewCallRepository(db)
|
callRepository := repository.NewCallRepository(db)
|
||||||
callService := wire.ProvideCallService(callRepository, hub, config, db)
|
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client)
|
||||||
callHandler := handler.NewCallHandler(callService)
|
callHandler := handler.NewCallHandler(callService)
|
||||||
reportRepository := repository.NewReportRepository(db)
|
reportRepository := repository.NewReportRepository(db)
|
||||||
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
||||||
@@ -143,13 +143,10 @@ func InitializeApp() (*App, error) {
|
|||||||
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
||||||
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
||||||
setupHandler := wire.ProvideSetupHandler(setupService)
|
setupHandler := wire.ProvideSetupHandler(setupService)
|
||||||
wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService, userRepository)
|
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userRepository)
|
||||||
tradeRepository := repository.NewTradeRepository(db)
|
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, logService, userActivityService, casbinService, wsHandler)
|
||||||
tradeService := wire.ProvideTradeService(tradeRepository)
|
|
||||||
tradeHandler := handler.NewTradeHandler(tradeService)
|
|
||||||
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler)
|
|
||||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache)
|
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache)
|
||||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, hub, logger)
|
app := NewApp(config, db, router, pushService, hotRankWorker, server, messagePublisher, logger)
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +185,6 @@ func ProvideRouter(
|
|||||||
adminVerificationHandler *handler.AdminVerificationHandler,
|
adminVerificationHandler *handler.AdminVerificationHandler,
|
||||||
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
||||||
setupHandler *handler.SetupHandler,
|
setupHandler *handler.SetupHandler,
|
||||||
tradeHandler *handler.TradeHandler,
|
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
@@ -226,7 +222,6 @@ func ProvideRouter(
|
|||||||
AdminVerificationHandler: adminVerificationHandler,
|
AdminVerificationHandler: adminVerificationHandler,
|
||||||
AdminProfileAuditHandler: adminProfileAuditHandler,
|
AdminProfileAuditHandler: adminProfileAuditHandler,
|
||||||
SetupHandler: setupHandler,
|
SetupHandler: setupHandler,
|
||||||
TradeHandler: tradeHandler,
|
|
||||||
LogService: logService,
|
LogService: logService,
|
||||||
ActivityService: activityService,
|
ActivityService: activityService,
|
||||||
CasbinService: casbinService,
|
CasbinService: casbinService,
|
||||||
|
|||||||
@@ -277,3 +277,16 @@ jpush:
|
|||||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||||
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
||||||
setup_secret: ""
|
setup_secret: ""
|
||||||
|
|
||||||
|
# WebSocket 配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_WEBSOCKET_MODE, APP_WEBSOCKET_CLUSTER_INSTANCE_ID
|
||||||
|
# APP_WEBSOCKET_CLUSTER_MSG_CHANNEL, APP_WEBSOCKET_CLUSTER_ONLINE_TTL
|
||||||
|
# APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL
|
||||||
|
websocket:
|
||||||
|
mode: standalone # standalone 或 cluster
|
||||||
|
cluster:
|
||||||
|
instance_id: "" # 留空自动生成 UUID
|
||||||
|
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||||
|
online_ttl: 60 # 在线状态 TTL(秒)
|
||||||
|
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Config struct {
|
|||||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||||
JPush JPushConfig `mapstructure:"jpush"`
|
JPush JPushConfig `mapstructure:"jpush"`
|
||||||
SetupSecret string `mapstructure:"setup_secret"`
|
SetupSecret string `mapstructure:"setup_secret"`
|
||||||
|
WebSocket WSConfig `mapstructure:"websocket"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置文件
|
// Load 加载配置文件
|
||||||
@@ -173,6 +174,12 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("jpush.master_secret", "")
|
viper.SetDefault("jpush.master_secret", "")
|
||||||
viper.SetDefault("jpush.production", false)
|
viper.SetDefault("jpush.production", false)
|
||||||
viper.SetDefault("setup_secret", "")
|
viper.SetDefault("setup_secret", "")
|
||||||
|
// WebSocket 默认值
|
||||||
|
viper.SetDefault("websocket.mode", "standalone")
|
||||||
|
viper.SetDefault("websocket.cluster.instance_id", "")
|
||||||
|
viper.SetDefault("websocket.cluster.msg_channel", "ws:msg")
|
||||||
|
viper.SetDefault("websocket.cluster.online_ttl", 60)
|
||||||
|
viper.SetDefault("websocket.cluster.heartbeat_interval", 20)
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||||
|
|||||||
15
internal/config/websocket.go
Normal file
15
internal/config/websocket.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// WSConfig WebSocket 配置
|
||||||
|
type WSConfig struct {
|
||||||
|
Mode string `mapstructure:"mode"` // standalone | cluster
|
||||||
|
Cluster WSClusterConfig `mapstructure:"cluster"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSClusterConfig WebSocket 集群配置
|
||||||
|
type WSClusterConfig struct {
|
||||||
|
InstanceID string `mapstructure:"instance_id"`
|
||||||
|
MsgChannel string `mapstructure:"msg_channel"`
|
||||||
|
OnlineTTL int `mapstructure:"online_ttl"`
|
||||||
|
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
|
||||||
|
}
|
||||||
@@ -90,17 +90,17 @@ type MessageHandler struct {
|
|||||||
messageService *service.MessageService
|
messageService *service.MessageService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
wsHub *ws.Hub
|
wsPublisher ws.MessagePublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler 创建消息处理器
|
// NewMessageHandler 创建消息处理器
|
||||||
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsHub *ws.Hub) *MessageHandler {
|
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||||
return &MessageHandler{
|
return &MessageHandler{
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
wsHub: wsHub,
|
wsPublisher: wsPublisher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func isAllowedWebSocketOrigin(origin string) bool {
|
|||||||
|
|
||||||
// WSHandler WebSocket处理器
|
// WSHandler WebSocket处理器
|
||||||
type WSHandler struct {
|
type WSHandler struct {
|
||||||
wsHub *ws.Hub
|
publisher ws.MessagePublisher
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
jwtService *service.JWTService
|
jwtService *service.JWTService
|
||||||
@@ -76,9 +76,21 @@ type WSHandler struct {
|
|||||||
clientSeq uint64
|
clientSeq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hub 获取底层 Hub(用于 Register/Unregister/AckMessage 等本地操作)
|
||||||
|
func (h *WSHandler) hub() *ws.Hub {
|
||||||
|
switch p := h.publisher.(type) {
|
||||||
|
case *ws.Bus:
|
||||||
|
return p.Hub()
|
||||||
|
case *ws.Hub:
|
||||||
|
return p
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewWSHandler 创建WebSocket处理器
|
// NewWSHandler 创建WebSocket处理器
|
||||||
func NewWSHandler(
|
func NewWSHandler(
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
chatService service.ChatService,
|
chatService service.ChatService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
jwtService *service.JWTService,
|
jwtService *service.JWTService,
|
||||||
@@ -86,7 +98,7 @@ func NewWSHandler(
|
|||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
) *WSHandler {
|
) *WSHandler {
|
||||||
return &WSHandler{
|
return &WSHandler{
|
||||||
wsHub: wsHub,
|
publisher: publisher,
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
@@ -148,7 +160,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册客户端
|
// 4. 注册客户端
|
||||||
regErr := h.wsHub.Register(client)
|
regErr := h.hub().Register(client)
|
||||||
if regErr != nil {
|
if regErr != nil {
|
||||||
zap.L().Warn("WebSocket registration rejected",
|
zap.L().Warn("WebSocket registration rejected",
|
||||||
zap.String("user_id", userID),
|
zap.String("user_id", userID),
|
||||||
@@ -159,7 +171,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer h.wsHub.Unregister(client)
|
defer h.hub().Unregister(client)
|
||||||
|
|
||||||
zap.L().Info("WebSocket client connected",
|
zap.L().Info("WebSocket client connected",
|
||||||
zap.String("user_id", userID),
|
zap.String("user_id", userID),
|
||||||
@@ -168,7 +180,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
|
|
||||||
// 5. 提示客户端进行 seq 同步
|
// 5. 提示客户端进行 seq 同步
|
||||||
syncMsg := ws.ResponseMessage{
|
syncMsg := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "sync_required",
|
Type: "sync_required",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
@@ -234,7 +246,7 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
// 解析消息
|
// 解析消息
|
||||||
var msg ws.Message
|
var msg ws.Message
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if err := json.Unmarshal(message, &msg); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid message format")
|
h.publisher.SendError(client, "parse_error", "invalid message format")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,14 +350,14 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
|||||||
case "call_mute":
|
case "call_mute":
|
||||||
h.handleCallMute(ctx, client, msg.Payload)
|
h.handleCallMute(ctx, client, msg.Payload)
|
||||||
default:
|
default:
|
||||||
h.wsHub.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePing 处理ping消息
|
// handlePing 处理ping消息
|
||||||
func (h *WSHandler) handlePing(client *ws.Client) {
|
func (h *WSHandler) handlePing(client *ws.Client) {
|
||||||
pong := ws.ResponseMessage{
|
pong := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "pong",
|
Type: "pong",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
@@ -371,24 +383,24 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid chat message format")
|
h.publisher.SendError(client, "parse_error", "invalid chat message format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ConversationID == "" {
|
if req.ConversationID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "conversation_id is required")
|
h.publisher.SendError(client, "invalid_params", "conversation_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Segments) == 0 {
|
if len(req.Segments) == 0 {
|
||||||
h.wsHub.SendError(client, "invalid_params", "segments is required")
|
h.publisher.SendError(client, "invalid_params", "segments is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "send_failed", err.Error())
|
h.publisher.SendError(client, "send_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +422,7 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := ws.ResponseMessage{
|
msg := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "message_sent",
|
Type: "message_sent",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: resp,
|
Payload: resp,
|
||||||
@@ -478,24 +490,24 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid recall message format")
|
h.publisher.SendError(client, "parse_error", "invalid recall message format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.MessageID == "" {
|
if req.MessageID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "message_id is required")
|
h.publisher.SendError(client, "invalid_params", "message_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "recall_failed", err.Error())
|
h.publisher.SendError(client, "recall_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送撤回成功响应
|
// 发送撤回成功响应
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "message_recalled",
|
Type: "message_recalled",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]string{"message_id": req.MessageID},
|
Payload: map[string]string{"message_id": req.MessageID},
|
||||||
@@ -517,18 +529,18 @@ func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
|
|||||||
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.wsHub.AckMessage(client.UserID, req.MessageID)
|
h.hub().AckMessage(client.UserID, req.MessageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isVerified 检查用户是否已通过身份认证
|
// isVerified 检查用户是否已通过身份认证
|
||||||
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
||||||
user, err := h.userRepo.GetByID(client.UserID)
|
user, err := h.userRepo.GetByID(client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "internal_error", "获取用户信息失败")
|
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if user.VerificationStatus != model.VerificationStatusApproved {
|
if user.VerificationStatus != model.VerificationStatusApproved {
|
||||||
h.wsHub.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
h.publisher.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -548,11 +560,11 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
MediaType string `json:"call_type"` // voice 或 video
|
MediaType string `json:"call_type"` // voice 或 video
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid call_invite format")
|
h.publisher.SendError(client, "parse_error", "invalid call_invite format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.CalleeID == "" || req.ConversationID == "" {
|
if req.CalleeID == "" || req.ConversationID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
h.publisher.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,16 +582,16 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
if errors.Is(err, apperrors.ErrCallInProgress) {
|
if errors.Is(err, apperrors.ErrCallInProgress) {
|
||||||
h.wsHub.SendError(client, "call_in_progress", err.Error())
|
h.publisher.SendError(client, "call_in_progress", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.wsHub.SendError(client, "call_invite_failed", err.Error())
|
h.publisher.SendError(client, "call_invite_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回给呼叫方的响应
|
// 返回给呼叫方的响应
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_invited",
|
Type: "call_invited",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -602,7 +614,7 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
|||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,15 +622,15 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// === 区分已接听错误 ===
|
// === 区分已接听错误 ===
|
||||||
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
|
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
|
||||||
h.wsHub.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
h.publisher.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.wsHub.SendError(client, "call_accept_failed", err.Error())
|
h.publisher.SendError(client, "call_accept_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_accepted",
|
Type: "call_accepted",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -639,12 +651,12 @@ func (h *WSHandler) handleCallReject(ctx context.Context, client *ws.Client, pay
|
|||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
|
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
|
||||||
h.wsHub.SendError(client, "call_reject_failed", err.Error())
|
h.publisher.SendError(client, "call_reject_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,12 +666,12 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
|
|||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
|
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
|
||||||
h.wsHub.SendError(client, "call_busy_failed", err.Error())
|
h.publisher.SendError(client, "call_busy_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +683,7 @@ func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payloa
|
|||||||
SDP json.RawMessage `json:"sdp"`
|
SDP json.RawMessage `json:"sdp"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
h.publisher.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +692,7 @@ func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payloa
|
|||||||
"sdp": json.RawMessage(req.SDP),
|
"sdp": json.RawMessage(req.SDP),
|
||||||
})
|
})
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
||||||
h.wsHub.SendError(client, "call_sdp_failed", err.Error())
|
h.publisher.SendError(client, "call_sdp_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +703,7 @@ func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payloa
|
|||||||
Candidate json.RawMessage `json:"candidate"`
|
Candidate json.RawMessage `json:"candidate"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,7 +711,7 @@ func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payloa
|
|||||||
"candidate": json.RawMessage(req.Candidate),
|
"candidate": json.RawMessage(req.Candidate),
|
||||||
})
|
})
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
||||||
h.wsHub.SendError(client, "call_ice_failed", err.Error())
|
h.publisher.SendError(client, "call_ice_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,18 +722,18 @@ func (h *WSHandler) handleCallEnd(ctx context.Context, client *ws.Client, payloa
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
|
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "call_end_failed", err.Error())
|
h.publisher.SendError(client, "call_end_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_ended",
|
Type: "call_ended",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -743,11 +755,11 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
|
|||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
|
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
|
||||||
h.wsHub.SendError(client, "call_mute_failed", err.Error())
|
h.publisher.SendError(client, "call_mute_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
395
internal/pkg/ws/bus.go
Normal file
395
internal/pkg/ws/bus.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// busMsg Redis Pub/Sub 消息格式
|
||||||
|
type busMsg struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
MsgType string `json:"msg_type"` // "user_msg" | "broadcast"
|
||||||
|
TargetUsers []string `json:"target_users"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
EventID uint64 `json:"event_id"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSClusterConfig WebSocket 集群配置
|
||||||
|
type WSClusterConfig struct {
|
||||||
|
InstanceID string
|
||||||
|
MsgChannel string
|
||||||
|
OnlineTTL int
|
||||||
|
HeartbeatInterval int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus 组合 Hub + Redis Pub/Sub + OnlineTracker,实现跨实例消息路由
|
||||||
|
type Bus struct {
|
||||||
|
hub *Hub
|
||||||
|
rdb *redis.Client
|
||||||
|
tracker *OnlineTracker
|
||||||
|
|
||||||
|
instanceID string
|
||||||
|
channel string
|
||||||
|
sub *redis.PubSub
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBus 创建 Bus 实例
|
||||||
|
// 如果 rdb 为 nil(standalone 模式或 Redis 不可用),Bus 退化为纯本地 Hub 代理
|
||||||
|
func NewBus(hub *Hub, rdb *redis.Client, cfg *WSClusterConfig) *Bus {
|
||||||
|
instanceID := cfg.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
onlineTTL := time.Duration(cfg.OnlineTTL) * time.Second
|
||||||
|
heartbeatInterval := time.Duration(cfg.HeartbeatInterval) * time.Second
|
||||||
|
if heartbeatInterval <= 0 {
|
||||||
|
heartbeatInterval = 20 * time.Second
|
||||||
|
}
|
||||||
|
if onlineTTL <= 0 {
|
||||||
|
onlineTTL = 60 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker := NewOnlineTracker(rdb, instanceID, onlineTTL, heartbeatInterval)
|
||||||
|
|
||||||
|
return &Bus{
|
||||||
|
hub: hub,
|
||||||
|
rdb: rdb,
|
||||||
|
tracker: tracker,
|
||||||
|
instanceID: instanceID,
|
||||||
|
channel: cfg.MsgChannel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub 获取底层 Hub(供 WebSocket handler 使用 Register/Unregister 等本地操作)
|
||||||
|
func (b *Bus) Hub() *Hub {
|
||||||
|
return b.hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceID 获取实例 ID
|
||||||
|
func (b *Bus) InstanceID() string {
|
||||||
|
return b.instanceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动 Bus 的 Redis 订阅和心跳
|
||||||
|
func (b *Bus) Start() error {
|
||||||
|
if b.rdb == nil {
|
||||||
|
zap.L().Info("WebSocket Bus: no Redis client, running in standalone mode")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
b.tracker.StartHeartbeat()
|
||||||
|
|
||||||
|
// 订阅 Redis channel
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
b.cancel = cancel
|
||||||
|
|
||||||
|
sub := b.rdb.Subscribe(ctx, b.channel)
|
||||||
|
b.sub = sub
|
||||||
|
|
||||||
|
// 确保订阅已建立
|
||||||
|
if _, err := sub.Receive(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go b.subscribeLoop(ctx, sub)
|
||||||
|
|
||||||
|
zap.L().Info("WebSocket Bus started",
|
||||||
|
zap.String("instance_id", b.instanceID),
|
||||||
|
zap.String("channel", b.channel),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册客户端连接(代理 Hub.Register + tracker)
|
||||||
|
func (b *Bus) Register(client *Client) error {
|
||||||
|
if err := b.hub.Register(client); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tracker.UserConnected(client.UserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister 注销客户端连接(代理 Hub.Unregister + tracker)
|
||||||
|
func (b *Bus) Unregister(client *Client) {
|
||||||
|
b.hub.Unregister(client)
|
||||||
|
b.tracker.UserDisconnected(client.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭 Bus:关闭订阅,清理在线状态
|
||||||
|
func (b *Bus) Close() error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.closed = true
|
||||||
|
|
||||||
|
if b.cancel != nil {
|
||||||
|
b.cancel()
|
||||||
|
}
|
||||||
|
if b.sub != nil {
|
||||||
|
b.sub.Close()
|
||||||
|
}
|
||||||
|
b.tracker.Stop()
|
||||||
|
b.hub.CloseAllConnections()
|
||||||
|
|
||||||
|
zap.L().Info("WebSocket Bus closed", zap.String("instance_id", b.instanceID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MessagePublisher 接口实现 ---
|
||||||
|
|
||||||
|
// PublishToUser 向指定用户发送事件(本地 + 远程)
|
||||||
|
func (b *Bus) PublishToUser(userID string, eventType string, payload any) Event {
|
||||||
|
ev := b.hub.PublishToUser(userID, eventType, payload)
|
||||||
|
b.publishToRemote([]string{userID}, "user_msg", ev, payload)
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUsers 向多个用户发送事件
|
||||||
|
func (b *Bus) PublishToUsers(userIDs []string, eventType string, payload any) {
|
||||||
|
b.hub.PublishToUsers(userIDs, eventType, payload)
|
||||||
|
b.publishToRemote(userIDs, "user_msg", Event{
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUsersConcurrent 并发推送消息给多个用户
|
||||||
|
func (b *Bus) PublishToUsersConcurrent(userIDs []string, eventType string, payload any) {
|
||||||
|
b.hub.PublishToUsersConcurrent(userIDs, eventType, payload)
|
||||||
|
b.publishToRemote(userIDs, "user_msg", Event{
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUserOnline 仅在用户在线时发送事件
|
||||||
|
func (b *Bus) PublishToUserOnline(userID string, eventType string, payload any) bool {
|
||||||
|
delivered := b.hub.PublishToUserOnline(userID, eventType, payload)
|
||||||
|
if !delivered {
|
||||||
|
// 本地不在线,尝试远程投递
|
||||||
|
ev := Event{
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
b.publishToRemote([]string{userID}, "user_msg", ev, payload)
|
||||||
|
}
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUserOnlineReliable 可靠地发送事件给在线用户
|
||||||
|
func (b *Bus) PublishToUserOnlineReliable(userID string, eventType string, payload any) bool {
|
||||||
|
delivered := b.hub.PublishToUserOnlineReliable(userID, eventType, payload)
|
||||||
|
if !delivered {
|
||||||
|
ev := Event{
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
b.publishToRemote([]string{userID}, "user_msg", ev, payload)
|
||||||
|
}
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast 广播消息给所有连接
|
||||||
|
func (b *Bus) Broadcast(eventType string, payload any) {
|
||||||
|
b.hub.Broadcast(eventType, payload)
|
||||||
|
b.publishToRemote(nil, "broadcast", Event{
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasClients 检查用户是否在线(本地优先,然后查 Redis)
|
||||||
|
func (b *Bus) HasClients(userID string) bool {
|
||||||
|
if b.hub.HasClients(userID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return b.tracker.IsOnline(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterOnline 批量检查用户在线状态
|
||||||
|
func (b *Bus) FilterOnline(userIDs []string) (online, offline []string) {
|
||||||
|
localOnline, offline := b.hub.FilterOnline(userIDs)
|
||||||
|
if b.rdb == nil {
|
||||||
|
return localOnline, offline
|
||||||
|
}
|
||||||
|
// 对 offline 的列表,查 Redis
|
||||||
|
remoteOnline, stillOffline := b.tracker.FilterOnline(offline)
|
||||||
|
online = append(localOnline, remoteOnline...)
|
||||||
|
return online, stillOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendError 向客户端发送错误消息(本地操作)
|
||||||
|
func (b *Bus) SendError(client *Client, code string, message string) {
|
||||||
|
b.hub.SendError(client, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextID 获取下一个事件 ID
|
||||||
|
func (b *Bus) NextID() uint64 {
|
||||||
|
return b.hub.NextID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDisconnect 注册断开连接事件处理器
|
||||||
|
func (b *Bus) OnDisconnect(handler DisconnectHandler) {
|
||||||
|
b.hub.OnDisconnect(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConnect 注册连接事件处理器
|
||||||
|
func (b *Bus) OnConnect(handler ConnectHandler) {
|
||||||
|
b.hub.OnConnect(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部方法 ---
|
||||||
|
|
||||||
|
// publishToRemote 发布消息到 Redis channel
|
||||||
|
func (b *Bus) publishToRemote(targetUsers []string, msgType string, ev Event, payload any) {
|
||||||
|
if b.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Bus: failed to marshal payload for remote publish",
|
||||||
|
zap.String("event_type", ev.Type),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := busMsg{
|
||||||
|
InstanceID: b.instanceID,
|
||||||
|
MsgType: msgType,
|
||||||
|
TargetUsers: targetUsers,
|
||||||
|
EventType: ev.Type,
|
||||||
|
EventID: ev.ID,
|
||||||
|
TS: ev.TS,
|
||||||
|
Payload: payloadBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Bus: failed to marshal busMsg", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.rdb.Publish(context.Background(), b.channel, data).Err(); err != nil {
|
||||||
|
zap.L().Error("Bus: failed to publish to Redis",
|
||||||
|
zap.String("channel", b.channel),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeLoop 处理 Redis 订阅消息
|
||||||
|
func (b *Bus) subscribeLoop(ctx context.Context, sub *redis.PubSub) {
|
||||||
|
ch := sub.Channel()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.handleRemoteMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoteMessage 处理从 Redis 收到的远程消息
|
||||||
|
func (b *Bus) handleRemoteMessage(msg *redis.Message) {
|
||||||
|
var bm busMsg
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &bm); err != nil {
|
||||||
|
zap.L().Error("Bus: failed to unmarshal remote message", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤自身发送的消息
|
||||||
|
if bm.InstanceID == b.instanceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch bm.MsgType {
|
||||||
|
case "user_msg":
|
||||||
|
b.handleRemoteUserMsg(bm)
|
||||||
|
case "broadcast":
|
||||||
|
b.handleRemoteBroadcast(bm)
|
||||||
|
default:
|
||||||
|
zap.L().Warn("Bus: unknown msg_type from remote",
|
||||||
|
zap.String("msg_type", bm.MsgType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoteUserMsg 处理远程定向消息
|
||||||
|
func (b *Bus) handleRemoteUserMsg(bm busMsg) {
|
||||||
|
// 构造 ResponseMessage 用于本地投递
|
||||||
|
respMsg := ResponseMessage{
|
||||||
|
EventID: bm.EventID,
|
||||||
|
Type: bm.EventType,
|
||||||
|
TS: bm.TS,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反序列化 payload
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal(bm.Payload, &payload); err != nil {
|
||||||
|
zap.L().Error("Bus: failed to unmarshal remote payload", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respMsg.Payload = payload
|
||||||
|
|
||||||
|
data, err := json.Marshal(respMsg)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Bus: failed to marshal response message", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.hub.deliverToUsers(bm.TargetUsers, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoteBroadcast 处理远程广播消息
|
||||||
|
func (b *Bus) handleRemoteBroadcast(bm busMsg) {
|
||||||
|
b.hub.Broadcast(bm.EventType, bm.Payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverToUsers 向指定用户列表投递预序列化消息
|
||||||
|
// 这是 Hub 批量投递的内部方法,Bus 需要调用它来投递远程消息
|
||||||
|
func (h *Hub) deliverToUsers(userIDs []string, data []byte) {
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
h.mu.RLock()
|
||||||
|
targets := make([]*Client, 0, len(h.clients[uid]))
|
||||||
|
for _, c := range h.clients[uid] {
|
||||||
|
targets = append(targets, c)
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range targets {
|
||||||
|
select {
|
||||||
|
case <-c.Quit:
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
zap.L().Warn("WebSocket client buffer full, closing slow client",
|
||||||
|
zap.String("user_id", uid),
|
||||||
|
zap.Uint64("client_id", c.ID),
|
||||||
|
)
|
||||||
|
close(c.Quit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
257
internal/pkg/ws/online_tracker.go
Normal file
257
internal/pkg/ws/online_tracker.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OnlineTracker 分布式在线状态追踪
|
||||||
|
// 使用 Redis Hash 存储各实例的用户连接数,支持跨实例在线查询
|
||||||
|
type OnlineTracker struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
instanceID string
|
||||||
|
localUsers sync.Map // 本实例在线用户集合: userID -> connCount
|
||||||
|
onlineTTL time.Duration
|
||||||
|
heartbeatInterval time.Duration
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOnlineTracker 创建在线状态追踪器
|
||||||
|
func NewOnlineTracker(rdb *redis.Client, instanceID string, onlineTTL, heartbeatInterval time.Duration) *OnlineTracker {
|
||||||
|
return &OnlineTracker{
|
||||||
|
rdb: rdb,
|
||||||
|
instanceID: instanceID,
|
||||||
|
onlineTTL: onlineTTL,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserConnected 用户连接,更新 Redis 和本地在线状态
|
||||||
|
func (t *OnlineTracker) UserConnected(userID string) {
|
||||||
|
if t.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
key := fmt.Sprintf("online:%s", userID)
|
||||||
|
|
||||||
|
// 本地计数
|
||||||
|
count := int64(0)
|
||||||
|
if v, ok := t.localUsers.Load(userID); ok {
|
||||||
|
count = v.(int64)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
t.localUsers.Store(userID, count)
|
||||||
|
|
||||||
|
// Redis: HINCRBY + EXPIRE
|
||||||
|
pipe := t.rdb.Pipeline()
|
||||||
|
pipe.HIncrBy(ctx, key, t.instanceID, 1)
|
||||||
|
pipe.Expire(ctx, key, t.onlineTTL)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
zap.L().Error("Failed to update online status on connect",
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDisconnected 用户断开连接,更新 Redis 和本地在线状态
|
||||||
|
// 返回该用户剩余的连接数
|
||||||
|
func (t *OnlineTracker) UserDisconnected(userID string) int {
|
||||||
|
if t.rdb == nil {
|
||||||
|
if v, ok := t.localUsers.Load(userID); ok {
|
||||||
|
count := v.(int64) - 1
|
||||||
|
if count <= 0 {
|
||||||
|
t.localUsers.Delete(userID)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t.localUsers.Store(userID, count)
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地计数递减
|
||||||
|
count := int64(0)
|
||||||
|
if v, ok := t.localUsers.Load(userID); ok {
|
||||||
|
count = v.(int64) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if count <= 0 {
|
||||||
|
t.localUsers.Delete(userID)
|
||||||
|
// Redis: 删除该实例的 field
|
||||||
|
ctx := context.Background()
|
||||||
|
key := fmt.Sprintf("online:%s", userID)
|
||||||
|
t.rdb.HDel(ctx, key, t.instanceID)
|
||||||
|
// 检查 key 是否为空,如果为空则删除
|
||||||
|
exists, _ := t.rdb.HLen(ctx, key).Result()
|
||||||
|
if exists == 0 {
|
||||||
|
t.rdb.Del(ctx, key)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
t.localUsers.Store(userID, count)
|
||||||
|
|
||||||
|
// Redis: HINCRBY -1
|
||||||
|
ctx := context.Background()
|
||||||
|
key := fmt.Sprintf("online:%s", userID)
|
||||||
|
newVal, err := t.rdb.HIncrBy(ctx, key, t.instanceID, -1).Result()
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Failed to update online status on disconnect",
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newVal <= 0 {
|
||||||
|
t.rdb.HDel(ctx, key, t.instanceID)
|
||||||
|
exists, _ := t.rdb.HLen(ctx, key).Result()
|
||||||
|
if exists == 0 {
|
||||||
|
t.rdb.Del(ctx, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOnline 检查用户是否在线(查询 Redis)
|
||||||
|
func (t *OnlineTracker) IsOnline(userID string) bool {
|
||||||
|
if t.rdb == nil {
|
||||||
|
_, ok := t.localUsers.Load(userID)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
key := fmt.Sprintf("online:%s", userID)
|
||||||
|
exists, err := t.rdb.Exists(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Failed to check online status",
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
// 降级:检查本地
|
||||||
|
_, ok := t.localUsers.Load(userID)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return exists > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterOnline 批量检查用户在线状态
|
||||||
|
func (t *OnlineTracker) FilterOnline(userIDs []string) (online, offline []string) {
|
||||||
|
if t.rdb == nil {
|
||||||
|
// 降级:只检查本地
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if _, ok := t.localUsers.Load(uid); ok {
|
||||||
|
online = append(online, uid)
|
||||||
|
} else {
|
||||||
|
offline = append(offline, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := t.rdb.Pipeline()
|
||||||
|
cmds := make([]*redis.IntCmd, len(userIDs))
|
||||||
|
for i, uid := range userIDs {
|
||||||
|
key := fmt.Sprintf("online:%s", uid)
|
||||||
|
cmds[i] = pipe.Exists(ctx, key)
|
||||||
|
}
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
zap.L().Error("Failed to batch check online status", zap.Error(err))
|
||||||
|
// 降级
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if _, ok := t.localUsers.Load(uid); ok {
|
||||||
|
online = append(online, uid)
|
||||||
|
} else {
|
||||||
|
offline = append(offline, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, uid := range userIDs {
|
||||||
|
if cmds[i].Val() > 0 {
|
||||||
|
online = append(online, uid)
|
||||||
|
} else {
|
||||||
|
offline = append(offline, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartHeartbeat 启动心跳续期
|
||||||
|
func (t *OnlineTracker) StartHeartbeat() {
|
||||||
|
if t.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.cancel = cancel
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(t.heartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
t.heartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OnlineTracker) heartbeat() {
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := t.rdb.Pipeline()
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
t.localUsers.Range(func(key, value any) bool {
|
||||||
|
userID := key.(string)
|
||||||
|
onlineKey := fmt.Sprintf("online:%s", userID)
|
||||||
|
pipe.Expire(ctx, onlineKey, t.onlineTTL)
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
zap.L().Error("Failed to heartbeat online keys", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止心跳并清理本实例的在线状态
|
||||||
|
func (t *OnlineTracker) Stop() {
|
||||||
|
if t.cancel != nil {
|
||||||
|
t.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := t.rdb.Pipeline()
|
||||||
|
|
||||||
|
// 清理本实例在所有在线 key 中的 field
|
||||||
|
t.localUsers.Range(func(key, value any) bool {
|
||||||
|
userID := key.(string)
|
||||||
|
onlineKey := fmt.Sprintf("online:%s", userID)
|
||||||
|
pipe.HDel(ctx, onlineKey, t.instanceID)
|
||||||
|
// 删除后检查是否为空
|
||||||
|
pipe.HLen(ctx, onlineKey)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
zap.L().Error("Failed to cleanup online status on stop", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/pkg/ws/publisher.go
Normal file
23
internal/pkg/ws/publisher.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
// MessagePublisher 定义消息发布接口
|
||||||
|
// ws.Hub 隐式满足此接口,ws.Bus 组合 Hub 并扩展跨实例能力
|
||||||
|
type MessagePublisher interface {
|
||||||
|
// 消息发布
|
||||||
|
PublishToUser(userID string, eventType string, payload any) Event
|
||||||
|
PublishToUsers(userIDs []string, eventType string, payload any)
|
||||||
|
PublishToUsersConcurrent(userIDs []string, eventType string, payload any)
|
||||||
|
PublishToUserOnline(userID string, eventType string, payload any) bool
|
||||||
|
PublishToUserOnlineReliable(userID string, eventType string, payload any) bool
|
||||||
|
Broadcast(eventType string, payload any)
|
||||||
|
|
||||||
|
// 在线状态
|
||||||
|
HasClients(userID string) bool
|
||||||
|
FilterOnline(userIDs []string) (online, offline []string)
|
||||||
|
|
||||||
|
// 本地操作(不经过 Redis)
|
||||||
|
SendError(client *Client, code string, message string)
|
||||||
|
NextID() uint64
|
||||||
|
OnDisconnect(handler DisconnectHandler)
|
||||||
|
OnConnect(handler ConnectHandler)
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,13 +50,18 @@ func (s *accountCleanupServiceImpl) CleanupExpiredAccounts(ctx context.Context)
|
|||||||
var deletedCount int64
|
var deletedCount int64
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if err := s.anonymizeAndDeleteUser(ctx, user); err != nil {
|
if err := s.anonymizeAndDeleteUser(ctx, user); err != nil {
|
||||||
log.Printf("[AccountCleanup] failed to cleanup user %s: %v", user.ID, err)
|
zap.L().Error("Failed to cleanup user",
|
||||||
|
zap.String("user_id", user.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
deletedCount++
|
deletedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[AccountCleanup] cleaned up %d expired accounts", deletedCount)
|
zap.L().Info("Cleaned up expired accounts",
|
||||||
|
zap.Int64("count", deletedCount),
|
||||||
|
)
|
||||||
return deletedCount, nil
|
return deletedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
redispkg "with_you/internal/pkg/redis"
|
||||||
|
|
||||||
"with_you/internal/config"
|
"with_you/internal/config"
|
||||||
apperrors "with_you/internal/errors"
|
apperrors "with_you/internal/errors"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -22,8 +24,27 @@ import (
|
|||||||
const (
|
const (
|
||||||
CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix)
|
CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix)
|
||||||
CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒
|
CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒
|
||||||
|
|
||||||
|
redisCallKeyPrefix = "call:"
|
||||||
|
redisCallByUserPrefix = "call_by_user:"
|
||||||
|
redisCallAcceptPrefix = "call:accept:"
|
||||||
|
redisCallTTL = 120 * time.Second
|
||||||
|
redisCallAcceptLockTTL = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// activeCallRedisData Redis 中存储的通话数据(精简字段)
|
||||||
|
type activeCallRedisData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
CallerID string `json:"caller_id"`
|
||||||
|
CalleeID string `json:"callee_id"`
|
||||||
|
CallType string `json:"call_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
StartedAt int64 `json:"started_at"` // 0 means nil
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveCall 内存中的活跃通话
|
// ActiveCall 内存中的活跃通话
|
||||||
type ActiveCall struct {
|
type ActiveCall struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -65,9 +86,10 @@ type CallService interface {
|
|||||||
|
|
||||||
type callService struct {
|
type callService struct {
|
||||||
callRepo repository.CallRepository
|
callRepo repository.CallRepository
|
||||||
hub *ws.Hub
|
hub ws.MessagePublisher
|
||||||
config *config.Config
|
config *config.Config
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
redis *redispkg.Client
|
||||||
|
|
||||||
// 内存中的活跃通话状态
|
// 内存中的活跃通话状态
|
||||||
activeCalls map[string]*ActiveCall // callID -> ActiveCall
|
activeCalls map[string]*ActiveCall // callID -> ActiveCall
|
||||||
@@ -79,22 +101,24 @@ type callService struct {
|
|||||||
// NewCallService 创建通话服务
|
// NewCallService 创建通话服务
|
||||||
func NewCallService(
|
func NewCallService(
|
||||||
callRepo repository.CallRepository,
|
callRepo repository.CallRepository,
|
||||||
hub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
|
redisClient *redispkg.Client,
|
||||||
) CallService {
|
) CallService {
|
||||||
svc := &callService{
|
svc := &callService{
|
||||||
callRepo: callRepo,
|
callRepo: callRepo,
|
||||||
hub: hub,
|
hub: publisher,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
db: db,
|
db: db,
|
||||||
|
redis: redisClient,
|
||||||
activeCalls: make(map[string]*ActiveCall),
|
activeCalls: make(map[string]*ActiveCall),
|
||||||
activeCallsByUser: make(map[string]map[string]bool),
|
activeCallsByUser: make(map[string]map[string]bool),
|
||||||
activeCallsByConv: make(map[string]string),
|
activeCallsByConv: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅 WebSocket 断开事件
|
// 订阅 WebSocket 断开事件
|
||||||
hub.OnDisconnect(svc.handleUserDisconnect)
|
publisher.OnDisconnect(svc.handleUserDisconnect)
|
||||||
|
|
||||||
// 自动启动超时清理
|
// 自动启动超时清理
|
||||||
svc.StartCleanupTicker()
|
svc.StartCleanupTicker()
|
||||||
@@ -108,11 +132,159 @@ func (s *callService) generateCallID() string {
|
|||||||
return strconv.FormatInt(id, 10)
|
return strconv.FormatInt(id, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getActiveCall 从内存获取活跃通话
|
// redisStoreCall 存储通话数据到 Redis
|
||||||
|
func (s *callService) redisStoreCall(call *ActiveCall) {
|
||||||
|
if s.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var startedAt int64
|
||||||
|
if call.StartedAt != nil {
|
||||||
|
startedAt = call.StartedAt.UnixMilli()
|
||||||
|
}
|
||||||
|
data := activeCallRedisData{
|
||||||
|
ID: call.ID,
|
||||||
|
ConversationID: call.ConversationID,
|
||||||
|
CallerID: call.CallerID,
|
||||||
|
CalleeID: call.CalleeID,
|
||||||
|
CallType: string(call.CallType),
|
||||||
|
Status: string(call.Status),
|
||||||
|
MediaType: call.MediaType,
|
||||||
|
CreatedAt: call.CreatedAt.UnixMilli(),
|
||||||
|
StartedAt: startedAt,
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Failed to marshal call for Redis", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callKey := redisCallKeyPrefix + call.ID
|
||||||
|
callerKey := redisCallByUserPrefix + call.CallerID
|
||||||
|
calleeKey := redisCallByUserPrefix + call.CalleeID
|
||||||
|
|
||||||
|
pipe := s.redis.Pipeline()
|
||||||
|
pipe.Set(ctx, callKey, jsonData, redisCallTTL)
|
||||||
|
pipe.Set(ctx, callerKey, call.ID, redisCallTTL)
|
||||||
|
pipe.Set(ctx, calleeKey, call.ID, redisCallTTL)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
zap.L().Error("Failed to store call in Redis", zap.String("call_id", call.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisRemoveCall 从 Redis 移除通话数据
|
||||||
|
func (s *callService) redisRemoveCall(call *ActiveCall) {
|
||||||
|
if s.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
keys := []string{
|
||||||
|
redisCallKeyPrefix + call.ID,
|
||||||
|
redisCallByUserPrefix + call.CallerID,
|
||||||
|
redisCallByUserPrefix + call.CalleeID,
|
||||||
|
redisCallAcceptPrefix + call.ID,
|
||||||
|
}
|
||||||
|
if err := s.redis.Del(ctx, keys...); err != nil {
|
||||||
|
zap.L().Error("Failed to remove call from Redis", zap.String("call_id", call.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisGetCall 从 Redis 获取通话数据
|
||||||
|
func (s *callService) redisGetCall(callID string) *ActiveCall {
|
||||||
|
if s.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
raw, err := s.redis.Get(ctx, redisCallKeyPrefix+callID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data activeCallRedisData
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
zap.L().Error("Failed to unmarshal call from Redis", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
call := &ActiveCall{
|
||||||
|
ID: data.ID,
|
||||||
|
ConversationID: data.ConversationID,
|
||||||
|
CallerID: data.CallerID,
|
||||||
|
CalleeID: data.CalleeID,
|
||||||
|
CallType: model.CallType(data.CallType),
|
||||||
|
Status: model.CallStatus(data.Status),
|
||||||
|
MediaType: data.MediaType,
|
||||||
|
ICEServers: s.config.WebRTC.ICEServers,
|
||||||
|
Participants: map[string]*ActiveParticipant{
|
||||||
|
data.CallerID: {UserID: data.CallerID, Status: model.ParticipantStatusJoined},
|
||||||
|
data.CalleeID: {UserID: data.CalleeID, Status: model.ParticipantStatusInvited},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
call.CreatedAt = time.UnixMilli(data.CreatedAt)
|
||||||
|
if data.StartedAt > 0 {
|
||||||
|
t := time.UnixMilli(data.StartedAt)
|
||||||
|
call.StartedAt = &t
|
||||||
|
// 如果已经开始,被叫方也已加入
|
||||||
|
call.Participants[data.CalleeID] = &ActiveParticipant{
|
||||||
|
UserID: data.CalleeID,
|
||||||
|
Status: model.ParticipantStatusJoined,
|
||||||
|
JoinedAt: call.StartedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisRefreshTTL 刷新通话相关 Redis 键的 TTL
|
||||||
|
func (s *callService) redisRefreshTTL(call *ActiveCall) {
|
||||||
|
if s.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
keys := []string{
|
||||||
|
redisCallKeyPrefix + call.ID,
|
||||||
|
redisCallByUserPrefix + call.CallerID,
|
||||||
|
redisCallByUserPrefix + call.CalleeID,
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
_, _ = s.redis.Expire(ctx, key, redisCallTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisTryAcceptLock 尝试获取 Accept 分布式锁
|
||||||
|
func (s *callService) redisTryAcceptLock(callID string) bool {
|
||||||
|
if s.redis == nil {
|
||||||
|
return true // 无 Redis 时允许通过(降级为本地锁)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
ok, err := s.redis.GetClient().SetNX(ctx, redisCallAcceptPrefix+callID, "1", redisCallAcceptLockTTL).Result()
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Redis SETNX error, allowing accept", zap.Error(err))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveCall 从内存获取活跃通话,L1 未命中时回退到 Redis
|
||||||
func (s *callService) getActiveCall(callID string) *ActiveCall {
|
func (s *callService) getActiveCall(callID string) *ActiveCall {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
call := s.activeCalls[callID]
|
||||||
return s.activeCalls[callID]
|
s.mu.RUnlock()
|
||||||
|
if call != nil {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 miss → Redis fallback
|
||||||
|
call = s.redisGetCall(callID)
|
||||||
|
if call != nil {
|
||||||
|
s.storeActiveCall(call)
|
||||||
|
}
|
||||||
|
return call
|
||||||
}
|
}
|
||||||
|
|
||||||
// getActiveCallByConversation 从内存获取会话的活跃通话
|
// getActiveCallByConversation 从内存获取会话的活跃通话
|
||||||
@@ -224,6 +396,9 @@ func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversati
|
|||||||
// 存储到内存
|
// 存储到内存
|
||||||
s.storeActiveCall(call)
|
s.storeActiveCall(call)
|
||||||
|
|
||||||
|
// 写入 Redis
|
||||||
|
s.redisStoreCall(call)
|
||||||
|
|
||||||
// 检查被叫方是否在线
|
// 检查被叫方是否在线
|
||||||
calleeOnline := s.hub.HasClients(calleeID)
|
calleeOnline := s.hub.HasClients(calleeID)
|
||||||
|
|
||||||
@@ -272,6 +447,11 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*Activ
|
|||||||
return nil, apperrors.ErrCallAlreadyAnswered
|
return nil, apperrors.ErrCallAlreadyAnswered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分布式锁:防止跨实例重复 Accept
|
||||||
|
if !s.redisTryAcceptLock(callID) {
|
||||||
|
return nil, apperrors.ErrCallAlreadyAnswered
|
||||||
|
}
|
||||||
|
|
||||||
// 使用原子操作更新状态
|
// 使用原子操作更新状态
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if call.Status != model.CallStatusCalling {
|
if call.Status != model.CallStatusCalling {
|
||||||
@@ -285,6 +465,10 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*Activ
|
|||||||
call.Participants[userID].JoinedAt = &now
|
call.Participants[userID].JoinedAt = &now
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// 更新 Redis
|
||||||
|
s.redisStoreCall(call)
|
||||||
|
s.redisRefreshTTL(call)
|
||||||
|
|
||||||
// 通知拨打方
|
// 通知拨打方
|
||||||
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]any{
|
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]any{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
@@ -324,6 +508,9 @@ func (s *callService) Reject(ctx context.Context, callID, userID string) error {
|
|||||||
// 从内存移除
|
// 从内存移除
|
||||||
s.removeActiveCall(callID)
|
s.removeActiveCall(callID)
|
||||||
|
|
||||||
|
// 从 Redis 移除
|
||||||
|
s.redisRemoveCall(call)
|
||||||
|
|
||||||
// 保存到数据库作为历史记录
|
// 保存到数据库作为历史记录
|
||||||
s.saveCallHistory(call, model.CallStatusRejected, 0)
|
s.saveCallHistory(call, model.CallStatusRejected, 0)
|
||||||
|
|
||||||
@@ -358,6 +545,9 @@ func (s *callService) Busy(ctx context.Context, callID, userID string) error {
|
|||||||
// 从内存移除
|
// 从内存移除
|
||||||
s.removeActiveCall(callID)
|
s.removeActiveCall(callID)
|
||||||
|
|
||||||
|
// 从 Redis 移除
|
||||||
|
s.redisRemoveCall(call)
|
||||||
|
|
||||||
// 保存到数据库作为历史记录
|
// 保存到数据库作为历史记录
|
||||||
s.saveCallHistory(call, model.CallStatusMissed, 0)
|
s.saveCallHistory(call, model.CallStatusMissed, 0)
|
||||||
|
|
||||||
@@ -414,6 +604,9 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
|
|||||||
// 从内存移除
|
// 从内存移除
|
||||||
s.removeActiveCall(callID)
|
s.removeActiveCall(callID)
|
||||||
|
|
||||||
|
// 从 Redis 移除
|
||||||
|
s.redisRemoveCall(call)
|
||||||
|
|
||||||
// 保存到数据库作为历史记录
|
// 保存到数据库作为历史记录
|
||||||
s.saveCallHistory(call, endStatus, duration)
|
s.saveCallHistory(call, endStatus, duration)
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type chatServiceImpl struct {
|
|||||||
repo repository.MessageRepository
|
repo repository.MessageRepository
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
sensitive SensitiveService
|
sensitive SensitiveService
|
||||||
wsHub *ws.Hub
|
wsHub ws.MessagePublisher
|
||||||
pushSvc PushService
|
pushSvc PushService
|
||||||
cache cache.Cache
|
cache cache.Cache
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ func NewChatService(
|
|||||||
repo repository.MessageRepository,
|
repo repository.MessageRepository,
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
sensitive SensitiveService,
|
sensitive SensitiveService,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
uploadService *UploadService,
|
uploadService *UploadService,
|
||||||
pushSvc PushService,
|
pushSvc PushService,
|
||||||
@@ -101,7 +101,7 @@ func NewChatService(
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
wsHub: wsHub,
|
wsHub: publisher,
|
||||||
pushSvc: pushSvc,
|
pushSvc: pushSvc,
|
||||||
cache: cacheBackend,
|
cache: cacheBackend,
|
||||||
conversationCache: conversationCache,
|
conversationCache: conversationCache,
|
||||||
|
|||||||
@@ -112,13 +112,13 @@ type groupService struct {
|
|||||||
messageRepo repository.MessageRepository
|
messageRepo repository.MessageRepository
|
||||||
requestRepo repository.GroupJoinRequestRepository
|
requestRepo repository.GroupJoinRequestRepository
|
||||||
notifyRepo repository.SystemNotificationRepository
|
notifyRepo repository.SystemNotificationRepository
|
||||||
wsHub *ws.Hub
|
wsHub ws.MessagePublisher
|
||||||
cache cache.Cache
|
cache cache.Cache
|
||||||
conversationCache *cache.ConversationCache
|
conversationCache *cache.ConversationCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGroupService 创建群组服务
|
// NewGroupService 创建群组服务
|
||||||
func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, wsHub *ws.Hub, cacheBackend cache.Cache) GroupService {
|
func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, publisher ws.MessagePublisher, cacheBackend cache.Cache) GroupService {
|
||||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo)
|
convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo)
|
||||||
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
|
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
|
||||||
conversationCache := cache.NewConversationCache(
|
conversationCache := cache.NewConversationCache(
|
||||||
@@ -134,7 +134,7 @@ func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.U
|
|||||||
messageRepo: messageRepo,
|
messageRepo: messageRepo,
|
||||||
requestRepo: requestRepo,
|
requestRepo: requestRepo,
|
||||||
notifyRepo: notifyRepo,
|
notifyRepo: notifyRepo,
|
||||||
wsHub: wsHub,
|
wsHub: publisher,
|
||||||
cache: cacheBackend,
|
cache: cacheBackend,
|
||||||
conversationCache: conversationCache,
|
conversationCache: conversationCache,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type pushServiceImpl struct {
|
|||||||
pushRepo repository.PushRecordRepository
|
pushRepo repository.PushRecordRepository
|
||||||
deviceRepo repository.DeviceTokenRepository
|
deviceRepo repository.DeviceTokenRepository
|
||||||
messageRepo repository.MessageRepository
|
messageRepo repository.MessageRepository
|
||||||
wsHub *ws.Hub
|
wsHub ws.MessagePublisher
|
||||||
jpushClient *jpush.Client
|
jpushClient *jpush.Client
|
||||||
|
|
||||||
pushQueue chan *pushTask
|
pushQueue chan *pushTask
|
||||||
@@ -102,14 +102,14 @@ func NewPushService(
|
|||||||
pushRepo repository.PushRecordRepository,
|
pushRepo repository.PushRecordRepository,
|
||||||
deviceRepo repository.DeviceTokenRepository,
|
deviceRepo repository.DeviceTokenRepository,
|
||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
jpushClient *jpush.Client,
|
jpushClient *jpush.Client,
|
||||||
) PushService {
|
) PushService {
|
||||||
return &pushServiceImpl{
|
return &pushServiceImpl{
|
||||||
pushRepo: pushRepo,
|
pushRepo: pushRepo,
|
||||||
deviceRepo: deviceRepo,
|
deviceRepo: deviceRepo,
|
||||||
messageRepo: messageRepo,
|
messageRepo: messageRepo,
|
||||||
wsHub: wsHub,
|
wsHub: publisher,
|
||||||
jpushClient: jpushClient,
|
jpushClient: jpushClient,
|
||||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type QRCodeSession struct {
|
|||||||
// QRCodeLoginService 二维码登录服务
|
// QRCodeLoginService 二维码登录服务
|
||||||
type QRCodeLoginService struct {
|
type QRCodeLoginService struct {
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
wsHub *ws.Hub
|
wsHub ws.MessagePublisher
|
||||||
jwtService *JWTService
|
jwtService *JWTService
|
||||||
userService UserService
|
userService UserService
|
||||||
activityService UserActivityService
|
activityService UserActivityService
|
||||||
@@ -62,10 +62,10 @@ type QRCodeLoginService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewQRCodeLoginService 创建二维码登录服务
|
// NewQRCodeLoginService 创建二维码登录服务
|
||||||
func NewQRCodeLoginService(redis *redis.Client, wsHub *ws.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
func NewQRCodeLoginService(redis *redis.Client, publisher ws.MessagePublisher, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
||||||
return &QRCodeLoginService{
|
return &QRCodeLoginService{
|
||||||
redis: redis,
|
redis: redis,
|
||||||
wsHub: wsHub,
|
wsHub: publisher,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
activityService: activityService,
|
activityService: activityService,
|
||||||
@@ -280,9 +280,16 @@ func (s *QRCodeLoginService) Cancel(ctx context.Context, sessionID, userID strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWSHub 获取WS Hub
|
// GetWSHub 获取底层 WS Hub(用于 Subscribe 等本地操作)
|
||||||
func (s *QRCodeLoginService) GetWSHub() *ws.Hub {
|
func (s *QRCodeLoginService) GetWSHub() *ws.Hub {
|
||||||
return s.wsHub
|
switch p := s.wsHub.(type) {
|
||||||
|
case *ws.Bus:
|
||||||
|
return p.Hub()
|
||||||
|
case *ws.Hub:
|
||||||
|
return p
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserService 获取用户服务
|
// GetUserService 获取用户服务
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.tradeRepo.IncrementViews(ctx, id)
|
|
||||||
|
|
||||||
return dto.ConvertTradeItemToResponse(item, isFavorited), nil
|
return dto.ConvertTradeItemToResponse(item, isFavorited), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -179,7 +178,10 @@ func (s *userProfileAuditServiceImpl) GetPendingCounts(ctx context.Context) (int
|
|||||||
func (s *userProfileAuditServiceImpl) reviewAvatarAsync(auditID, userID, avatarURL string) {
|
func (s *userProfileAuditServiceImpl) reviewAvatarAsync(auditID, userID, avatarURL string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("[ERROR] Panic in avatar moderation async flow, audit=%s panic=%v", auditID, r)
|
zap.L().Error("Panic in avatar moderation async flow",
|
||||||
|
zap.String("audit_id", auditID),
|
||||||
|
zap.Any("panic", r),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -245,7 +247,10 @@ func (s *userProfileAuditServiceImpl) reviewAvatarAsync(auditID, userID, avatarU
|
|||||||
func (s *userProfileAuditServiceImpl) reviewCoverAsync(auditID, userID, coverURL string) {
|
func (s *userProfileAuditServiceImpl) reviewCoverAsync(auditID, userID, coverURL string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("[ERROR] Panic in cover moderation async flow, audit=%s panic=%v", auditID, r)
|
zap.L().Error("Panic in cover moderation async flow",
|
||||||
|
zap.String("audit_id", auditID),
|
||||||
|
zap.Any("panic", r),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -311,7 +316,10 @@ func (s *userProfileAuditServiceImpl) reviewCoverAsync(auditID, userID, coverURL
|
|||||||
func (s *userProfileAuditServiceImpl) reviewBioAsync(auditID, userID, bio string) {
|
func (s *userProfileAuditServiceImpl) reviewBioAsync(auditID, userID, bio string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("[ERROR] Panic in bio moderation async flow, audit=%s panic=%v", auditID, r)
|
zap.L().Error("Panic in bio moderation async flow",
|
||||||
|
zap.String("audit_id", auditID),
|
||||||
|
zap.Any("panic", r),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ func ProvideMessageHandler(
|
|||||||
messageService *service.MessageService,
|
messageService *service.MessageService,
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
) *handler.MessageHandler {
|
) *handler.MessageHandler {
|
||||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, wsHub)
|
return handler.NewMessageHandler(chatService, messageService, userService, groupService, publisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSystemMessageHandler 提供系统消息处理器
|
// ProvideSystemMessageHandler 提供系统消息处理器
|
||||||
@@ -93,14 +93,14 @@ func ProvideScheduleHandler(
|
|||||||
|
|
||||||
// ProvideWSHandler 提供WebSocket处理器
|
// ProvideWSHandler 提供WebSocket处理器
|
||||||
func ProvideWSHandler(
|
func ProvideWSHandler(
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
chatService service.ChatService,
|
chatService service.ChatService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
jwtService *service.JWTService,
|
jwtService *service.JWTService,
|
||||||
callService service.CallService,
|
callService service.CallService,
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
) *handler.WSHandler {
|
) *handler.WSHandler {
|
||||||
return handler.NewWSHandler(wsHub, chatService, groupService, jwtService, callService, userRepo)
|
return handler.NewWSHandler(publisher, chatService, groupService, jwtService, callService, userRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideAdminVerificationHandler 提供管理端身份认证处理器
|
// ProvideAdminVerificationHandler 提供管理端身份认证处理器
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ var InfrastructureSet = wire.NewSet(
|
|||||||
ProvideBuiltinHooks,
|
ProvideBuiltinHooks,
|
||||||
ProvideModerationHooks,
|
ProvideModerationHooks,
|
||||||
|
|
||||||
// WebSocket Hub
|
// WebSocket MessagePublisher
|
||||||
ProvideWSHub,
|
ProvideWSMessagePublisher,
|
||||||
|
|
||||||
// 外部服务客户端
|
// 外部服务客户端
|
||||||
ProvideOpenAIClient,
|
ProvideOpenAIClient,
|
||||||
@@ -163,9 +163,32 @@ func ProvideModerationHooks(
|
|||||||
return moderationHooks
|
return moderationHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideWSHub 提供 WebSocket Hub
|
// ProvideWSMessagePublisher 提供 WebSocket 消息发布器
|
||||||
func ProvideWSHub() *ws.Hub {
|
// standalone 模式返回 *Hub,cluster 模式返回 *Bus(组合 Hub + Redis Pub/Sub)
|
||||||
return ws.NewHub()
|
func ProvideWSMessagePublisher(cfg *config.Config, redisClient *redis.Client) ws.MessagePublisher {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
|
||||||
|
if cfg.WebSocket.Mode == "cluster" && redisClient != nil {
|
||||||
|
bus := ws.NewBus(hub, redisClient.GetClient(), &ws.WSClusterConfig{
|
||||||
|
InstanceID: cfg.WebSocket.Cluster.InstanceID,
|
||||||
|
MsgChannel: cfg.WebSocket.Cluster.MsgChannel,
|
||||||
|
OnlineTTL: cfg.WebSocket.Cluster.OnlineTTL,
|
||||||
|
HeartbeatInterval: cfg.WebSocket.Cluster.HeartbeatInterval,
|
||||||
|
})
|
||||||
|
if err := bus.Start(); err != nil {
|
||||||
|
zap.L().Error("Failed to start WebSocket Bus, falling back to standalone mode",
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return hub
|
||||||
|
}
|
||||||
|
zap.L().Info("WebSocket running in cluster mode",
|
||||||
|
zap.String("instance_id", bus.InstanceID()),
|
||||||
|
)
|
||||||
|
return bus
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.L().Info("WebSocket running in standalone mode")
|
||||||
|
return hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideOpenAIClient 提供 OpenAI 客户端
|
// ProvideOpenAIClient 提供 OpenAI 客户端
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ func ProvidePushService(
|
|||||||
pushRepo repository.PushRecordRepository,
|
pushRepo repository.PushRecordRepository,
|
||||||
deviceTokenRepo repository.DeviceTokenRepository,
|
deviceTokenRepo repository.DeviceTokenRepository,
|
||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
jpushClient *jpush.Client,
|
jpushClient *jpush.Client,
|
||||||
) service.PushService {
|
) service.PushService {
|
||||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub, jpushClient)
|
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, publisher, jpushClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSystemMessageService 提供系统消息服务
|
// ProvideSystemMessageService 提供系统消息服务
|
||||||
@@ -205,12 +205,12 @@ func ProvideChannelService(channelRepo repository.ChannelRepository, cacheBacken
|
|||||||
func ProvideChatService(
|
func ProvideChatService(
|
||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
uploadService *service.UploadService,
|
uploadService *service.UploadService,
|
||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
) service.ChatService {
|
) service.ChatService {
|
||||||
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService, pushService)
|
return service.NewChatService(messageRepo, userRepo, nil, publisher, cacheBackend, uploadService, pushService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideScheduleService 提供日程服务
|
// ProvideScheduleService 提供日程服务
|
||||||
@@ -236,10 +236,10 @@ func ProvideGroupService(
|
|||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
requestRepo repository.GroupJoinRequestRepository,
|
requestRepo repository.GroupJoinRequestRepository,
|
||||||
notifyRepo repository.SystemNotificationRepository,
|
notifyRepo repository.SystemNotificationRepository,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
) service.GroupService {
|
) service.GroupService {
|
||||||
return service.NewGroupService(groupRepo, userRepo, messageRepo, requestRepo, notifyRepo, wsHub, cacheBackend)
|
return service.NewGroupService(groupRepo, userRepo, messageRepo, requestRepo, notifyRepo, publisher, cacheBackend)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideUploadService 提供上传服务
|
// ProvideUploadService 提供上传服务
|
||||||
@@ -382,13 +382,13 @@ func ProvideHotRankWorker(cfg *config.Config, postRepo repository.PostRepository
|
|||||||
|
|
||||||
func ProvideQRCodeLoginService(
|
func ProvideQRCodeLoginService(
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
jwtService *service.JWTService,
|
jwtService *service.JWTService,
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
) *service.QRCodeLoginService {
|
) *service.QRCodeLoginService {
|
||||||
return service.NewQRCodeLoginService(redisClient, wsHub, jwtService, userService, activityService, logService)
|
return service.NewQRCodeLoginService(redisClient, publisher, jwtService, userService, activityService, logService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideMaterialService 提供学习资料服务
|
// ProvideMaterialService 提供学习资料服务
|
||||||
@@ -407,11 +407,12 @@ func parseDuration(s string) (time.Duration, error) {
|
|||||||
// ProvideCallService 提供通话服务
|
// ProvideCallService 提供通话服务
|
||||||
func ProvideCallService(
|
func ProvideCallService(
|
||||||
callRepo repository.CallRepository,
|
callRepo repository.CallRepository,
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
|
redisClient *redis.Client,
|
||||||
) service.CallService {
|
) service.CallService {
|
||||||
return service.NewCallService(callRepo, wsHub, cfg, db)
|
return service.NewCallService(callRepo, publisher, cfg, db, redisClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideReportService 提供举报服务
|
// ProvideReportService 提供举报服务
|
||||||
|
|||||||
Reference in New Issue
Block a user