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:
@@ -49,12 +49,3 @@ func (h *CallHandler) GetCallHistory(c *gin.Context) {
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// GetICEServers 获取 ICE 服务器配置
|
||||
// GET /api/v1/calls/ice-servers
|
||||
func (h *CallHandler) GetICEServers(c *gin.Context) {
|
||||
servers := h.callService.GetICEServers()
|
||||
response.Success(c, gin.H{
|
||||
"ice_servers": servers,
|
||||
})
|
||||
}
|
||||
|
||||
155
internal/handler/livekit_handler.go
Normal file
155
internal/handler/livekit_handler.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// LiveKitHandler LiveKit 令牌和 Webhook 处理器
|
||||
type LiveKitHandler struct {
|
||||
liveKitService service.LiveKitService
|
||||
callService service.CallService
|
||||
config *config.LiveKitConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLiveKitHandler 创建 LiveKit 处理器
|
||||
func NewLiveKitHandler(
|
||||
liveKitService service.LiveKitService,
|
||||
callService service.CallService,
|
||||
cfg *config.Config,
|
||||
logger *zap.Logger,
|
||||
) *LiveKitHandler {
|
||||
return &LiveKitHandler{
|
||||
liveKitService: liveKitService,
|
||||
callService: callService,
|
||||
config: &cfg.LiveKit,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken 生成 LiveKit 访问令牌
|
||||
// GET /api/v1/calls/token?room=<callID>
|
||||
func (h *LiveKitHandler) GetToken(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
response.Forbidden(c, "livekit is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
room := c.Query("room")
|
||||
if room == "" {
|
||||
response.BadRequest(c, "room parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户是该通话的参与者
|
||||
activeCall := h.callService.GetActiveCall(c.Request.Context(), room)
|
||||
if activeCall == nil {
|
||||
response.NotFound(c, "call not found or already ended")
|
||||
return
|
||||
}
|
||||
|
||||
isParticipant := false
|
||||
for _, p := range activeCall.Participants {
|
||||
if p.UserID == userID.(string) {
|
||||
isParticipant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isParticipant && activeCall.CallerID != userID.(string) && activeCall.CalleeID != userID.(string) {
|
||||
response.Forbidden(c, "not a participant of this call")
|
||||
return
|
||||
}
|
||||
|
||||
canPublish := true
|
||||
canSubscribe := true
|
||||
if cp := c.Query("can_publish"); cp != "" {
|
||||
canPublish, _ = strconv.ParseBool(cp)
|
||||
}
|
||||
if cs := c.Query("can_subscribe"); cs != "" {
|
||||
canSubscribe, _ = strconv.ParseBool(cs)
|
||||
}
|
||||
|
||||
token, err := h.liveKitService.GenerateToken(room, userID.(string), canPublish, canSubscribe)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||
response.InternalServerError(c, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
"url": h.config.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWebhook 处理 LiveKit Webhook 回调
|
||||
// POST /api/v1/calls/webhook
|
||||
func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "livekit is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read webhook body", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
event, err := h.liveKitService.ProcessWebhook(c.Request.Context(), body, authHeader)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to process webhook", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook"})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 webhook 事件类型处理
|
||||
switch event.Event {
|
||||
case "room_finished":
|
||||
h.handleRoomFinished(c.Request.Context(), event)
|
||||
case "participant_left":
|
||||
h.logger.Info("participant left room",
|
||||
zap.String("room", event.Room.GetName()),
|
||||
zap.String("identity", event.Participant.GetIdentity()),
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRoomFinished 处理 room_finished 事件,作为通话历史持久化的安全兜底
|
||||
func (h *LiveKitHandler) handleRoomFinished(ctx context.Context, event *livekit.WebhookEvent) {
|
||||
roomName := event.Room.GetName()
|
||||
if roomName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试结束通话(如果尚未结束)
|
||||
// callService.End 会检查调用是否仍然活跃,如果已经结束则返回 nil
|
||||
_, err := h.callService.End(ctx, roomName, "", "room_finished")
|
||||
if err != nil {
|
||||
h.logger.Debug("room_finished: call end returned error (may already be ended)",
|
||||
zap.String("room", roomName),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(256 * 1024) // 256KB (SDP/ICE candidates can be large)
|
||||
conn.SetReadLimit(256 * 1024) // 256KB
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
zap.L().Debug("WebSocket pong received",
|
||||
@@ -383,10 +383,8 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
||||
h.handleCallReject(ctx, client, msg.Payload)
|
||||
case "call_busy":
|
||||
h.handleCallBusy(ctx, client, msg.Payload)
|
||||
case "call_sdp":
|
||||
h.handleCallSDP(ctx, client, msg.Payload)
|
||||
case "call_ice":
|
||||
h.handleCallICE(ctx, client, msg.Payload)
|
||||
case "call_ready":
|
||||
h.handleCallReady(ctx, client, msg.Payload)
|
||||
case "call_end":
|
||||
h.handleCallEnd(ctx, client, msg.Payload)
|
||||
case "call_mute":
|
||||
@@ -717,43 +715,18 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallSDP 转发 SDP
|
||||
func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
// handleCallReady 处理通话就绪(LiveKit 房间连接成功)
|
||||
func (h *WSHandler) handleCallReady(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
CallID string `json:"call_id"`
|
||||
SDPType string `json:"sdp_type"`
|
||||
SDP json.RawMessage `json:"sdp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
||||
h.publisher.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
signalPayload, _ := json.Marshal(map[string]any{
|
||||
"sdp_type": req.SDPType,
|
||||
"sdp": json.RawMessage(req.SDP),
|
||||
})
|
||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
||||
h.publisher.SendError(client, "call_sdp_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallICE 转发 ICE candidate
|
||||
func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
CallID string `json:"call_id"`
|
||||
Candidate json.RawMessage `json:"candidate"`
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
signalPayload, _ := json.Marshal(map[string]any{
|
||||
"candidate": json.RawMessage(req.Candidate),
|
||||
})
|
||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
||||
h.publisher.SendError(client, "call_ice_failed", err.Error())
|
||||
if err := h.callService.Ready(ctx, req.CallID, client.UserID); err != nil {
|
||||
h.publisher.SendError(client, "call_ready_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user