Files

539 lines
15 KiB
Go
Raw Permalink Normal View History

2025-12-15 09:04:14 +08:00
/**
* package api
*
* 通话相关 API 处理器
* 功能
* 1. 创建/加入/离开通话房间
* 2. 处理 WebRTC 信令offer/answer/ice
* 3. 生成 RTMP 推拉流地址小程序
*/
package api
import (
"fmt"
2025-12-15 21:57:21 +08:00
"log"
2025-12-15 09:04:14 +08:00
"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 web app miniprogram wxapp"` // h5/web/app/miniprogram/wxapp
2025-12-15 09:04:14 +08:00
}
// 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"`
2025-12-15 21:57:21 +08:00
ICEServers []ICEServerConfig `json:"ice_servers,omitempty"` // H5/App 用 (WebRTC 模式)
WSPushURL string `json:"ws_push_url,omitempty"` // H5/App 用 (RTMP 模式 WebSocket 推流地址)
SelfFLVURL string `json:"self_flv_url,omitempty"` // H5/App 用 - 自己的 HTTP-FLV 地址(供小程序拉流)
2025-12-15 13:21:08 +08:00
FlvPullURLs []PullURLInfo `json:"flv_pull_urls,omitempty"` // H5/App 拉取小程序流的 FLV 地址
2025-12-15 21:57:21 +08:00
PushURL string `json:"push_url,omitempty"` // 小程序用 RTMP 推流地址
FLVURL string `json:"flv_url,omitempty"` // 小程序用 - 自己的 HTTP-FLV 地址(供其他端拉流)
PullURLs []PullURLInfo `json:"pull_urls,omitempty"` // 小程序用 RTMP 拉流地址
2025-12-15 09:04:14 +08:00
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 处理函数 ==========
2026-08-24 15:29:53 +08:00
// currentCallUserID 从 JWT 上下文获取当前登录用户ID。
// 通话相关接口必须以此为准,忽略请求体里的 user_id防止冒充他人进房/推流/信令。
func currentCallUserID(c *gin.Context) string {
if uid, ok := c.Get("user_id"); ok {
if s, ok2 := uid.(string); ok2 {
return s
}
}
return ""
}
2025-12-15 09:04:14 +08:00
// 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
}
2026-08-24 15:29:53 +08:00
// 身份以 JWT 为准,忽略请求体中的 user_id防止冒充他人加入通话
if uid := currentCallUserID(c); uid != "" {
req.UserID = uid
} else {
utils.Unauthorized(c, "未认证")
return
}
2025-12-15 09:04:14 +08:00
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", "web", "app":
// H5/Web/App 使用 WebRTC 或 RTMP 模式
2025-12-15 09:04:14 +08:00
pType := mediaserver.ParticipantTypeWebRTC
2025-12-15 21:57:21 +08:00
participant, err := room.AddParticipant(req.UserID, pType)
2025-12-15 09:04:14 +08:00
if err != nil {
utils.BadRequest(c, err.Error())
return
}
2025-12-15 21:57:21 +08:00
// 返回 ICE 服务器配置 (WebRTC 模式)
2025-12-15 09:04:14 +08:00
response.ICEServers = getICEServers(req.UserID)
2025-12-15 21:57:21 +08:00
// 生成 WebSocket 推流地址 (RTMP 模式)
// 格式: ws://host:port/api/call/ws-push?stream_id=xxx&user_id=xxx&room_id=xxx&token=xxx
rtmpServer := ms.GetRTMP()
if rtmpServer != nil {
// 为 H5/App 生成流信息
stream, err := rtmpServer.GenerateStreamURLs(req.RoomID, req.UserID)
if err == nil {
// 更新参与者的流地址
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
// 构建 WebSocket 推流 URL
token := mediaserver.GenerateStreamToken(stream.ID)
response.WSPushURL = fmt.Sprintf("wss://g-ws.nailaoyun.cn/api/call/ws-push?stream_id=%s&user_id=%s&room_id=%s&token=%s",
stream.ID, req.UserID, req.RoomID, token)
// 返回 H5/App 自己的 FLV 地址(供小程序拉流)
response.SelfFLVURL = stream.FLVURL
}
}
2025-12-15 13:21:08 +08:00
// 获取房间内小程序用户的 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", "wxapp":
2025-12-15 09:04:14 +08:00
// 小程序使用 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
}
// 使用 FFmpeg -listen 模式接收小程序推流
stream, err := rtmpServer.GenerateStreamURLsWithPlatform(req.RoomID, req.UserID, req.Platform)
2025-12-15 09:04:14 +08:00
if err != nil {
utils.InternalError(c, "生成推流地址失败")
return
}
// 更新参与者的流地址
2025-12-15 13:21:08 +08:00
room.SetParticipantRTMPURLs(req.UserID, stream.PushURL, stream.PullURL, stream.FLVURL, stream.ID)
2025-12-15 09:04:14 +08:00
participant.PushURL = stream.PushURL
participant.PullURL = stream.PullURL
2025-12-15 13:21:08 +08:00
participant.FLVURL = stream.FLVURL
2025-12-15 09:04:14 +08:00
participant.StreamID = stream.ID
response.PushURL = stream.PushURL
2025-12-15 21:57:21 +08:00
response.FLVURL = stream.FLVURL // 返回自己的 FLV 地址,供客户端发送给其他端
2025-12-15 09:04:14 +08:00
// 获取房间内其他用户的拉流地址
pullURLs := make([]PullURLInfo, 0)
for _, p := range room.GetOtherParticipants(req.UserID) {
if p.PullURL != "" {
pullURLs = append(pullURLs, PullURLInfo{
UserID: p.UserID,
URL: p.PullURL,
2025-12-15 13:21:08 +08:00
FLVURL: p.FLVURL,
2025-12-15 09:04:14 +08:00
})
}
}
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
}
2026-08-24 15:29:53 +08:00
// 身份以 JWT 为准,防止冒充他人离开通话房间
if uid := currentCallUserID(c); uid != "" {
req.UserID = uid
} else {
utils.Unauthorized(c, "未认证")
return
}
2025-12-15 09:04:14 +08:00
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
}
2026-08-24 15:29:53 +08:00
// 身份以 JWT 为准,防止冒充他人提交 SDP Offer
if uid := currentCallUserID(c); uid != "" {
req.UserID = uid
} else {
utils.Unauthorized(c, "未认证")
return
}
2025-12-15 09:04:14 +08:00
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
}
2026-08-24 15:29:53 +08:00
// 身份以 JWT 为准,防止冒充他人提交 ICE 候选
if uid := currentCallUserID(c); uid != "" {
req.UserID = uid
} else {
utils.Unauthorized(c, "未认证")
return
}
2025-12-15 09:04:14 +08:00
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) {
2026-08-24 15:29:53 +08:00
// 凭证绑定当前登录用户,取自 JWT避免为任意 user_id 签发 TURN 凭证被滥用
userID := currentCallUserID(c)
2025-12-15 09:04:14 +08:00
if userID == "" {
2026-08-24 15:29:53 +08:00
utils.Unauthorized(c, "未认证")
return
2025-12-15 09:04:14 +08:00
}
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)
2025-12-15 21:57:21 +08:00
// 注意ws-push 路由已移到全局main.go避免中间件干扰 WebSocket 升级
2025-12-15 09:04:14 +08:00
}
}
2025-12-15 21:57:21 +08:00
// WSPushHandler WebSocket 推流处理
// GET /api/call/ws-push?stream_id=xxx&user_id=xxx&room_id=xxx&token=xxx
func WSPushHandler(c *gin.Context) {
// 详细日志:确认请求到达
log.Printf("🔌 [WSPush] 收到请求: %s from %s", c.Request.URL.String(), c.ClientIP())
log.Printf("🔌 [WSPush] Headers: Upgrade=%s Connection=%s Origin=%s",
c.GetHeader("Upgrade"), c.GetHeader("Connection"), c.GetHeader("Origin"))
ms := mediaserver.GetServer()
if ms.GetConfig() == nil || !ms.GetConfig().Enabled {
log.Printf("❌ [WSPush] 媒体服务未启用")
utils.Error(c, 503, "媒体服务未启用")
return
}
wsProxy := ms.GetWSProxy()
if wsProxy == nil {
log.Printf("❌ [WSPush] WebSocket代理未启用")
utils.Error(c, 503, "WebSocket代理未启用")
return
}
log.Printf("✅ [WSPush] 转交给 WebSocket 代理处理")
// 交给 WebSocket 代理处理
wsProxy.HandleWebSocket(c.Writer, c.Request)
}