微信小程序

This commit is contained in:
2025-12-15 09:04:14 +08:00
parent 4f2b0bb213
commit 2485cd3ac9
12 changed files with 2622 additions and 1 deletions

View File

@@ -0,0 +1,419 @@
/**
* 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 用
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)
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.ID)
participant.PushURL = stream.PushURL
participant.PullURL = stream.PullURL
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,
})
}
}
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)
}
}

View File

@@ -0,0 +1,183 @@
/**
* package api
*
* 媒体服务监控 API 处理器
* 功能:
* 1. 查看媒体服务器状态
* 2. 查看活跃房间和流
* 3. 管理桥接器
*/
package api
import (
"xk-websocket-v2/internal/mediaserver"
"xk-websocket-v2/internal/utils"
"github.com/gin-gonic/gin"
)
// MediaServerStatusResponse 媒体服务器状态响应
type MediaServerStatusResponse struct {
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
RoomCount int `json:"room_count"`
SFUTrackCount int `json:"sfu_track_count"`
RTMPStreamCount int `json:"rtmp_stream_count"`
}
// GetMediaServerStatusHandler 获取媒体服务器状态
// GET /api/media/status
func GetMediaServerStatusHandler(c *gin.Context) {
ms := mediaserver.GetServer()
config := ms.GetConfig()
if config == nil {
utils.Error(c, 503, "媒体服务未配置")
return
}
response := MediaServerStatusResponse{
Enabled: config.Enabled,
RoomCount: ms.GetRoomCount(),
Config: map[string]interface{}{
"public_ip": config.PublicIP,
"webrtc_port": config.WebRTCPort,
"rtmp_port": config.RTMPPort,
"http_flv_port": config.HTTPFLVPort,
"max_room_size": config.MaxRoomSize,
},
}
// 获取 SFU 轨道数
if sfu := ms.GetSFU(); sfu != nil {
response.SFUTrackCount = sfu.GetTrackCount()
}
// 获取 RTMP 流数
if rtmp := ms.GetRTMP(); rtmp != nil {
response.RTMPStreamCount = rtmp.GetStreamCount()
}
utils.SuccessWithData(c, response, "获取成功")
}
// GetAllRoomsHandler 获取所有房间
// GET /api/media/rooms
func GetAllRoomsHandler(c *gin.Context) {
ms := mediaserver.GetServer()
rooms := ms.GetAllRooms()
utils.SuccessWithData(c, gin.H{
"count": len(rooms),
"rooms": rooms,
}, "获取成功")
}
// GetRoomDetailHandler 获取房间详情
// GET /api/media/rooms/:room_id
func GetRoomDetailHandler(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()
// 获取桥接信息
bm := mediaserver.GetBridgeManager()
bridge := bm.GetBridge(roomID)
var bridgeInfo map[string]interface{}
if bridge != nil && bridge.IsRunning() {
w2r, r2w := bridge.GetAllConverters()
w2rCount, r2wCount := bridge.GetConverterCount()
bridgeInfo = map[string]interface{}{
"running": true,
"webrtc_to_rtmp": w2rCount,
"rtmp_to_webrtc": r2wCount,
"w2r_converters": w2r,
"r2w_converters": r2w,
}
}
utils.SuccessWithData(c, gin.H{
"room": info,
"bridge": bridgeInfo,
}, "获取成功")
}
// GetRTMPStreamsHandler 获取 RTMP 流列表
// GET /api/media/rtmp/streams
func GetRTMPStreamsHandler(c *gin.Context) {
ms := mediaserver.GetServer()
rtmp := ms.GetRTMP()
if rtmp == nil {
utils.Error(c, 503, "RTMP服务未启用")
return
}
roomID := c.Query("room_id")
var streams []*mediaserver.RTMPStream
if roomID != "" {
streams = rtmp.GetStreamsByRoom(roomID)
} else {
// 获取所有流(简化实现:只返回数量)
utils.SuccessWithData(c, gin.H{
"count": rtmp.GetStreamCount(),
}, "获取成功")
return
}
utils.SuccessWithData(c, gin.H{
"count": len(streams),
"streams": streams,
}, "获取成功")
}
// CloseRoomHandler 关闭房间
// POST /api/media/rooms/:room_id/close
func CloseRoomHandler(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
}
// 移除桥接
bm := mediaserver.GetBridgeManager()
bm.RemoveBridge(roomID)
// 移除房间
ms.RemoveRoom(roomID)
utils.Success(c, "房间已关闭")
}
// RegisterMediaRoutes 注册媒体相关路由
func RegisterMediaRoutes(router *gin.RouterGroup) {
mediaGroup := router.Group("/media")
{
mediaGroup.GET("/status", GetMediaServerStatusHandler)
mediaGroup.GET("/rooms", GetAllRoomsHandler)
mediaGroup.GET("/rooms/:room_id", GetRoomDetailHandler)
mediaGroup.POST("/rooms/:room_id/close", CloseRoomHandler)
mediaGroup.GET("/rtmp/streams", GetRTMPStreamsHandler)
}
}