Files
backend/internal/service/call_service.go

418 lines
12 KiB
Go
Raw Normal View History

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"
"gorm.io/gorm"
)
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
StartCleanupTicker() // 新增:启动超时清理
}
type callService struct {
callRepo repository.CallRepository
hub *ws.Hub
config *config.Config
db *gorm.DB
}
// NewCallService 创建通话服务
func NewCallService(
callRepo repository.CallRepository,
hub *ws.Hub,
cfg *config.Config,
db *gorm.DB,
) CallService {
svc := &callService{
callRepo: callRepo,
hub: hub,
config: cfg,
db: db,
}
// 自动启动超时清理
svc.StartCleanupTicker()
return svc
}
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,
"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 !isParticipant(call, userID) {
return nil, ErrNotParticipant
}
// === 修改:使用乐观锁更新状态 ===
// 只有 calling 状态的通话才能被接听
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, ErrCallAlreadyAnswered
}
// 更新参与者状态
participant, err := s.callRepo.GetCallParticipant(callID, userID)
if err == nil {
participant.Status = model.ParticipantStatusJoined
participant.JoinedAt = &now
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)
}
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]interface{}{
"call_id": callID,
"started_at": now.UnixMilli(),
"ice_servers": s.config.WebRTC.ICEServers,
})
// 通知被叫方其他设备
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
participant.UpdatedAt = 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
}
// 允许系统结束通话时 userID 为空
if userID != "" && !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 {
if reason == "timeout" {
endStatus = model.CallStatusMissed
} else {
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
participant.UpdatedAt = 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 {
// 使用可靠发送来转发 SDP 和 ICE 消息
s.hub.PublishToUserOnlineReliable(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
}
// 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()
// 查找超过 CallTimeoutMs 的 calling 状态通话
expiredAt := time.Now().Add(-time.Duration(CallTimeoutMs) * time.Millisecond)
var calls []model.CallSession
if err := s.db.
Where("status = ? AND created_at < ?", model.CallStatusCalling, expiredAt).
Find(&calls).Error; err != nil {
return
}
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
}