feat(call): integrate LiveKit for voice and video calling
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:
@@ -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) {
|
||||
|
||||
191
internal/service/livekit_service.go
Normal file
191
internal/service/livekit_service.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"with_you/internal/config"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
)
|
||||
|
||||
// LiveKitService LiveKit 令牌生成和 Webhook 处理服务
|
||||
type LiveKitService interface {
|
||||
GenerateToken(roomName, userID string, canPublish, canSubscribe bool) (string, error)
|
||||
ProcessWebhook(ctx context.Context, body []byte, authHeader string) (*livekit.WebhookEvent, error)
|
||||
}
|
||||
|
||||
type liveKitService struct {
|
||||
config *config.LiveKitConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLiveKitService 创建 LiveKit 服务
|
||||
func NewLiveKitService(cfg *config.Config, logger *zap.Logger) LiveKitService {
|
||||
return &liveKitService{
|
||||
config: &cfg.LiveKit,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken 为指定 room 和 user 生成 LiveKit 访问令牌
|
||||
func (s *liveKitService) GenerateToken(roomName, userID string, canPublish, canSubscribe bool) (string, error) {
|
||||
if !s.config.Enabled {
|
||||
return "", fmt.Errorf("livekit is not enabled")
|
||||
}
|
||||
|
||||
ttl := s.config.TokenTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 600 // 默认 10 分钟
|
||||
}
|
||||
|
||||
at := auth.NewAccessToken(s.config.APIKey, s.config.APISecret)
|
||||
at.SetName(userID)
|
||||
at.SetIdentity(userID)
|
||||
at.SetValidFor(time.Duration(ttl) * time.Second)
|
||||
|
||||
grant := &auth.VideoGrant{
|
||||
RoomJoin: true,
|
||||
Room: roomName,
|
||||
CanPublish: &canPublish,
|
||||
CanSubscribe: &canSubscribe,
|
||||
CanPublishData: &canSubscribe,
|
||||
}
|
||||
at.SetVideoGrant(grant)
|
||||
|
||||
token, err := at.ToJWT()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ProcessWebhook 验证并解析 LiveKit Webhook 事件
|
||||
func (s *liveKitService) ProcessWebhook(ctx context.Context, body []byte, authHeader string) (*livekit.WebhookEvent, error) {
|
||||
if s.config.WebhookSecret != "" {
|
||||
if err := s.verifyWebhookSignature(body, authHeader); err != nil {
|
||||
return nil, fmt.Errorf("webhook signature verification failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var event livekit.WebhookEvent
|
||||
if err := protojson.Unmarshal(body, &event); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal webhook event: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("received livekit webhook",
|
||||
zap.String("event", event.Event),
|
||||
zap.String("room_name", event.Room.GetName()),
|
||||
)
|
||||
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// verifyWebhookSignature 验证 Webhook 签名
|
||||
func (s *liveKitService) verifyWebhookSignature(body []byte, authHeader string) error {
|
||||
// LiveKit webhook 使用 SHA256 HMAC 签名
|
||||
// Authorization header 格式: "sha256=<base64 encoded signature>"
|
||||
if authHeader == "" {
|
||||
return fmt.Errorf("missing authorization header")
|
||||
}
|
||||
|
||||
expectedPrefix := "sha256="
|
||||
if len(authHeader) < len(expectedPrefix) || authHeader[:len(expectedPrefix)] != expectedPrefix {
|
||||
return fmt.Errorf("invalid authorization header format")
|
||||
}
|
||||
|
||||
expectedSig, err := base64.StdEncoding.DecodeString(authHeader[len(expectedPrefix):])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode signature: %w", err)
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(s.config.WebhookSecret))
|
||||
mac.Write(body)
|
||||
actualSig := mac.Sum(nil)
|
||||
|
||||
if !hmac.Equal(actualSig, expectedSig) {
|
||||
return fmt.Errorf("signature mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebhookRoomFinished 处理 room_finished 事件的负载
|
||||
type WebhookRoomFinished struct {
|
||||
RoomName string `json:"room_name"`
|
||||
Duration int64 `json:"duration"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
}
|
||||
|
||||
// ParseRoomFinished 从 webhook event 中解析 room_finished 数据
|
||||
func ParseRoomFinished(event *livekit.WebhookEvent) (*WebhookRoomFinished, error) {
|
||||
if event.Event != "room_finished" {
|
||||
return nil, fmt.Errorf("not a room_finished event: %s", event.Event)
|
||||
}
|
||||
|
||||
room := event.Room
|
||||
if room == nil {
|
||||
return nil, fmt.Errorf("room is nil in webhook event")
|
||||
}
|
||||
|
||||
duration := int64(0)
|
||||
startedAt := int64(0)
|
||||
endedAt := int64(0)
|
||||
|
||||
if room.CreationTime > 0 {
|
||||
startedAt = room.CreationTime
|
||||
}
|
||||
|
||||
endedAt = time.Now().Unix()
|
||||
if startedAt > 0 {
|
||||
duration = endedAt - startedAt
|
||||
}
|
||||
|
||||
return &WebhookRoomFinished{
|
||||
RoomName: room.Name,
|
||||
Duration: duration,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: endedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WebhookEventJSON 用于 JSON 序列化的 webhook 事件
|
||||
type WebhookEventJSON struct {
|
||||
Event string `json:"event"`
|
||||
Room *WebhookRoomJSON `json:"room,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookRoomJSON webhook 房间信息 JSON
|
||||
type WebhookRoomJSON struct {
|
||||
Sid string `json:"sid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreationTime int64 `json:"creation_time,omitempty"`
|
||||
NumParticipants uint32 `json:"num_participants,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookEventToJSON 将 webhook event 转为自定义 JSON 结构(避免 protobuf 内部字段)
|
||||
func WebhookEventToJSON(event *livekit.WebhookEvent) (*WebhookEventJSON, error) {
|
||||
data, err := protojson.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result WebhookEventJSON
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user