feat(call): integrate LiveKit for voice and video calling
All checks were successful
Build Backend / build (push) Successful in 3m47s
Build Backend / build-docker (push) Successful in 5m9s

Replace the manual WebRTC signaling implementation with LiveKit SFU. This includes:
- Adding LiveKit service, handler, and configuration.
- Updating Docker Compose to include LiveKit server, Redis, and PostgreSQL.
- Refactoring `CallService` and `WSHandler` to support LiveKit room readiness instead of raw SDP/ICE relaying.
- Adding new API endpoints for LiveKit token generation and webhooks.
- Removing deprecated WebRTC configuration and manual signaling DTOs.
This commit is contained in:
2026-06-01 13:41:02 +08:00
parent 9356cb6876
commit 14114db68d
22 changed files with 731 additions and 452 deletions

View File

@@ -59,8 +59,6 @@ type ActiveCall struct {
Duration int64 // 通话时长(秒)
// 参与者状态
Participants map[string]*ActiveParticipant
// ICE Servers
ICEServers []config.ICEServerConfig
}
// ActiveParticipant 内存中的活跃参与者
@@ -77,10 +75,10 @@ type CallService interface {
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)
RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) 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)
GetICEServers() []config.ICEServerConfig
StartCleanupTicker()
}
@@ -217,8 +215,7 @@ func (s *callService) redisGetCall(callID string) *ActiveCall {
CallType: model.CallType(data.CallType),
Status: model.CallStatus(data.Status),
MediaType: data.MediaType,
ICEServers: s.config.WebRTC.ICEServers,
Participants: map[string]*ActiveParticipant{
Participants: map[string]*ActiveParticipant{
data.CallerID: {UserID: data.CallerID, Status: model.ParticipantStatusJoined},
data.CalleeID: {UserID: data.CalleeID, Status: model.ParticipantStatusInvited},
},
@@ -390,8 +387,7 @@ func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversati
callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now},
calleeID: {UserID: calleeID, Status: model.ParticipantStatusInvited},
},
ICEServers: s.config.WebRTC.ICEServers,
}
}
// 存储到内存
s.storeActiveCall(call)
@@ -411,8 +407,7 @@ func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversati
"media_type": mediaType,
"created_at": now.UnixMilli(),
"lifetime": CallLifetimeMs,
"ice_servers": s.config.WebRTC.ICEServers,
}
}
online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload)
@@ -473,8 +468,7 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*Activ
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]any{
"call_id": callID,
"started_at": now.UnixMilli(),
"ice_servers": s.config.WebRTC.ICEServers,
})
})
// 通知被叫方其他设备
s.hub.PublishToUserOnline(userID, "call_answered_elsewhere", map[string]any{
@@ -626,35 +620,6 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
return call, nil
}
func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string, signalType string, payload json.RawMessage) 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, fromUserID) {
s.mu.RUnlock()
return apperrors.ErrNotCallParticipant
}
s.mu.RUnlock()
for pUserID := range call.Participants {
if pUserID != fromUserID {
s.hub.PublishToUserOnlineReliable(pUserID, signalType, map[string]any{
"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 := s.getActiveCall(callID)
if call == nil {
@@ -684,13 +649,41 @@ func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted
return nil
}
// GetActiveCall 获取活跃通话(公开方法,供 LiveKit handler 调用)
func (s *callService) GetActiveCall(ctx context.Context, callID string) *ActiveCall {
return s.getActiveCall(callID)
}
// 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)
}
func (s *callService) GetICEServers() []config.ICEServerConfig {
return s.config.WebRTC.ICEServers
}
// saveCallHistory 保存通话历史到数据库
func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64) {