package service import ( "context" "encoding/json" "fmt" "strconv" "sync" "time" redispkg "with_you/internal/pkg/redis" "with_you/internal/pkg/utils" "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,omitempty"` // 兼容旧数据,1v1时仍有值 CallType string `json:"call_type"` Status string `json:"status"` MediaType string `json:"media_type"` GroupID string `json:"group_id,omitempty"` CreatedAt int64 `json:"created_at"` StartedAt int64 `json:"started_at"` // 0 means nil ParticipantIDs []string `json:"participant_ids"` } // ActiveCall 内存中的活跃通话 type ActiveCall struct { ID string ConversationID string CallerID string CalleeID string // 1v1通话的被叫方ID(兼容) GroupID string // 群组通话的群组ID CallType model.CallType Status model.CallStatus MediaType string // voice 或 video CreatedAt time.Time StartedAt *time.Time Duration int64 // 通话时长(秒) // 参与者状态 Participants map[string]*ActiveParticipant } // ActiveParticipant 内存中的活跃参与者 type ActiveParticipant struct { UserID string Status model.ParticipantStatus JoinedAt *time.Time LeftAt *time.Time } // CallService 通话服务接口 type CallService interface { Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error) GroupInvite(ctx context.Context, callerID, groupID, conversationID, mediaType string) (*ActiveCall, 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) ParticipantJoin(ctx context.Context, callID, userID string) error ParticipantLeave(ctx context.Context, callID, userID string, reason string) error SetMuted(ctx context.Context, callID, userID string, muted bool) error Ready(ctx context.Context, callID, userID string) error GetActiveCall(ctx context.Context, callID string) *ActiveCall GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error) StartCleanupTicker() } type callService struct { callRepo repository.CallRepository hub ws.MessagePublisher config *config.Config db *gorm.DB redis *redispkg.Client groupService GroupService // 内存中的活跃通话状态 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, groupService GroupService, ) CallService { svc := &callService{ callRepo: callRepo, hub: publisher, config: cfg, db: db, redis: redisClient, groupService: groupService, 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(使用雪花算法,与数据库ID一致) func (s *callService) generateCallID() string { id, err := utils.GetSnowflake().GenerateID() if err != nil { zap.L().Error("Failed to generate snowflake call ID", zap.Error(err)) return strconv.FormatInt(time.Now().UnixNano(), 10) } 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() } participantIDs := make([]string, 0, len(call.Participants)) for id := range call.Participants { participantIDs = append(participantIDs, id) } 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, GroupID: call.GroupID, CreatedAt: call.CreatedAt.UnixMilli(), StartedAt: startedAt, ParticipantIDs: participantIDs, } 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 pipe := s.redis.Pipeline() pipe.Set(ctx, callKey, jsonData, redisCallTTL) // 为每个参与者建立索引 for userID := range call.Participants { pipe.Set(ctx, redisCallByUserPrefix+userID, 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, redisCallAcceptPrefix + call.ID, } for userID := range call.Participants { keys = append(keys, redisCallByUserPrefix+userID) } 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 } // 构建参与者列表 participants := make(map[string]*ActiveParticipant) for _, pid := range data.ParticipantIDs { status := model.ParticipantStatusInvited if pid == data.CallerID { status = model.ParticipantStatusJoined } participants[pid] = &ActiveParticipant{ UserID: pid, Status: status, } } // 兼容旧数据:无 ParticipantIDs 时使用 CallerID/CalleeID if len(participants) == 0 && data.CalleeID != "" { participants[data.CallerID] = &ActiveParticipant{ UserID: data.CallerID, Status: model.ParticipantStatusJoined, } participants[data.CalleeID] = &ActiveParticipant{ UserID: data.CalleeID, Status: model.ParticipantStatusInvited, } } call := &ActiveCall{ ID: data.ID, ConversationID: data.ConversationID, CallerID: data.CallerID, CalleeID: data.CalleeID, GroupID: data.GroupID, CallType: model.CallType(data.CallType), Status: model.CallStatus(data.Status), MediaType: data.MediaType, Participants: participants, } call.CreatedAt = time.UnixMilli(data.CreatedAt) if data.StartedAt > 0 { t := time.UnixMilli(data.StartedAt) call.StartedAt = &t // 如果已经开始,被叫方也已加入 if data.CalleeID != "" { if p, ok := call.Participants[data.CalleeID]; ok { p.Status = model.ParticipantStatusJoined p.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} for userID := range call.Participants { keys = append(keys, redisCallByUserPrefix+userID) } 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}, }, } // 存储到内存 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, } 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(), }) // 通知被叫方其他设备 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, userID) // 通知拨打方 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, userID) // 通知其他参与者 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) 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 } // GetActiveCall 获取活跃通话(公开方法,供 LiveKit handler 调用) func (s *callService) GetActiveCall(ctx context.Context, callID string) *ActiveCall { return s.getActiveCall(callID) } // GroupInvite 发起群组通话 func (s *callService) GroupInvite(ctx context.Context, callerID, groupID, conversationID, mediaType string) (*ActiveCall, error) { if s.groupService == nil { return nil, apperrors.ErrGroupCallNotSupported } // 验证调用者是群组成员 member, err := s.groupService.GetMember(groupID, callerID) if err != nil { return nil, apperrors.ErrNotGroupMember } if member == nil { return nil, apperrors.ErrNotGroupMember } // 检查会话是否有活跃通话 if active := s.getActiveCallByConversation(conversationID); active != nil { return nil, apperrors.Wrap(fmt.Errorf("call %s", active.ID), apperrors.ErrCallInProgress) } // 获取群组成员 members, _, err := s.groupService.GetMembers(groupID, 1, 50) if err != nil { return nil, apperrors.Wrap(err, apperrors.ErrInternal) } now := time.Now() callID := s.generateCallID() // 构建参与者列表 participants := map[string]*ActiveParticipant{ callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now}, } for _, m := range members { if m.UserID != callerID { participants[m.UserID] = &ActiveParticipant{ UserID: m.UserID, Status: model.ParticipantStatusInvited, } } } call := &ActiveCall{ ID: callID, ConversationID: conversationID, CallerID: callerID, GroupID: groupID, CallType: model.CallTypeGroup, Status: model.CallStatusCalling, MediaType: mediaType, CreatedAt: now, Participants: participants, } s.storeActiveCall(call) s.redisStoreCall(call) // 通知所有在线成员 payload := map[string]any{ "call_id": call.ID, "conversation_id": conversationID, "caller_id": callerID, "call_type": "group", "media_type": mediaType, "group_id": groupID, "created_at": now.UnixMilli(), "lifetime": CallLifetimeMs, } for pID := range participants { if pID != callerID { s.hub.PublishToUserOnline(pID, "call_incoming", payload) } } return call, nil } // ParticipantJoin 参与者加入通话 func (s *callService) ParticipantJoin(ctx context.Context, callID, userID string) error { call := s.getActiveCall(callID) if call == nil { return apperrors.ErrCallNotFound } s.mu.Lock() p, ok := call.Participants[userID] if !ok { s.mu.Unlock() return apperrors.ErrNotCallParticipant } now := time.Now() p.Status = model.ParticipantStatusJoined p.JoinedAt = &now // 如果是第一个被叫方加入,标记通话已接通 if call.Status == model.CallStatusCalling { call.Status = model.CallStatusConnected call.StartedAt = &now } s.mu.Unlock() s.redisStoreCall(call) // 通知其他参与者 for pID := range call.Participants { if pID != userID { s.hub.PublishToUserOnline(pID, "call_participant_joined", map[string]any{ "call_id": callID, "user_id": userID, }) } } return nil } // ParticipantLeave 参与者离开通话 func (s *callService) ParticipantLeave(ctx context.Context, callID, userID string, reason string) error { call := s.getActiveCall(callID) if call == nil { return apperrors.ErrCallNotFound } s.mu.Lock() p, ok := call.Participants[userID] if !ok { s.mu.Unlock() return apperrors.ErrNotCallParticipant } now := time.Now() p.Status = model.ParticipantStatusLeft p.LeftAt = &now // 计算剩余活跃参与者数量 activeCount := 0 for _, pp := range call.Participants { if pp.Status == model.ParticipantStatusJoined { activeCount++ } } shouldEnd := false if call.CallType == model.CallTypeGroup && activeCount <= 1 { shouldEnd = true } else if call.CallType == model.CallTypePrivate { shouldEnd = true } s.mu.Unlock() // 通知其他参与者 for pID := range call.Participants { if pID != userID { s.hub.PublishToUserOnline(pID, "call_participant_left", map[string]any{ "call_id": callID, "user_id": userID, "reason": reason, }) } } if shouldEnd { _, _ = s.End(ctx, callID, userID, reason) } return nil } // Ready 标记参与者已加入 LiveKit 房间 func (s *callService) Ready(ctx context.Context, callID, userID string) error { call := s.getActiveCall(callID) if call == nil { return apperrors.ErrCallNotFound } s.mu.RLock() 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_ready", map[string]any{ "call_id": callID, "user_id": userID, }) } } 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) } // saveCallHistory 保存通话历史到数据库 func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64, endedBy string) { ctx := context.Background() now := time.Now() var endedByPtr *string if endedBy != "" { endedByPtr = &endedBy } dbCall := &model.CallSession{ ID: call.ID, ConversationID: call.ConversationID, CallerID: call.CallerID, CallType: call.CallType, MediaType: call.MediaType, Status: status, EndedBy: endedByPtr, 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), ) } } }