437 lines
12 KiB
Go
437 lines
12 KiB
Go
/**
|
||
* 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)
|
||
}
|
||
}
|