Files
backend/internal/service/call_service.go
lan 60654bc0f4
All checks were successful
Build Backend / build (push) Successful in 2m14s
Build Backend / build-docker (push) Successful in 1m15s
feat(call): implement group calling and enhance call management
Introduces group calling capabilities, including group invites and participant
lifecycle management. Enhances the existing call system with support for
media types (voice/video) and improved participant tracking.

- Add `GroupInvite` and `ParticipantJoin/Leave` to `CallService`.
- Implement WebSocket handlers for `call_group_invite` and
  `call_participant_join`.
- Update `CallSession` model to support group IDs, media types, and
  tracking who ended the call.
- Integrate `GroupService` into `CallService` via dependency injection.
- Add error constants for group call limitations and participant capacity.
- Update LiveKit webhook handling to process participant leave events.

Refactor grade synchronization to remove semester-based filtering in favor
of a more flexible user-based approach and improve GPA summary updates.
2026-06-07 00:11:47 +08:00

1014 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
)
}
}
}