feat(call): integrate call handling and WebSocket support
- Added CallHandler and related services for managing call sessions and participants. - Enhanced WSHandler to support call signaling, including invite, answer, reject, and end functionalities. - Updated router to include call-related routes for history and ICE server retrieval. - Introduced WebRTC configuration in the application settings for call management. - Refactored wire generation to include CallService and CallRepository for improved dependency injection.
This commit is contained in:
319
internal/service/call_service.go
Normal file
319
internal/service/call_service.go
Normal file
@@ -0,0 +1,319 @@
|
||||
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")
|
||||
)
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"call_id": call.ID,
|
||||
"conversation_id": conversationID,
|
||||
"caller_id": callerID,
|
||||
"call_type": call.CallType,
|
||||
"created_at": now.UnixMilli(),
|
||||
"ice_servers": s.config.WebRTC.ICEServers,
|
||||
}
|
||||
s.hub.PublishToUser(calleeID, "call_incoming", payload)
|
||||
|
||||
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.IsActive() {
|
||||
return nil, ErrCallNotActive
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
s.hub.PublishToUser(call.CallerID, "call_accepted", map[string]interface{}{
|
||||
"call_id": callID,
|
||||
"started_at": now.UnixMilli(),
|
||||
"ice_servers": s.config.WebRTC.ICEServers,
|
||||
})
|
||||
|
||||
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.PublishToUser(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.PublishToUser(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.PublishToUser(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.PublishToUser(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.PublishToUser(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
|
||||
}
|
||||
Reference in New Issue
Block a user