/** * 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) } }