2026-04-28 14:53:04 +08:00
|
|
|
|
package service
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
2026-03-28 07:20:02 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"sync"
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
redispkg "with_you/internal/pkg/redis"
|
2026-06-07 00:11:47 +08:00
|
|
|
|
"with_you/internal/pkg/utils"
|
2026-05-06 12:39:11 +08:00
|
|
|
|
|
2026-04-22 16:01:59 +08:00
|
|
|
|
"with_you/internal/config"
|
|
|
|
|
|
apperrors "with_you/internal/errors"
|
|
|
|
|
|
"with_you/internal/model"
|
|
|
|
|
|
"with_you/internal/pkg/ws"
|
|
|
|
|
|
"with_you/internal/repository"
|
2026-03-28 00:20:56 +08:00
|
|
|
|
|
2026-03-28 04:34:49 +08:00
|
|
|
|
"go.uber.org/zap"
|
2026-03-28 00:20:56 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-03-27 01:54:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-27 17:10:30 +08:00
|
|
|
|
// 通话相关常量
|
|
|
|
|
|
const (
|
|
|
|
|
|
CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix)
|
2026-03-28 00:20:56 +08:00
|
|
|
|
CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒
|
2026-05-06 12:39:11 +08:00
|
|
|
|
|
|
|
|
|
|
redisCallKeyPrefix = "call:"
|
|
|
|
|
|
redisCallByUserPrefix = "call_by_user:"
|
|
|
|
|
|
redisCallAcceptPrefix = "call:accept:"
|
|
|
|
|
|
redisCallTTL = 120 * time.Second
|
|
|
|
|
|
redisCallAcceptLockTTL = 10 * time.Second
|
2026-03-27 01:54:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// activeCallRedisData Redis 中存储的通话数据(精简字段)
|
|
|
|
|
|
type activeCallRedisData struct {
|
2026-06-07 00:11:47 +08:00
|
|
|
|
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"`
|
2026-05-06 12:39:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// ActiveCall 内存中的活跃通话
|
|
|
|
|
|
type ActiveCall struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
ConversationID string
|
|
|
|
|
|
CallerID string
|
2026-06-07 00:11:47 +08:00
|
|
|
|
CalleeID string // 1v1通话的被叫方ID(兼容)
|
|
|
|
|
|
GroupID string // 群组通话的群组ID
|
2026-03-28 07:20:02 +08:00
|
|
|
|
CallType model.CallType
|
|
|
|
|
|
Status model.CallStatus
|
|
|
|
|
|
MediaType string // voice 或 video
|
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
|
StartedAt *time.Time
|
2026-03-30 04:49:35 +08:00
|
|
|
|
Duration int64 // 通话时长(秒)
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 参与者状态
|
|
|
|
|
|
Participants map[string]*ActiveParticipant
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ActiveParticipant 内存中的活跃参与者
|
|
|
|
|
|
type ActiveParticipant struct {
|
|
|
|
|
|
UserID string
|
|
|
|
|
|
Status model.ParticipantStatus
|
|
|
|
|
|
JoinedAt *time.Time
|
2026-06-07 00:11:47 +08:00
|
|
|
|
LeftAt *time.Time
|
2026-03-28 07:20:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 01:54:34 +08:00
|
|
|
|
// CallService 通话服务接口
|
|
|
|
|
|
type CallService interface {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error)
|
2026-06-07 00:11:47 +08:00
|
|
|
|
GroupInvite(ctx context.Context, callerID, groupID, conversationID, mediaType string) (*ActiveCall, error)
|
2026-03-28 07:20:02 +08:00
|
|
|
|
Accept(ctx context.Context, callID, userID string) (*ActiveCall, error)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
Reject(ctx context.Context, callID, userID string) error
|
|
|
|
|
|
Busy(ctx context.Context, callID, userID string) error
|
2026-03-28 07:20:02 +08:00
|
|
|
|
End(ctx context.Context, callID, userID string, reason string) (*ActiveCall, error)
|
2026-06-07 00:11:47 +08:00
|
|
|
|
ParticipantJoin(ctx context.Context, callID, userID string) error
|
|
|
|
|
|
ParticipantLeave(ctx context.Context, callID, userID string, reason string) error
|
2026-03-27 01:54:34 +08:00
|
|
|
|
SetMuted(ctx context.Context, callID, userID string, muted bool) error
|
2026-06-01 13:41:02 +08:00
|
|
|
|
Ready(ctx context.Context, callID, userID string) error
|
|
|
|
|
|
GetActiveCall(ctx context.Context, callID string) *ActiveCall
|
2026-03-27 01:54:34 +08:00
|
|
|
|
GetCallHistory(ctx context.Context, userID string, page, pageSize int) ([]model.CallSession, int64, error)
|
2026-03-28 04:34:49 +08:00
|
|
|
|
StartCleanupTicker()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type callService struct {
|
2026-06-07 00:11:47 +08:00
|
|
|
|
callRepo repository.CallRepository
|
|
|
|
|
|
hub ws.MessagePublisher
|
|
|
|
|
|
config *config.Config
|
|
|
|
|
|
db *gorm.DB
|
|
|
|
|
|
redis *redispkg.Client
|
|
|
|
|
|
groupService GroupService
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
// 内存中的活跃通话状态
|
|
|
|
|
|
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
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewCallService 创建通话服务
|
|
|
|
|
|
func NewCallService(
|
|
|
|
|
|
callRepo repository.CallRepository,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
publisher ws.MessagePublisher,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
cfg *config.Config,
|
2026-03-28 00:20:56 +08:00
|
|
|
|
db *gorm.DB,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
redisClient *redispkg.Client,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
groupService GroupService,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
) CallService {
|
2026-03-28 00:20:56 +08:00
|
|
|
|
svc := &callService{
|
2026-03-28 07:20:02 +08:00
|
|
|
|
callRepo: callRepo,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
hub: publisher,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
config: cfg,
|
|
|
|
|
|
db: db,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
redis: redisClient,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
groupService: groupService,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
activeCalls: make(map[string]*ActiveCall),
|
|
|
|
|
|
activeCallsByUser: make(map[string]map[string]bool),
|
|
|
|
|
|
activeCallsByConv: make(map[string]string),
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 00:20:56 +08:00
|
|
|
|
|
2026-03-28 04:34:49 +08:00
|
|
|
|
// 订阅 WebSocket 断开事件
|
2026-05-06 12:39:11 +08:00
|
|
|
|
publisher.OnDisconnect(svc.handleUserDisconnect)
|
2026-03-28 04:34:49 +08:00
|
|
|
|
|
2026-03-28 00:20:56 +08:00
|
|
|
|
// 自动启动超时清理
|
|
|
|
|
|
svc.StartCleanupTicker()
|
|
|
|
|
|
|
|
|
|
|
|
return svc
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:11:47 +08:00
|
|
|
|
// generateCallID 生成通话ID(使用雪花算法,与数据库ID一致)
|
2026-03-28 07:20:02 +08:00
|
|
|
|
func (s *callService) generateCallID() string {
|
2026-06-07 00:11:47 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
return strconv.FormatInt(id, 10)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
2026-06-07 00:11:47 +08:00
|
|
|
|
|
|
|
|
|
|
participantIDs := make([]string, 0, len(call.Participants))
|
|
|
|
|
|
for id := range call.Participants {
|
|
|
|
|
|
participantIDs = append(participantIDs, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
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,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
GroupID: call.GroupID,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
CreatedAt: call.CreatedAt.UnixMilli(),
|
|
|
|
|
|
StartedAt: startedAt,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
ParticipantIDs: participantIDs,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-06-07 00:11:47 +08:00
|
|
|
|
// 为每个参与者建立索引
|
|
|
|
|
|
for userID := range call.Participants {
|
|
|
|
|
|
pipe.Set(ctx, redisCallByUserPrefix+userID, call.ID, redisCallTTL)
|
|
|
|
|
|
}
|
2026-05-06 12:39:11 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-06-07 00:11:47 +08:00
|
|
|
|
for userID := range call.Participants {
|
|
|
|
|
|
keys = append(keys, redisCallByUserPrefix+userID)
|
|
|
|
|
|
}
|
2026-05-06 12:39:11 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:11:47 +08:00
|
|
|
|
// 构建参与者列表
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
call := &ActiveCall{
|
|
|
|
|
|
ID: data.ID,
|
|
|
|
|
|
ConversationID: data.ConversationID,
|
|
|
|
|
|
CallerID: data.CallerID,
|
|
|
|
|
|
CalleeID: data.CalleeID,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
GroupID: data.GroupID,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
CallType: model.CallType(data.CallType),
|
|
|
|
|
|
Status: model.CallStatus(data.Status),
|
|
|
|
|
|
MediaType: data.MediaType,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
Participants: participants,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
call.CreatedAt = time.UnixMilli(data.CreatedAt)
|
|
|
|
|
|
if data.StartedAt > 0 {
|
|
|
|
|
|
t := time.UnixMilli(data.StartedAt)
|
|
|
|
|
|
call.StartedAt = &t
|
|
|
|
|
|
// 如果已经开始,被叫方也已加入
|
2026-06-07 00:11:47 +08:00
|
|
|
|
if data.CalleeID != "" {
|
|
|
|
|
|
if p, ok := call.Participants[data.CalleeID]; ok {
|
|
|
|
|
|
p.Status = model.ParticipantStatusJoined
|
|
|
|
|
|
p.JoinedAt = call.StartedAt
|
|
|
|
|
|
}
|
2026-05-06 12:39:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return call
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// redisRefreshTTL 刷新通话相关 Redis 键的 TTL
|
|
|
|
|
|
func (s *callService) redisRefreshTTL(call *ActiveCall) {
|
|
|
|
|
|
if s.redis == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
2026-06-07 00:11:47 +08:00
|
|
|
|
keys := []string{redisCallKeyPrefix + call.ID}
|
|
|
|
|
|
for userID := range call.Participants {
|
|
|
|
|
|
keys = append(keys, redisCallByUserPrefix+userID)
|
2026-05-06 12:39:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-03-28 07:20:02 +08:00
|
|
|
|
func (s *callService) getActiveCall(callID string) *ActiveCall {
|
|
|
|
|
|
s.mu.RLock()
|
2026-05-06 12:39:11 +08:00
|
|
|
|
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
|
2026-03-28 07:20:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return nil, false, apperrors.Wrap(fmt.Errorf("call %s", active.ID), apperrors.ErrCallInProgress)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
2026-03-28 07:20:02 +08:00
|
|
|
|
callID := s.generateCallID()
|
|
|
|
|
|
|
|
|
|
|
|
call := &ActiveCall{
|
|
|
|
|
|
ID: callID,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
|
CallerID: callerID,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
CalleeID: calleeID,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
CallType: model.CallTypePrivate,
|
|
|
|
|
|
Status: model.CallStatusCalling,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
MediaType: mediaType,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
CreatedAt: now,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
Participants: map[string]*ActiveParticipant{
|
|
|
|
|
|
callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now},
|
|
|
|
|
|
calleeID: {UserID: calleeID, Status: model.ParticipantStatusInvited},
|
|
|
|
|
|
},
|
2026-06-01 13:41:02 +08:00
|
|
|
|
}
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 存储到内存
|
|
|
|
|
|
s.storeActiveCall(call)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 写入 Redis
|
|
|
|
|
|
s.redisStoreCall(call)
|
|
|
|
|
|
|
2026-03-28 04:34:49 +08:00
|
|
|
|
// 检查被叫方是否在线
|
|
|
|
|
|
calleeOnline := s.hub.HasClients(calleeID)
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 发送来电通知
|
2026-03-30 04:49:35 +08:00
|
|
|
|
payload := map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": call.ID,
|
|
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
|
"caller_id": callerID,
|
|
|
|
|
|
"call_type": call.CallType,
|
2026-03-28 01:01:22 +08:00
|
|
|
|
"media_type": mediaType,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"created_at": now.UnixMilli(),
|
2026-03-28 00:20:56 +08:00
|
|
|
|
"lifetime": CallLifetimeMs,
|
2026-06-01 13:41:02 +08:00
|
|
|
|
}
|
2026-03-27 17:10:30 +08:00
|
|
|
|
|
|
|
|
|
|
online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 04:34:49 +08:00
|
|
|
|
if online {
|
|
|
|
|
|
zap.L().Debug("Call invite sent to online callee",
|
|
|
|
|
|
zap.String("call_id", call.ID),
|
|
|
|
|
|
zap.String("callee_id", calleeID),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
zap.L().Debug("Call invite created for offline callee",
|
2026-03-28 04:34:49 +08:00
|
|
|
|
zap.String("call_id", call.ID),
|
|
|
|
|
|
zap.String("callee_id", calleeID),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return call, calleeOnline, nil
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
func (s *callService) Accept(ctx context.Context, callID, userID string) (*ActiveCall, error) {
|
|
|
|
|
|
call := s.getActiveCall(callID)
|
|
|
|
|
|
if call == nil {
|
|
|
|
|
|
return nil, apperrors.ErrCallNotFound
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 00:20:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查用户是否是参与者
|
2026-03-27 01:54:34 +08:00
|
|
|
|
if !isParticipant(call, userID) {
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return nil, apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 检查通话状态
|
|
|
|
|
|
if call.Status != model.CallStatusCalling {
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return nil, apperrors.ErrCallAlreadyAnswered
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 分布式锁:防止跨实例重复 Accept
|
|
|
|
|
|
if !s.redisTryAcceptLock(callID) {
|
|
|
|
|
|
return nil, apperrors.ErrCallAlreadyAnswered
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 使用原子操作更新状态
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
|
if call.Status != model.CallStatusCalling {
|
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
return nil, apperrors.ErrCallAlreadyAnswered
|
2026-03-28 00:20:56 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
now := time.Now()
|
|
|
|
|
|
call.Status = model.CallStatusConnected
|
|
|
|
|
|
call.StartedAt = &now
|
|
|
|
|
|
call.Participants[userID].Status = model.ParticipantStatusJoined
|
|
|
|
|
|
call.Participants[userID].JoinedAt = &now
|
|
|
|
|
|
s.mu.Unlock()
|
2026-03-28 00:20:56 +08:00
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 更新 Redis
|
|
|
|
|
|
s.redisStoreCall(call)
|
|
|
|
|
|
s.redisRefreshTTL(call)
|
|
|
|
|
|
|
2026-03-28 00:20:56 +08:00
|
|
|
|
// 通知拨打方
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": callID,
|
2026-03-27 17:10:30 +08:00
|
|
|
|
"started_at": now.UnixMilli(),
|
2026-06-01 13:41:02 +08:00
|
|
|
|
})
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 00:20:56 +08:00
|
|
|
|
// 通知被叫方其他设备
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.hub.PublishToUserOnline(userID, "call_answered_elsewhere", map[string]any{
|
2026-03-27 17:10:30 +08:00
|
|
|
|
"call_id": callID,
|
|
|
|
|
|
"reason": "answered_on_another_device",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-27 01:54:34 +08:00
|
|
|
|
return call, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *callService) Reject(ctx context.Context, callID, userID string) error {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
call := s.getActiveCall(callID)
|
|
|
|
|
|
if call == nil {
|
|
|
|
|
|
return apperrors.ErrCallNotFound
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
if call.Status != model.CallStatusCalling {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrInvalidCallState
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !isParticipant(call, userID) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
call.Status = model.CallStatusRejected
|
2026-03-28 07:20:02 +08:00
|
|
|
|
call.Participants[userID].Status = model.ParticipantStatusRejected
|
|
|
|
|
|
s.mu.Unlock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 从内存移除
|
|
|
|
|
|
s.removeActiveCall(callID)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 从 Redis 移除
|
|
|
|
|
|
s.redisRemoveCall(call)
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 保存到数据库作为历史记录
|
2026-06-07 00:11:47 +08:00
|
|
|
|
s.saveCallHistory(call, model.CallStatusRejected, 0, userID)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 通知拨打方
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": callID,
|
|
|
|
|
|
"reason": "rejected",
|
|
|
|
|
|
})
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
2026-03-27 01:54:34 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *callService) Busy(ctx context.Context, callID, userID string) error {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
call := s.getActiveCall(callID)
|
|
|
|
|
|
if call == nil {
|
|
|
|
|
|
return apperrors.ErrCallNotFound
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
if call.Status != model.CallStatusCalling {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrInvalidCallState
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !isParticipant(call, userID) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
call.Status = model.CallStatusMissed
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 从内存移除
|
|
|
|
|
|
s.removeActiveCall(callID)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 从 Redis 移除
|
|
|
|
|
|
s.redisRemoveCall(call)
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 保存到数据库作为历史记录
|
2026-06-07 00:11:47 +08:00
|
|
|
|
s.saveCallHistory(call, model.CallStatusMissed, 0, "")
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
// 通知拨打方
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": callID,
|
|
|
|
|
|
})
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
2026-03-27 01:54:34 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
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
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
|
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
|
|
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return nil, apperrors.ErrCallNotActive
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 00:20:56 +08:00
|
|
|
|
// 允许系统结束通话时 userID 为空
|
|
|
|
|
|
if userID != "" && !isParticipant(call, userID) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return nil, apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
switch reason {
|
|
|
|
|
|
case "timeout":
|
2026-03-28 00:20:56 +08:00
|
|
|
|
endStatus = model.CallStatusMissed
|
2026-03-28 07:20:02 +08:00
|
|
|
|
case "disconnect":
|
|
|
|
|
|
endStatus = model.CallStatusMissed
|
|
|
|
|
|
default:
|
2026-03-28 00:20:56 +08:00
|
|
|
|
endStatus = model.CallStatusCancelled
|
|
|
|
|
|
}
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
call.Status = endStatus
|
|
|
|
|
|
call.Duration = duration
|
2026-03-28 07:20:02 +08:00
|
|
|
|
if userID != "" {
|
|
|
|
|
|
call.Participants[userID].Status = model.ParticipantStatusLeft
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.Unlock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 从内存移除
|
|
|
|
|
|
s.removeActiveCall(callID)
|
|
|
|
|
|
|
2026-05-06 12:39:11 +08:00
|
|
|
|
// 从 Redis 移除
|
|
|
|
|
|
s.redisRemoveCall(call)
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 保存到数据库作为历史记录
|
2026-06-07 00:11:47 +08:00
|
|
|
|
s.saveCallHistory(call, endStatus, duration, userID)
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// 通知其他参与者
|
|
|
|
|
|
for pUserID := range call.Participants {
|
|
|
|
|
|
if pUserID != userID {
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.hub.PublishToUserOnline(pUserID, "call_ended", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": callID,
|
|
|
|
|
|
"ended_by": userID,
|
|
|
|
|
|
"reason": reason,
|
|
|
|
|
|
"duration": duration,
|
|
|
|
|
|
"ended_at": now.UnixMilli(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return call, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 13:41:02 +08:00
|
|
|
|
func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted bool) error {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
call := s.getActiveCall(callID)
|
|
|
|
|
|
if call == nil {
|
|
|
|
|
|
return apperrors.ErrCallNotFound
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
|
if call.Status != model.CallStatusCalling && call.Status != model.CallStatusConnected {
|
|
|
|
|
|
s.mu.RUnlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrCallNotActive
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-06-01 13:41:02 +08:00
|
|
|
|
if !isParticipant(call, userID) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.RUnlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.RUnlock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
for pUserID := range call.Participants {
|
2026-06-01 13:41:02 +08:00
|
|
|
|
if pUserID != userID {
|
|
|
|
|
|
s.hub.PublishToUserOnline(pUserID, "call_peer_muted", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"call_id": callID,
|
2026-06-01 13:41:02 +08:00
|
|
|
|
"user_id": userID,
|
|
|
|
|
|
"muted": muted,
|
2026-03-27 01:54:34 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 13:41:02 +08:00
|
|
|
|
// GetActiveCall 获取活跃通话(公开方法,供 LiveKit handler 调用)
|
|
|
|
|
|
func (s *callService) GetActiveCall(ctx context.Context, callID string) *ActiveCall {
|
|
|
|
|
|
return s.getActiveCall(callID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:11:47 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 13:41:02 +08:00
|
|
|
|
// Ready 标记参与者已加入 LiveKit 房间
|
|
|
|
|
|
func (s *callService) Ready(ctx context.Context, callID, userID string) error {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
call := s.getActiveCall(callID)
|
|
|
|
|
|
if call == nil {
|
|
|
|
|
|
return apperrors.ErrCallNotFound
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
|
|
|
|
|
|
s.mu.RLock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
if !isParticipant(call, userID) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.RUnlock()
|
2026-03-28 07:03:21 +08:00
|
|
|
|
return apperrors.ErrNotCallParticipant
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.RUnlock()
|
2026-03-27 01:54:34 +08:00
|
|
|
|
|
2026-06-01 13:41:02 +08:00
|
|
|
|
// 通知其他参与者该用户已就绪
|
2026-03-28 07:20:02 +08:00
|
|
|
|
for pUserID := range call.Participants {
|
|
|
|
|
|
if pUserID != userID {
|
2026-06-01 13:41:02 +08:00
|
|
|
|
s.hub.PublishToUserOnline(pUserID, "call_ready", map[string]any{
|
2026-03-27 01:54:34 +08:00
|
|
|
|
"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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
// saveCallHistory 保存通话历史到数据库
|
2026-06-07 00:11:47 +08:00
|
|
|
|
func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64, endedBy string) {
|
2026-03-28 07:20:02 +08:00
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
2026-06-07 00:11:47 +08:00
|
|
|
|
var endedByPtr *string
|
|
|
|
|
|
if endedBy != "" {
|
|
|
|
|
|
endedByPtr = &endedBy
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
dbCall := &model.CallSession{
|
|
|
|
|
|
ID: call.ID,
|
|
|
|
|
|
ConversationID: call.ConversationID,
|
|
|
|
|
|
CallerID: call.CallerID,
|
|
|
|
|
|
CallType: call.CallType,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
MediaType: call.MediaType,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
Status: status,
|
2026-06-07 00:11:47 +08:00
|
|
|
|
EndedBy: endedByPtr,
|
2026-03-28 07:20:02 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 00:20:56 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-28 00:20:56 +08:00
|
|
|
|
}
|
2026-03-28 07:20:02 +08:00
|
|
|
|
s.mu.RUnlock()
|
2026-03-28 00:20:56 +08:00
|
|
|
|
|
2026-03-28 07:20:02 +08:00
|
|
|
|
for _, callID := range expiredCalls {
|
|
|
|
|
|
zap.L().Info("Cleaning up expired call",
|
|
|
|
|
|
zap.String("call_id", callID),
|
|
|
|
|
|
)
|
|
|
|
|
|
_, _ = s.End(ctx, callID, "", "timeout")
|
2026-03-27 01:54:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-28 04:34:49 +08:00
|
|
|
|
|
|
|
|
|
|
// handleUserDisconnect 处理用户 WebSocket 断开连接事件
|
|
|
|
|
|
func (s *callService) handleUserDisconnect(userID string, remainingCount int) {
|
|
|
|
|
|
// 如果用户还有其他连接,不处理
|
|
|
|
|
|
if remainingCount > 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
// 获取用户参与的所有活跃通话
|
2026-03-28 07:20:02 +08:00
|
|
|
|
calls := s.getActiveCallsByUser(userID)
|
2026-03-28 04:34:49 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|