Files
backend/internal/service/call_service.go
lan c630cbf4d0
All checks were successful
Build Backend / build (push) Successful in 2m0s
Build Backend / build-docker (push) Successful in 1m26s
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.
2026-05-06 12:39:11 +08:00

802 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"time"
redispkg "with_you/internal/pkg/redis"
"with_you/internal/config"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"with_you/internal/pkg/ws"
"with_you/internal/repository"
"go.uber.org/zap"
"gorm.io/gorm"
)
// 通话相关常量
const (
CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix)
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 内存中的活跃通话
type ActiveCall struct {
ID string
ConversationID string
CallerID string
CalleeID string
CallType model.CallType
Status model.CallStatus
MediaType string // voice 或 video
CreatedAt time.Time
StartedAt *time.Time
Duration int64 // 通话时长(秒)
// 参与者状态
Participants map[string]*ActiveParticipant
// ICE Servers
ICEServers []config.ICEServerConfig
}
// ActiveParticipant 内存中的活跃参与者
type ActiveParticipant struct {
UserID string
Status model.ParticipantStatus
JoinedAt *time.Time
}
// CallService 通话服务接口
type CallService interface {
Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error)
Accept(ctx context.Context, callID, userID string) (*ActiveCall, error)
Reject(ctx context.Context, callID, userID string) error
Busy(ctx context.Context, callID, userID string) error
End(ctx context.Context, callID, userID string, reason string) (*ActiveCall, error)
RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) error
SetMuted(ctx context.Context, callID, userID string, muted bool) error
GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error)
GetICEServers() []config.ICEServerConfig
StartCleanupTicker()
}
type callService struct {
callRepo repository.CallRepository
hub ws.MessagePublisher
config *config.Config
db *gorm.DB
redis *redispkg.Client
// 内存中的活跃通话状态
activeCalls map[string]*ActiveCall // callID -> ActiveCall
activeCallsByUser map[string]map[string]bool // userID -> set of callIDs
activeCallsByConv map[string]string // conversationID -> callID
mu sync.RWMutex
}
// NewCallService 创建通话服务
func NewCallService(
callRepo repository.CallRepository,
publisher ws.MessagePublisher,
cfg *config.Config,
db *gorm.DB,
redisClient *redispkg.Client,
) CallService {
svc := &callService{
callRepo: callRepo,
hub: publisher,
config: cfg,
db: db,
redis: redisClient,
activeCalls: make(map[string]*ActiveCall),
activeCallsByUser: make(map[string]map[string]bool),
activeCallsByConv: make(map[string]string),
}
// 订阅 WebSocket 断开事件
publisher.OnDisconnect(svc.handleUserDisconnect)
// 自动启动超时清理
svc.StartCleanupTicker()
return svc
}
// generateCallID 生成通话ID
func (s *callService) generateCallID() string {
id := time.Now().UnixNano()
return strconv.FormatInt(id, 10)
}
// 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 {
s.mu.RLock()
call := 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 从内存获取会话的活跃通话
func (s *callService) getActiveCallByConversation(conversationID string) *ActiveCall {
s.mu.RLock()
callID, exists := s.activeCallsByConv[conversationID]
if !exists {
s.mu.RUnlock()
return nil
}
call := s.activeCalls[callID]
s.mu.RUnlock()
return call
}
// getActiveCallsByUser 获取用户的所有活跃通话
func (s *callService) getActiveCallsByUser(userID string) []*ActiveCall {
s.mu.RLock()
callIDs, exists := s.activeCallsByUser[userID]
if !exists {
s.mu.RUnlock()
return nil
}
var calls []*ActiveCall
for callID := range callIDs {
if call, ok := s.activeCalls[callID]; ok {
calls = append(calls, call)
}
}
s.mu.RUnlock()
return calls
}
// storeActiveCall 存储活跃通话到内存
func (s *callService) storeActiveCall(call *ActiveCall) {
s.mu.Lock()
defer s.mu.Unlock()
s.activeCalls[call.ID] = call
// 按会话索引
s.activeCallsByConv[call.ConversationID] = call.ID
// 按用户索引
for userID := range call.Participants {
if s.activeCallsByUser[userID] == nil {
s.activeCallsByUser[userID] = make(map[string]bool)
}
s.activeCallsByUser[userID][call.ID] = true
}
}
// removeActiveCall 从内存移除活跃通话
func (s *callService) removeActiveCall(callID string) *ActiveCall {
s.mu.Lock()
defer s.mu.Unlock()
call, exists := s.activeCalls[callID]
if !exists {
return nil
}
delete(s.activeCalls, callID)
delete(s.activeCallsByConv, call.ConversationID)
for userID := range call.Participants {
if userCalls, ok := s.activeCallsByUser[userID]; ok {
delete(userCalls, callID)
if len(userCalls) == 0 {
delete(s.activeCallsByUser, userID)
}
}
}
return call
}
// isParticipant 检查用户是否是通话参与者
func isParticipant(call *ActiveCall, userID string) bool {
_, ok := call.Participants[userID]
return ok
}
func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error) {
// 检查会话是否有活跃通话
if active := s.getActiveCallByConversation(conversationID); active != nil {
return nil, false, apperrors.Wrap(fmt.Errorf("call %s", active.ID), apperrors.ErrCallInProgress)
}
now := time.Now()
callID := s.generateCallID()
call := &ActiveCall{
ID: callID,
ConversationID: conversationID,
CallerID: callerID,
CalleeID: calleeID,
CallType: model.CallTypePrivate,
Status: model.CallStatusCalling,
MediaType: mediaType,
CreatedAt: now,
Participants: map[string]*ActiveParticipant{
callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now},
calleeID: {UserID: calleeID, Status: model.ParticipantStatusInvited},
},
ICEServers: s.config.WebRTC.ICEServers,
}
// 存储到内存
s.storeActiveCall(call)
// 写入 Redis
s.redisStoreCall(call)
// 检查被叫方是否在线
calleeOnline := s.hub.HasClients(calleeID)
// 发送来电通知
payload := map[string]any{
"call_id": call.ID,
"conversation_id": conversationID,
"caller_id": callerID,
"call_type": call.CallType,
"media_type": mediaType,
"created_at": now.UnixMilli(),
"lifetime": CallLifetimeMs,
"ice_servers": s.config.WebRTC.ICEServers,
}
online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload)
if online {
zap.L().Debug("Call invite sent to online callee",
zap.String("call_id", call.ID),
zap.String("callee_id", calleeID),
)
} else {
zap.L().Debug("Call invite created for offline callee",
zap.String("call_id", call.ID),
zap.String("callee_id", calleeID),
)
}
return call, calleeOnline, nil
}
func (s *callService) Accept(ctx context.Context, callID, userID string) (*ActiveCall, error) {
call := s.getActiveCall(callID)
if call == nil {
return nil, apperrors.ErrCallNotFound
}
// 检查用户是否是参与者
if !isParticipant(call, userID) {
return nil, apperrors.ErrNotCallParticipant
}
// 检查通话状态
if call.Status != model.CallStatusCalling {
return nil, apperrors.ErrCallAlreadyAnswered
}
// 分布式锁:防止跨实例重复 Accept
if !s.redisTryAcceptLock(callID) {
return nil, apperrors.ErrCallAlreadyAnswered
}
// 使用原子操作更新状态
s.mu.Lock()
if call.Status != model.CallStatusCalling {
s.mu.Unlock()
return nil, apperrors.ErrCallAlreadyAnswered
}
now := time.Now()
call.Status = model.CallStatusConnected
call.StartedAt = &now
call.Participants[userID].Status = model.ParticipantStatusJoined
call.Participants[userID].JoinedAt = &now
s.mu.Unlock()
// 更新 Redis
s.redisStoreCall(call)
s.redisRefreshTTL(call)
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]any{
"call_id": callID,
"started_at": now.UnixMilli(),
"ice_servers": s.config.WebRTC.ICEServers,
})
// 通知被叫方其他设备
s.hub.PublishToUserOnline(userID, "call_answered_elsewhere", map[string]any{
"call_id": callID,
"reason": "answered_on_another_device",
})
return call, nil
}
func (s *callService) Reject(ctx context.Context, callID, userID string) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.Lock()
if call.Status != model.CallStatusCalling {
s.mu.Unlock()
return apperrors.ErrInvalidCallState
}
if !isParticipant(call, userID) {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant
}
call.Status = model.CallStatusRejected
call.Participants[userID].Status = model.ParticipantStatusRejected
s.mu.Unlock()
// 从内存移除
s.removeActiveCall(callID)
// 从 Redis 移除
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, model.CallStatusRejected, 0)
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]any{
"call_id": callID,
"reason": "rejected",
})
return nil
}
func (s *callService) Busy(ctx context.Context, callID, userID string) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.Lock()
if call.Status != model.CallStatusCalling {
s.mu.Unlock()
return apperrors.ErrInvalidCallState
}
if !isParticipant(call, userID) {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant
}
call.Status = model.CallStatusMissed
s.mu.Unlock()
// 从内存移除
s.removeActiveCall(callID)
// 从 Redis 移除
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, model.CallStatusMissed, 0)
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]any{
"call_id": callID,
})
return nil
}
func (s *callService) End(ctx context.Context, callID, userID string, reason string) (*ActiveCall, error) {
call := s.getActiveCall(callID)
if call == nil {
return nil, apperrors.ErrCallNotFound
}
s.mu.Lock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.Unlock()
return nil, apperrors.ErrCallNotActive
}
// 允许系统结束通话时 userID 为空
if userID != "" && !isParticipant(call, userID) {
s.mu.Unlock()
return nil, apperrors.ErrNotCallParticipant
}
now := time.Now()
var duration int64
if call.StartedAt != nil {
duration = int64(now.Sub(*call.StartedAt).Seconds())
}
endStatus := model.CallStatusEnded
if call.Status == model.CallStatusCalling {
switch reason {
case "timeout":
endStatus = model.CallStatusMissed
case "disconnect":
endStatus = model.CallStatusMissed
default:
endStatus = model.CallStatusCancelled
}
}
call.Status = endStatus
call.Duration = duration
if userID != "" {
call.Participants[userID].Status = model.ParticipantStatusLeft
}
s.mu.Unlock()
// 从内存移除
s.removeActiveCall(callID)
// 从 Redis 移除
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, endStatus, duration)
// 通知其他参与者
for pUserID := range call.Participants {
if pUserID != userID {
s.hub.PublishToUserOnline(pUserID, "call_ended", map[string]any{
"call_id": callID,
"ended_by": userID,
"reason": reason,
"duration": duration,
"ended_at": now.UnixMilli(),
})
}
}
return call, nil
}
func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.RLock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.RUnlock()
return apperrors.ErrCallNotActive
}
if !isParticipant(call, fromUserID) {
s.mu.RUnlock()
return apperrors.ErrNotCallParticipant
}
s.mu.RUnlock()
for pUserID := range call.Participants {
if pUserID != fromUserID {
s.hub.PublishToUserOnlineReliable(pUserID, signalType, map[string]any{
"call_id": callID,
"from_id": fromUserID,
"payload": json.RawMessage(payload),
})
}
}
return nil
}
func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted bool) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.RLock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.RUnlock()
return apperrors.ErrCallNotActive
}
if !isParticipant(call, userID) {
s.mu.RUnlock()
return apperrors.ErrNotCallParticipant
}
s.mu.RUnlock()
for pUserID := range call.Participants {
if pUserID != userID {
s.hub.PublishToUserOnline(pUserID, "call_peer_muted", map[string]any{
"call_id": callID,
"user_id": userID,
"muted": muted,
})
}
}
return nil
}
func (s *callService) GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error) {
return s.callRepo.GetCallHistory(userID, page, pageSize)
}
func (s *callService) GetICEServers() []config.ICEServerConfig {
return s.config.WebRTC.ICEServers
}
// saveCallHistory 保存通话历史到数据库
func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64) {
ctx := context.Background()
now := time.Now()
dbCall := &model.CallSession{
ID: call.ID,
ConversationID: call.ConversationID,
CallerID: call.CallerID,
CallType: call.CallType,
Status: status,
StartedAt: call.StartedAt,
EndedAt: &now,
Duration: duration,
CreatedAt: call.CreatedAt,
UpdatedAt: now,
}
var participants []*model.CallParticipant
for _, p := range call.Participants {
participants = append(participants, &model.CallParticipant{
CallID: call.ID,
UserID: p.UserID,
Status: p.Status,
JoinedAt: p.JoinedAt,
CreatedAt: call.CreatedAt,
UpdatedAt: now,
})
}
if err := s.callRepo.CreateCallWithParticipants(ctx, dbCall, participants); err != nil {
zap.L().Error("Failed to save call history",
zap.String("call_id", call.ID),
zap.Error(err),
)
}
}
// StartCleanupTicker 启动超时清理定时器
func (s *callService) StartCleanupTicker() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.cleanupExpiredCalls()
}
}()
}
// cleanupExpiredCalls 清理过期的通话
func (s *callService) cleanupExpiredCalls() {
ctx := context.Background()
s.mu.RLock()
var expiredCalls []string
now := time.Now()
for _, call := range s.activeCalls {
if call.Status == model.CallStatusCalling {
elapsed := now.Sub(call.CreatedAt)
if elapsed.Milliseconds() > CallTimeoutMs {
expiredCalls = append(expiredCalls, call.ID)
}
}
}
s.mu.RUnlock()
for _, callID := range expiredCalls {
zap.L().Info("Cleaning up expired call",
zap.String("call_id", callID),
)
_, _ = s.End(ctx, callID, "", "timeout")
}
}
// handleUserDisconnect 处理用户 WebSocket 断开连接事件
func (s *callService) handleUserDisconnect(userID string, remainingCount int) {
// 如果用户还有其他连接,不处理
if remainingCount > 0 {
return
}
ctx := context.Background()
// 获取用户参与的所有活跃通话
calls := s.getActiveCallsByUser(userID)
if len(calls) == 0 {
return
}
zap.L().Info("User disconnected, ending active calls",
zap.String("user_id", userID),
zap.Int("active_calls", len(calls)),
)
// 结束所有活跃通话
for _, call := range calls {
_, err := s.End(ctx, call.ID, userID, "disconnect")
if err != nil {
zap.L().Error("Failed to end call on user disconnect",
zap.String("user_id", userID),
zap.String("call_id", call.ID),
zap.Error(err),
)
}
}
}