package service import ( "context" "encoding/json" "errors" "fmt" "time" "carrot_bbs/internal/config" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/ws" "carrot_bbs/internal/repository" ) var ( ErrCallNotFound = errors.New("call not found") ErrCallNotActive = errors.New("call is not active") ErrCallInProgress = errors.New("call already in progress between users") ErrNotParticipant = errors.New("user is not a participant of this call") ErrInvalidCallState = errors.New("invalid call state for this operation") ErrCalleeOffline = errors.New("callee is offline") ErrCallAlreadyAnswered = errors.New("call already answered on another device") ) // 通话相关常量 const ( CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix) CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒 ) // CallService 通话服务接口 type CallService interface { Invite(ctx context.Context, callerID, calleeID, conversationID string) (*model.CallSession, error) Accept(ctx context.Context, callID, userID string) (*model.CallSession, 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) (*model.CallSession, 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 } type callService struct { callRepo repository.CallRepository hub *ws.Hub config *config.Config } // NewCallService 创建通话服务 func NewCallService( callRepo repository.CallRepository, hub *ws.Hub, cfg *config.Config, ) CallService { return &callService{ callRepo: callRepo, hub: hub, config: cfg, } } func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversationID string) (*model.CallSession, error) { // === Telegram: 检查被叫方是否在线 === if !s.hub.HasClients(calleeID) { return nil, ErrCalleeOffline } active, err := s.callRepo.GetActiveCallByConversationID(conversationID) if err == nil && active != nil { return nil, fmt.Errorf("%w: call %s", ErrCallInProgress, active.ID) } now := time.Now() call := &model.CallSession{ ConversationID: conversationID, CallerID: callerID, CallType: model.CallTypePrivate, Status: model.CallStatusCalling, CreatedAt: now, UpdatedAt: now, } participants := []*model.CallParticipant{ {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now, CreatedAt: now, UpdatedAt: now}, {UserID: calleeID, Status: model.ParticipantStatusInvited, CreatedAt: now, UpdatedAt: now}, } if err := s.callRepo.CreateCallWithParticipants(ctx, call, participants); err != nil { return nil, fmt.Errorf("create call session: %w", err) } call, err = s.callRepo.GetCallByIDWithParticipants(call.ID) if err != nil { return nil, fmt.Errorf("get call with participants: %w", err) } // === Element: 包含 lifetime 字段 === payload := map[string]interface{}{ "call_id": call.ID, "conversation_id": conversationID, "caller_id": callerID, "call_type": call.CallType, "created_at": now.UnixMilli(), "lifetime": CallLifetimeMs, // 新增: 有效期 60秒 "ice_servers": s.config.WebRTC.ICEServers, } // === Telegram: 不存 history === online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload) if !online { // 极端情况: 检查时在线但发送时离线了,清理通话记录 call.Status = model.CallStatusCancelled call.EndedAt = &now s.callRepo.UpdateCall(call) return nil, ErrCalleeOffline } return call, nil } func (s *callService) Accept(ctx context.Context, callID, userID string) (*model.CallSession, error) { call, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return nil, fmt.Errorf("%w: %v", ErrCallNotFound, err) } // === 状态原子检查: 确保单设备接听 === if call.Status != model.CallStatusCalling { return nil, ErrCallAlreadyAnswered } if !isParticipant(call, userID) { return nil, ErrNotParticipant } now := time.Now() call.Status = model.CallStatusConnected call.StartedAt = &now call.UpdatedAt = now if err := s.callRepo.UpdateCall(call); err != nil { return nil, fmt.Errorf("update call connected: %w", err) } participant, err := s.callRepo.GetCallParticipant(callID, userID) if err == nil { participant.Status = model.ParticipantStatusJoined participant.JoinedAt = &now s.callRepo.UpdateCallParticipant(participant) } // === Telegram: 通知拨打方已接听 === s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]interface{}{ "call_id": callID, "started_at": now.UnixMilli(), "ice_servers": s.config.WebRTC.ICEServers, }) // === Telegram: 通知被叫方其他设备 call_answered_elsewhere === s.hub.PublishToUserOnline(userID, "call_answered_elsewhere", map[string]interface{}{ "call_id": callID, "reason": "answered_on_another_device", }) return call, nil } func (s *callService) Reject(ctx context.Context, callID, userID string) error { call, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return fmt.Errorf("%w: %v", ErrCallNotFound, err) } if call.Status != model.CallStatusCalling { return ErrInvalidCallState } if !isParticipant(call, userID) { return ErrNotParticipant } now := time.Now() call.Status = model.CallStatusRejected call.EndedAt = &now call.UpdatedAt = now if err := s.callRepo.UpdateCall(call); err != nil { return fmt.Errorf("update call rejected: %w", err) } participant, err := s.callRepo.GetCallParticipant(callID, userID) if err == nil { participant.Status = model.ParticipantStatusRejected participant.LeftAt = &now s.callRepo.UpdateCallParticipant(participant) } s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]interface{}{ "call_id": callID, "reason": "rejected", }) return nil } func (s *callService) Busy(ctx context.Context, callID, userID string) error { call, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return fmt.Errorf("%w: %v", ErrCallNotFound, err) } if call.Status != model.CallStatusCalling { return ErrInvalidCallState } if !isParticipant(call, userID) { return ErrNotParticipant } now := time.Now() call.Status = model.CallStatusMissed call.EndedAt = &now call.UpdatedAt = now if err := s.callRepo.UpdateCall(call); err != nil { return fmt.Errorf("update call missed: %w", err) } s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]interface{}{ "call_id": callID, }) return nil } func (s *callService) End(ctx context.Context, callID, userID string, reason string) (*model.CallSession, error) { call, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return nil, fmt.Errorf("%w: %v", ErrCallNotFound, err) } if !call.IsActive() { return nil, ErrCallNotActive } if !isParticipant(call, userID) { return nil, ErrNotParticipant } 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 { endStatus = model.CallStatusCancelled } call.Status = endStatus call.EndedAt = &now call.Duration = duration call.UpdatedAt = now if err := s.callRepo.UpdateCall(call); err != nil { return nil, fmt.Errorf("end call: %w", err) } participant, err := s.callRepo.GetCallParticipant(callID, userID) if err == nil { participant.Status = model.ParticipantStatusLeft participant.LeftAt = &now s.callRepo.UpdateCallParticipant(participant) } participants, _ := s.callRepo.GetCallParticipants(callID) for _, p := range participants { if p.UserID != userID { s.hub.PublishToUserOnline(p.UserID, "call_ended", map[string]interface{}{ "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, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return fmt.Errorf("%w: %v", ErrCallNotFound, err) } if !call.IsActive() { return ErrCallNotActive } if !isParticipant(call, fromUserID) { return ErrNotParticipant } participants, _ := s.callRepo.GetCallParticipants(callID) for _, p := range participants { if p.UserID != fromUserID { s.hub.PublishToUserOnline(p.UserID, signalType, map[string]interface{}{ "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, err := s.callRepo.GetCallByIDWithParticipants(callID) if err != nil { return fmt.Errorf("%w: %v", ErrCallNotFound, err) } if !call.IsActive() { return ErrCallNotActive } if !isParticipant(call, userID) { return ErrNotParticipant } participants, _ := s.callRepo.GetCallParticipants(callID) for _, p := range participants { if p.UserID != userID { s.hub.PublishToUserOnline(p.UserID, "call_peer_muted", map[string]interface{}{ "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 } // isParticipant 检查用户是否是通话参与者 func isParticipant(call *model.CallSession, userID string) bool { for _, p := range call.Participants { if p.UserID == userID { return true } } return false }