Files
nl-im-service/internal/api/call_handler.go
2025-12-15 13:21:08 +08:00

437 lines
12 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 api
*
* 通话相关 API 处理器
* 功能:
* 1. 创建/加入/离开通话房间
* 2. 处理 WebRTC 信令offer/answer/ice
* 3. 生成 RTMP 推拉流地址(小程序)
*/
package api
import (
"fmt"
"xk-websocket-v2/internal/mediaserver"
"xk-websocket-v2/internal/utils"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
// ========== 请求/响应结构体 ==========
// CreateCallRoomRequest 创建通话房间请求
type CreateCallRoomRequest struct {
RoomID string `json:"room_id" binding:"required"`
CallType string `json:"call_type" binding:"required,oneof=audio video"` // audio/video
IsGroupCall bool `json:"is_group_call"`
}
// JoinCallRoomRequest 加入通话房间请求
type JoinCallRoomRequest struct {
RoomID string `json:"room_id" binding:"required"`
UserID string `json:"user_id" binding:"required"`
Platform string `json:"platform" binding:"required,oneof=h5 app miniprogram"` // h5/app/miniprogram
}
// LeaveCallRoomRequest 离开通话房间请求
type LeaveCallRoomRequest struct {
RoomID string `json:"room_id" binding:"required"`
UserID string `json:"user_id" binding:"required"`
}
// WebRTCOfferRequest WebRTC Offer 请求
type WebRTCOfferRequest struct {
RoomID string `json:"room_id" binding:"required"`
UserID string `json:"user_id" binding:"required"`
SDP string `json:"sdp" binding:"required"`
}
// WebRTCICERequest WebRTC ICE 候选请求
type WebRTCICERequest struct {
RoomID string `json:"room_id" binding:"required"`
UserID string `json:"user_id" binding:"required"`
Candidate string `json:"candidate" binding:"required"`
}
// JoinCallRoomResponse 加入通话房间响应
type JoinCallRoomResponse struct {
RoomID string `json:"room_id"`
Platform string `json:"platform"`
ICEServers []ICEServerConfig `json:"ice_servers,omitempty"` // H5/App 用
FlvPullURLs []PullURLInfo `json:"flv_pull_urls,omitempty"` // H5/App 拉取小程序流的 FLV 地址
PushURL string `json:"push_url,omitempty"` // 小程序用
PullURLs []PullURLInfo `json:"pull_urls,omitempty"` // 小程序用
Participants []ParticipantInfo `json:"participants"`
}
// ICEServerConfig ICE 服务器配置
type ICEServerConfig struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
// PullURLInfo 拉流地址信息
type PullURLInfo struct {
UserID string `json:"user_id"`
URL string `json:"url"`
FLVURL string `json:"flv_url,omitempty"`
}
// ParticipantInfo 参与者信息
type ParticipantInfo struct {
UserID string `json:"user_id"`
Platform string `json:"platform"`
HasAudio bool `json:"has_audio"`
HasVideo bool `json:"has_video"`
}
// RoomInfoResponse 房间信息响应
type RoomInfoResponse struct {
RoomID string `json:"room_id"`
CallType string `json:"call_type"`
IsGroupCall bool `json:"is_group_call"`
ParticipantCount int `json:"participant_count"`
Participants []ParticipantInfo `json:"participants"`
}
// ========== API 处理函数 ==========
// CreateCallRoomHandler 创建通话房间
// POST /api/call/room
func CreateCallRoomHandler(c *gin.Context) {
var req CreateCallRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误: "+err.Error())
return
}
ms := mediaserver.GetServer()
if ms.GetConfig() == nil || !ms.GetConfig().Enabled {
utils.Error(c, 503, "媒体服务未启用")
return
}
// 创建房间
room := ms.GetOrCreateRoom(req.RoomID)
room.SetCallType(req.CallType, req.IsGroupCall)
utils.SuccessWithData(c, gin.H{
"room_id": req.RoomID,
"call_type": req.CallType,
"is_group": req.IsGroupCall,
"created": true,
}, "创建成功")
}
// JoinCallRoomHandler 加入通话房间
// POST /api/call/join
func JoinCallRoomHandler(c *gin.Context) {
var req JoinCallRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误: "+err.Error())
return
}
ms := mediaserver.GetServer()
if ms.GetConfig() == nil || !ms.GetConfig().Enabled {
utils.Error(c, 503, "媒体服务未启用")
return
}
room := ms.GetRoom(req.RoomID)
if room == nil {
// 自动创建房间
room = ms.GetOrCreateRoom(req.RoomID)
}
response := JoinCallRoomResponse{
RoomID: req.RoomID,
Platform: req.Platform,
}
switch req.Platform {
case "h5", "app":
// H5/App 使用 WebRTC
pType := mediaserver.ParticipantTypeWebRTC
_, err := room.AddParticipant(req.UserID, pType)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
// 返回 ICE 服务器配置
response.ICEServers = getICEServers(req.UserID)
// 获取房间内小程序用户的 FLV 拉流地址(用于 Web 端播放小程序流)
flvPullURLs := make([]PullURLInfo, 0)
for _, p := range room.GetOtherParticipants(req.UserID) {
// 只返回小程序用户的 FLV 地址
if p.Type == mediaserver.ParticipantTypeRTMP && p.FLVURL != "" {
flvPullURLs = append(flvPullURLs, PullURLInfo{
UserID: p.UserID,
URL: p.PullURL, // RTMP 地址
FLVURL: p.FLVURL, // HTTP-FLV 地址
})
}
}
response.FlvPullURLs = flvPullURLs
case "miniprogram":
// 小程序使用 RTMP
pType := mediaserver.ParticipantTypeRTMP
participant, err := room.AddParticipant(req.UserID, pType)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
// 生成推拉流地址
rtmpServer := ms.GetRTMP()
if rtmpServer == nil {
utils.Error(c, 503, "RTMP服务未启用")
return
}
stream, err := rtmpServer.GenerateStreamURLs(req.RoomID, req.UserID)
if err != nil {
utils.InternalError(c, "生成推流地址失败")
return
}
// 更新参与者的流地址
room.SetParticipantRTMPURLs(req.UserID, stream.PushURL, stream.PullURL, stream.FLVURL, stream.ID)
participant.PushURL = stream.PushURL
participant.PullURL = stream.PullURL
participant.FLVURL = stream.FLVURL
participant.StreamID = stream.ID
response.PushURL = stream.PushURL
// 获取房间内其他用户的拉流地址
pullURLs := make([]PullURLInfo, 0)
for _, p := range room.GetOtherParticipants(req.UserID) {
if p.PullURL != "" {
pullURLs = append(pullURLs, PullURLInfo{
UserID: p.UserID,
URL: p.PullURL,
FLVURL: p.FLVURL,
})
}
}
response.PullURLs = pullURLs
}
// 获取参与者列表
participants := make([]ParticipantInfo, 0)
for _, p := range room.GetAllParticipants() {
platform := "h5"
if p.Type == mediaserver.ParticipantTypeRTMP {
platform = "miniprogram"
}
participants = append(participants, ParticipantInfo{
UserID: p.UserID,
Platform: platform,
HasAudio: p.HasAudio,
HasVideo: p.HasVideo,
})
}
response.Participants = participants
utils.SuccessWithData(c, response, "加入成功")
}
// LeaveCallRoomHandler 离开通话房间
// POST /api/call/leave
func LeaveCallRoomHandler(c *gin.Context) {
var req LeaveCallRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误: "+err.Error())
return
}
ms := mediaserver.GetServer()
room := ms.GetRoom(req.RoomID)
if room == nil {
utils.SuccessWithData(c, gin.H{"left": true}, "已离开")
return
}
// 获取参与者信息
participant := room.GetParticipant(req.UserID)
if participant != nil && participant.Type == mediaserver.ParticipantTypeRTMP {
// 清理 RTMP 流
rtmpServer := ms.GetRTMP()
if rtmpServer != nil && participant.StreamID != "" {
rtmpServer.RemoveStream(participant.StreamID)
}
}
// 移除参与者
room.RemoveParticipant(req.UserID)
// 如果房间空了,移除房间
if room.IsEmpty() {
ms.RemoveRoom(req.RoomID)
}
utils.SuccessWithData(c, gin.H{"left": true}, "已离开")
}
// GetCallRoomHandler 获取通话房间信息
// GET /api/call/room/:room_id
func GetCallRoomHandler(c *gin.Context) {
roomID := c.Param("room_id")
if roomID == "" {
utils.BadRequest(c, "房间ID不能为空")
return
}
ms := mediaserver.GetServer()
room := ms.GetRoom(roomID)
if room == nil {
utils.NotFound(c, "房间不存在")
return
}
info := room.GetInfo()
participants := make([]ParticipantInfo, 0)
for _, p := range info.Participants {
platform := "h5"
if p.Type == mediaserver.ParticipantTypeRTMP {
platform = "miniprogram"
}
participants = append(participants, ParticipantInfo{
UserID: p.UserID,
Platform: platform,
HasAudio: p.HasAudio,
HasVideo: p.HasVideo,
})
}
utils.SuccessWithData(c, RoomInfoResponse{
RoomID: info.ID,
CallType: info.CallType,
IsGroupCall: info.IsGroupCall,
ParticipantCount: info.ParticipantCount,
Participants: participants,
}, "获取成功")
}
// WebRTCOfferHandler 处理 WebRTC Offer
// POST /api/call/offer
func WebRTCOfferHandler(c *gin.Context) {
var req WebRTCOfferRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误: "+err.Error())
return
}
ms := mediaserver.GetServer()
sfu := ms.GetSFU()
if sfu == nil {
utils.Error(c, 503, "SFU服务未启用")
return
}
// 处理 Offer 并返回 Answer
answerSDP, err := sfu.HandleOffer(req.RoomID, req.UserID, req.SDP)
if err != nil {
utils.InternalError(c, "处理Offer失败: "+err.Error())
return
}
utils.SuccessWithData(c, gin.H{
"sdp": answerSDP,
}, "处理成功")
}
// WebRTCICEHandler 处理 WebRTC ICE 候选
// POST /api/call/ice
func WebRTCICEHandler(c *gin.Context) {
var req WebRTCICERequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误: "+err.Error())
return
}
ms := mediaserver.GetServer()
sfu := ms.GetSFU()
if sfu == nil {
utils.Error(c, 503, "SFU服务未启用")
return
}
// 添加 ICE 候选
if err := sfu.HandleICECandidate(req.RoomID, req.UserID, req.Candidate); err != nil {
utils.InternalError(c, "处理ICE失败: "+err.Error())
return
}
utils.SuccessWithData(c, gin.H{"added": true}, "添加成功")
}
// GetICEServersHandler 获取 ICE 服务器配置
// GET /api/call/ice-servers
func GetICEServersHandler(c *gin.Context) {
userID := c.Query("user_id")
if userID == "" {
userID = "anonymous"
}
servers := getICEServers(userID)
utils.SuccessWithData(c, gin.H{
"ice_servers": servers,
}, "获取成功")
}
// getICEServers 获取 ICE 服务器配置
func getICEServers(userID string) []ICEServerConfig {
servers := []ICEServerConfig{}
turnPublicIP := viper.GetString("turn.public_ip")
turnPort := viper.GetInt("turn.listen_port")
if turnPublicIP != "" && turnPort > 0 {
portStr := fmt.Sprintf("%d", turnPort)
// STUN 服务器
servers = append(servers, ICEServerConfig{
URLs: []string{
"stun:" + turnPublicIP + ":" + portStr,
},
})
// TURN 服务器(如果启用)
if viper.GetBool("turn.enabled") {
username, credential := mediaserver.GetTURNCredentials(userID)
servers = append(servers, ICEServerConfig{
URLs: []string{
"turn:" + turnPublicIP + ":" + portStr,
},
Username: username,
Credential: credential,
})
}
}
return servers
}
// RegisterCallRoutes 注册通话相关路由
func RegisterCallRoutes(router *gin.RouterGroup) {
callGroup := router.Group("/call")
{
callGroup.POST("/room", CreateCallRoomHandler)
callGroup.GET("/room/:room_id", GetCallRoomHandler)
callGroup.POST("/join", JoinCallRoomHandler)
callGroup.POST("/leave", LeaveCallRoomHandler)
callGroup.POST("/offer", WebRTCOfferHandler)
callGroup.POST("/ice", WebRTCICEHandler)
callGroup.GET("/ice-servers", GetICEServersHandler)
}
}