feat(call): implement active call management and memory storage
All checks were successful
Build Backend / build (push) Successful in 17m44s
Build Backend / build-docker (push) Successful in 1m3s

- Introduced ActiveCall and ActiveParticipant types to manage call state in memory.
- Updated CallService interface to return ActiveCall instances for Invite, Accept, and End methods.
- Added methods for storing, retrieving, and removing active calls from memory, enhancing call handling efficiency.
- Implemented concurrency control with sync.RWMutex to ensure thread-safe access to active call data.
This commit is contained in:
lafay
2026-03-28 07:20:02 +08:00
parent 736344f123
commit 2205b8ad04

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"sync"
"time" "time"
"carrot_bbs/internal/config" "carrot_bbs/internal/config"
@@ -22,13 +24,38 @@ const (
CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒 CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒
) )
// 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 通话服务接口 // CallService 通话服务接口
type CallService interface { type CallService interface {
Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*model.CallSession, bool, error) Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error)
Accept(ctx context.Context, callID, userID string) (*model.CallSession, error) Accept(ctx context.Context, callID, userID string) (*ActiveCall, error)
Reject(ctx context.Context, callID, userID string) error Reject(ctx context.Context, callID, userID string) error
Busy(ctx context.Context, callID, userID string) error Busy(ctx context.Context, callID, userID string) error
End(ctx context.Context, callID, userID string, reason string) (*model.CallSession, 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 RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) error
SetMuted(ctx context.Context, callID, userID string, muted bool) error SetMuted(ctx context.Context, callID, userID string, muted bool) error
GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error) GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error)
@@ -41,6 +68,12 @@ type callService struct {
hub *ws.Hub hub *ws.Hub
config *config.Config config *config.Config
db *gorm.DB db *gorm.DB
// 内存中的活跃通话状态
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 创建通话服务 // NewCallService 创建通话服务
@@ -51,58 +84,150 @@ func NewCallService(
db *gorm.DB, db *gorm.DB,
) CallService { ) CallService {
svc := &callService{ svc := &callService{
callRepo: callRepo, callRepo: callRepo,
hub: hub, hub: hub,
config: cfg, config: cfg,
db: db, db: db,
activeCalls: make(map[string]*ActiveCall),
activeCallsByUser: make(map[string]map[string]bool),
activeCallsByConv: make(map[string]string),
} }
// 订阅 WebSocket 断开事件 // 订阅 WebSocket 断开事件
hub.OnDisconnect(svc.handleUserDisconnect) hub.OnDisconnect(svc.handleUserDisconnect)
// 订阅 WebSocket 连接事件
hub.OnConnect(svc.handleUserConnect)
// 自动启动超时清理 // 自动启动超时清理
svc.StartCleanupTicker() svc.StartCleanupTicker()
return svc return svc
} }
func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*model.CallSession, bool, error) { // generateCallID 生成通话ID
active, err := s.callRepo.GetActiveCallByConversationID(conversationID) func (s *callService) generateCallID() string {
if err == nil && active != nil { id := time.Now().UnixNano()
return strconv.FormatInt(id, 10)
}
// getActiveCall 从内存获取活跃通话
func (s *callService) getActiveCall(callID string) *ActiveCall {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeCalls[callID]
}
// 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) return nil, false, apperrors.Wrap(fmt.Errorf("call %s", active.ID), apperrors.ErrCallInProgress)
} }
now := time.Now() now := time.Now()
call := &model.CallSession{ callID := s.generateCallID()
call := &ActiveCall{
ID: callID,
ConversationID: conversationID, ConversationID: conversationID,
CallerID: callerID, CallerID: callerID,
CalleeID: calleeID,
CallType: model.CallTypePrivate, CallType: model.CallTypePrivate,
Status: model.CallStatusCalling, Status: model.CallStatusCalling,
MediaType: mediaType,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, Participants: map[string]*ActiveParticipant{
callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now},
calleeID: {UserID: calleeID, Status: model.ParticipantStatusInvited},
},
ICEServers: s.config.WebRTC.ICEServers,
} }
participants := []*model.CallParticipant{ // 存储到内存
{UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now, CreatedAt: now, UpdatedAt: now}, s.storeActiveCall(call)
{UserID: calleeID, Status: model.ParticipantStatusInvited, CreatedAt: now, UpdatedAt: now},
}
if err := s.callRepo.CreateCallWithParticipants(ctx, call, participants); err != nil {
return nil, false, fmt.Errorf("create call session: %w", err)
}
call, err = s.callRepo.GetCallByIDWithParticipants(call.ID)
if err != nil {
return nil, false, fmt.Errorf("get call with participants: %w", err)
}
// 检查被叫方是否在线 // 检查被叫方是否在线
calleeOnline := s.hub.HasClients(calleeID) calleeOnline := s.hub.HasClients(calleeID)
// === Element: 包含 lifetime 字段 === // 发送来电通知
payload := map[string]interface{}{ payload := map[string]interface{}{
"call_id": call.ID, "call_id": call.ID,
"conversation_id": conversationID, "conversation_id": conversationID,
@@ -114,35 +239,27 @@ func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversati
"ice_servers": s.config.WebRTC.ICEServers, "ice_servers": s.config.WebRTC.ICEServers,
} }
// 尝试向被叫方发送来电通知
// 如果对方在线,立即收到来电
// 如果对方不在线,依然返回成功,让呼叫方正常等待
// 后端超时清理机制会在超时后自动结束通话并通知呼叫方
online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload) online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload)
if online { if online {
zap.L().Debug("Call invite sent to online callee", zap.L().Debug("Call invite sent to online callee",
zap.String("call_id", call.ID), zap.String("call_id", call.ID),
zap.String("callee_id", calleeID), zap.String("callee_id", calleeID),
zap.Bool("online", true),
) )
} else { } else {
zap.L().Debug("Call invite created for offline callee, waiting for reconnect", zap.L().Debug("Call invite created for offline callee",
zap.String("call_id", call.ID), zap.String("call_id", call.ID),
zap.String("callee_id", calleeID), zap.String("callee_id", calleeID),
zap.Bool("online", false),
) )
} }
// 返回通话会话和被叫方在线状态
// 前端可以根据 calleeOnline 决定是否显示"等待对方上线"的提示
return call, calleeOnline, nil return call, calleeOnline, nil
} }
func (s *callService) Accept(ctx context.Context, callID, userID string) (*model.CallSession, error) { func (s *callService) Accept(ctx context.Context, callID, userID string) (*ActiveCall, error) {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return nil, apperrors.Wrap(err, apperrors.ErrCallNotFound) return nil, apperrors.ErrCallNotFound
} }
// 检查用户是否是参与者 // 检查用户是否是参与者
@@ -150,39 +267,23 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*model
return nil, apperrors.ErrNotCallParticipant return nil, apperrors.ErrNotCallParticipant
} }
// === 修改:使用乐观锁更新状态 === // 检查通话状态
// 只有 calling 状态的通话才能被接听 if call.Status != model.CallStatusCalling {
now := time.Now()
result := s.db.Model(&model.CallSession{}).
Where("id = ? AND status = ?", callID, model.CallStatusCalling).
Updates(map[string]interface{}{
"status": model.CallStatusConnected,
"started_at": now,
"updated_at": now,
})
if result.Error != nil {
return nil, fmt.Errorf("accept call: %w", result.Error)
}
if result.RowsAffected == 0 {
// 没有更新任何行,说明通话已被其他设备接听
return nil, apperrors.ErrCallAlreadyAnswered return nil, apperrors.ErrCallAlreadyAnswered
} }
// 更新参与者状态 // 使用原子操作更新状态
participant, err := s.callRepo.GetCallParticipant(callID, userID) s.mu.Lock()
if err == nil { if call.Status != model.CallStatusCalling {
participant.Status = model.ParticipantStatusJoined s.mu.Unlock()
participant.JoinedAt = &now return nil, apperrors.ErrCallAlreadyAnswered
participant.UpdatedAt = now
s.callRepo.UpdateCallParticipant(participant)
}
// 重新获取更新后的通话
call, err = s.callRepo.GetCallByIDWithParticipants(callID)
if err != nil {
return nil, fmt.Errorf("get updated call: %w", err)
} }
now := time.Now()
call.Status = model.CallStatusConnected
call.StartedAt = &now
call.Participants[userID].Status = model.ParticipantStatusJoined
call.Participants[userID].JoinedAt = &now
s.mu.Unlock()
// 通知拨打方 // 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]interface{}{ s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]interface{}{
@@ -201,78 +302,87 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*model
} }
func (s *callService) Reject(ctx context.Context, callID, userID string) error { func (s *callService) Reject(ctx context.Context, callID, userID string) error {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return apperrors.Wrap(err, apperrors.ErrCallNotFound) return apperrors.ErrCallNotFound
} }
s.mu.Lock()
if call.Status != model.CallStatusCalling { if call.Status != model.CallStatusCalling {
s.mu.Unlock()
return apperrors.ErrInvalidCallState return apperrors.ErrInvalidCallState
} }
if !isParticipant(call, userID) { if !isParticipant(call, userID) {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant return apperrors.ErrNotCallParticipant
} }
now := time.Now()
call.Status = model.CallStatusRejected call.Status = model.CallStatusRejected
call.EndedAt = &now call.Participants[userID].Status = model.ParticipantStatusRejected
call.UpdatedAt = now s.mu.Unlock()
if err := s.callRepo.UpdateCall(call); err != nil { // 从内存移除
return fmt.Errorf("update call rejected: %w", err) s.removeActiveCall(callID)
}
participant, err := s.callRepo.GetCallParticipant(callID, userID) // 保存到数据库作为历史记录
if err == nil { s.saveCallHistory(call, model.CallStatusRejected, 0)
participant.Status = model.ParticipantStatusRejected
participant.LeftAt = &now
participant.UpdatedAt = now
s.callRepo.UpdateCallParticipant(participant)
}
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]interface{}{ s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]interface{}{
"call_id": callID, "call_id": callID,
"reason": "rejected", "reason": "rejected",
}) })
return nil return nil
} }
func (s *callService) Busy(ctx context.Context, callID, userID string) error { func (s *callService) Busy(ctx context.Context, callID, userID string) error {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return apperrors.Wrap(err, apperrors.ErrCallNotFound) return apperrors.ErrCallNotFound
} }
s.mu.Lock()
if call.Status != model.CallStatusCalling { if call.Status != model.CallStatusCalling {
s.mu.Unlock()
return apperrors.ErrInvalidCallState return apperrors.ErrInvalidCallState
} }
if !isParticipant(call, userID) { if !isParticipant(call, userID) {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant return apperrors.ErrNotCallParticipant
} }
now := time.Now()
call.Status = model.CallStatusMissed call.Status = model.CallStatusMissed
call.EndedAt = &now s.mu.Unlock()
call.UpdatedAt = now
if err := s.callRepo.UpdateCall(call); err != nil { // 从内存移除
return fmt.Errorf("update call missed: %w", err) s.removeActiveCall(callID)
}
// 保存到数据库作为历史记录
s.saveCallHistory(call, model.CallStatusMissed, 0)
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]interface{}{ s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]interface{}{
"call_id": callID, "call_id": callID,
}) })
return nil return nil
} }
func (s *callService) End(ctx context.Context, callID, userID string, reason string) (*model.CallSession, error) { func (s *callService) End(ctx context.Context, callID, userID string, reason string) (*ActiveCall, error) {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return nil, apperrors.Wrap(err, apperrors.ErrCallNotFound) return nil, apperrors.ErrCallNotFound
} }
if !call.IsActive() {
s.mu.Lock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.Unlock()
return nil, apperrors.ErrCallNotActive return nil, apperrors.ErrCallNotActive
} }
// 允许系统结束通话时 userID 为空 // 允许系统结束通话时 userID 为空
if userID != "" && !isParticipant(call, userID) { if userID != "" && !isParticipant(call, userID) {
s.mu.Unlock()
return nil, apperrors.ErrNotCallParticipant return nil, apperrors.ErrNotCallParticipant
} }
@@ -284,34 +394,33 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
endStatus := model.CallStatusEnded endStatus := model.CallStatusEnded
if call.Status == model.CallStatusCalling { if call.Status == model.CallStatusCalling {
if reason == "timeout" { switch reason {
case "timeout":
endStatus = model.CallStatusMissed endStatus = model.CallStatusMissed
} else { case "disconnect":
endStatus = model.CallStatusMissed
default:
endStatus = model.CallStatusCancelled endStatus = model.CallStatusCancelled
} }
} }
call.Status = endStatus call.Status = endStatus
call.EndedAt = &now
call.Duration = duration call.Duration = duration
call.UpdatedAt = now if userID != "" {
call.Participants[userID].Status = model.ParticipantStatusLeft
if err := s.callRepo.UpdateCall(call); err != nil {
return nil, fmt.Errorf("end call: %w", err)
} }
s.mu.Unlock()
participant, err := s.callRepo.GetCallParticipant(callID, userID) // 从内存移除
if err == nil { s.removeActiveCall(callID)
participant.Status = model.ParticipantStatusLeft
participant.LeftAt = &now
participant.UpdatedAt = now
s.callRepo.UpdateCallParticipant(participant)
}
participants, _ := s.callRepo.GetCallParticipants(callID) // 保存到数据库作为历史记录
for _, p := range participants { s.saveCallHistory(call, endStatus, duration)
if p.UserID != userID {
s.hub.PublishToUserOnline(p.UserID, "call_ended", map[string]interface{}{ // 通知其他参与者
for pUserID := range call.Participants {
if pUserID != userID {
s.hub.PublishToUserOnline(pUserID, "call_ended", map[string]interface{}{
"call_id": callID, "call_id": callID,
"ended_by": userID, "ended_by": userID,
"reason": reason, "reason": reason,
@@ -325,22 +434,25 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
} }
func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) error { func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) error {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return apperrors.Wrap(err, apperrors.ErrCallNotFound) return apperrors.ErrCallNotFound
} }
if !call.IsActive() {
s.mu.RLock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.RUnlock()
return apperrors.ErrCallNotActive return apperrors.ErrCallNotActive
} }
if !isParticipant(call, fromUserID) { if !isParticipant(call, fromUserID) {
s.mu.RUnlock()
return apperrors.ErrNotCallParticipant return apperrors.ErrNotCallParticipant
} }
s.mu.RUnlock()
participants, _ := s.callRepo.GetCallParticipants(callID) for pUserID := range call.Participants {
for _, p := range participants { if pUserID != fromUserID {
if p.UserID != fromUserID { s.hub.PublishToUserOnlineReliable(pUserID, signalType, map[string]interface{}{
// 使用可靠发送来转发 SDP 和 ICE 消息
s.hub.PublishToUserOnlineReliable(p.UserID, signalType, map[string]interface{}{
"call_id": callID, "call_id": callID,
"from_id": fromUserID, "from_id": fromUserID,
"payload": json.RawMessage(payload), "payload": json.RawMessage(payload),
@@ -351,21 +463,25 @@ func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string
} }
func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted bool) error { func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted bool) error {
call, err := s.callRepo.GetCallByIDWithParticipants(callID) call := s.getActiveCall(callID)
if err != nil { if call == nil {
return apperrors.Wrap(err, apperrors.ErrCallNotFound) return apperrors.ErrCallNotFound
} }
if !call.IsActive() {
s.mu.RLock()
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
s.mu.RUnlock()
return apperrors.ErrCallNotActive return apperrors.ErrCallNotActive
} }
if !isParticipant(call, userID) { if !isParticipant(call, userID) {
s.mu.RUnlock()
return apperrors.ErrNotCallParticipant return apperrors.ErrNotCallParticipant
} }
s.mu.RUnlock()
participants, _ := s.callRepo.GetCallParticipants(callID) for pUserID := range call.Participants {
for _, p := range participants { if pUserID != userID {
if p.UserID != userID { s.hub.PublishToUserOnline(pUserID, "call_peer_muted", map[string]interface{}{
s.hub.PublishToUserOnline(p.UserID, "call_peer_muted", map[string]interface{}{
"call_id": callID, "call_id": callID,
"user_id": userID, "user_id": userID,
"muted": muted, "muted": muted,
@@ -383,6 +499,44 @@ func (s *callService) GetICEServers() []config.ICEServerConfig {
return s.config.WebRTC.ICEServers 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 启动超时清理定时器 // StartCleanupTicker 启动超时清理定时器
func (s *callService) StartCleanupTicker() { func (s *callService) StartCleanupTicker() {
go func() { go func() {
@@ -399,34 +553,28 @@ func (s *callService) StartCleanupTicker() {
func (s *callService) cleanupExpiredCalls() { func (s *callService) cleanupExpiredCalls() {
ctx := context.Background() ctx := context.Background()
// 查找超过 CallTimeoutMs 的 calling 状态通话 s.mu.RLock()
expiredAt := time.Now().Add(-time.Duration(CallTimeoutMs) * time.Millisecond) var expiredCalls []string
var calls []model.CallSession now := time.Now()
for _, call := range s.activeCalls {
if err := s.db. if call.Status == model.CallStatusCalling {
Where("status = ? AND created_at < ?", model.CallStatusCalling, expiredAt). elapsed := now.Sub(call.CreatedAt)
Find(&calls).Error; err != nil { if elapsed.Milliseconds() > CallTimeoutMs {
return expiredCalls = append(expiredCalls, call.ID)
} }
for _, call := range calls {
// 标记为 missed
_, _ = s.End(ctx, call.ID, "", "timeout")
}
}
// isParticipant 检查用户是否是通话参与者
func isParticipant(call *model.CallSession, userID string) bool {
for _, p := range call.Participants {
if p.UserID == userID {
return true
} }
} }
return false 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 断开连接事件 // handleUserDisconnect 处理用户 WebSocket 断开连接事件
// 当用户完全离线时remainingCount == 0结束该用户参与的所有活跃通话
func (s *callService) handleUserDisconnect(userID string, remainingCount int) { func (s *callService) handleUserDisconnect(userID string, remainingCount int) {
// 如果用户还有其他连接,不处理 // 如果用户还有其他连接,不处理
if remainingCount > 0 { if remainingCount > 0 {
@@ -436,15 +584,7 @@ func (s *callService) handleUserDisconnect(userID string, remainingCount int) {
ctx := context.Background() ctx := context.Background()
// 获取用户参与的所有活跃通话 // 获取用户参与的所有活跃通话
calls, err := s.callRepo.GetActiveCallsByUserID(userID) calls := s.getActiveCallsByUser(userID)
if err != nil {
zap.L().Error("Failed to get active calls for disconnected user",
zap.String("user_id", userID),
zap.Error(err),
)
return
}
if len(calls) == 0 { if len(calls) == 0 {
return return
} }
@@ -466,80 +606,3 @@ func (s *callService) handleUserDisconnect(userID string, remainingCount int) {
} }
} }
} }
// handleUserConnect 处理用户 WebSocket 连接事件
// 当用户从离线状态变为在线时,检查是否有待处理的通话邀请并推送
func (s *callService) handleUserConnect(userID string, isFirstConnection bool) {
// 只有当用户是从完全离线变为在线时才处理
if !isFirstConnection {
return
}
// 获取用户作为被叫方的所有活跃通话
calls, err := s.callRepo.GetActiveCallsByUserID(userID)
if err != nil {
zap.L().Error("Failed to get active calls for connected user",
zap.String("user_id", userID),
zap.Error(err),
)
return
}
if len(calls) == 0 {
return
}
zap.L().Info("User connected, sending pending call invites",
zap.String("user_id", userID),
zap.Int("pending_calls", len(calls)),
)
// 推送所有待处理的通话邀请
for _, call := range calls {
// 检查用户是否是被叫方status == invited
isCallee := false
for _, p := range call.Participants {
if p.UserID == userID && p.Status == model.ParticipantStatusInvited {
isCallee = true
break
}
}
if !isCallee {
continue
}
// 检查通话是否还在有效期内
elapsed := time.Since(call.CreatedAt)
if elapsed.Milliseconds() > CallLifetimeMs {
zap.L().Debug("Skipping expired call invite",
zap.String("call_id", call.ID),
zap.Duration("elapsed", elapsed),
)
continue
}
remainingLifetime := CallLifetimeMs - elapsed.Milliseconds()
// 推送来电通知
payload := map[string]interface{}{
"call_id": call.ID,
"conversation_id": call.ConversationID,
"caller_id": call.CallerID,
"call_type": call.CallType,
"media_type": "voice", // 默认语音通话
"created_at": call.CreatedAt.UnixMilli(),
"lifetime": remainingLifetime,
"ice_servers": s.config.WebRTC.ICEServers,
}
sent := s.hub.PublishToUserOnline(userID, "call_incoming", payload)
if sent {
zap.L().Debug("Pending call invite sent to newly connected user",
zap.String("call_id", call.ID),
zap.String("user_id", userID),
zap.Int64("remaining_lifetime_ms", remainingLifetime),
)
}
}
}