视频会议
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/echochat/backend/app/dto"
|
"github.com/echochat/backend/app/dto"
|
||||||
"github.com/echochat/backend/app/meeting/model"
|
"github.com/echochat/backend/app/meeting/model"
|
||||||
"github.com/echochat/backend/app/meeting/service"
|
"github.com/echochat/backend/app/meeting/service"
|
||||||
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/logs"
|
"github.com/echochat/backend/pkg/logs"
|
||||||
"github.com/echochat/backend/pkg/middleware"
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
"github.com/echochat/backend/pkg/utils"
|
"github.com/echochat/backend/pkg/utils"
|
||||||
@@ -20,11 +21,48 @@ import (
|
|||||||
// Task 5 完成:填充 12 个接口的请求解析、业务调用、DTO 转换、错误码映射
|
// Task 5 完成:填充 12 个接口的请求解析、业务调用、DTO 转换、错误码映射
|
||||||
type MeetingController struct {
|
type MeetingController struct {
|
||||||
meetingService *service.MeetingService
|
meetingService *service.MeetingService
|
||||||
|
meetingConfig *config.MeetingConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMeetingController 创建 MeetingController 实例
|
// NewMeetingController 创建 MeetingController 实例
|
||||||
func NewMeetingController(meetingService *service.MeetingService) *MeetingController {
|
func NewMeetingController(meetingService *service.MeetingService, meetingConfig *config.MeetingConfig) *MeetingController {
|
||||||
return &MeetingController{meetingService: meetingService}
|
return &MeetingController{meetingService: meetingService, meetingConfig: meetingConfig}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudLawCreateMeetingRequest struct {
|
||||||
|
CallID string `json:"callId"`
|
||||||
|
CallerID int64 `json:"callerId"`
|
||||||
|
ReceiverID int64 `json:"receiverId"`
|
||||||
|
ReceiverType string `json:"receiverType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CallType string `json:"callType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *MeetingController) requireCloudLawInternalToken(c *gin.Context) bool {
|
||||||
|
expected := ""
|
||||||
|
if ctl.meetingConfig != nil {
|
||||||
|
expected = ctl.meetingConfig.CloudLawInternalToken
|
||||||
|
}
|
||||||
|
if expected == "" {
|
||||||
|
utils.ResponseForbidden(c, "云律内部接口未配置")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.GetHeader("X-Internal-Token") != expected {
|
||||||
|
utils.ResponseUnauthorized(c, "内部访问令牌无效")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudLawMeetingPayload(room *model.MeetingRoom, onlineCount int64) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"roomId": strconv.FormatInt(room.ID, 10),
|
||||||
|
"roomCode": room.RoomCode,
|
||||||
|
"code": room.RoomCode,
|
||||||
|
"meetingCode": room.RoomCode,
|
||||||
|
"status": room.Status,
|
||||||
|
"onlineCount": onlineCount,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false
|
// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false
|
||||||
@@ -162,6 +200,72 @@ func chatToDTO(m *model.MeetingChat, userMap map[int64]service.UserDisplayInfo)
|
|||||||
|
|
||||||
// ====== REST API ======
|
// ====== REST API ======
|
||||||
|
|
||||||
|
func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) {
|
||||||
|
if !ctl.requireCloudLawInternalToken(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req cloudLawCreateMeetingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CallerID <= 0 {
|
||||||
|
utils.ResponseBadRequest(c, "callerId不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := req.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "云律立即连线"
|
||||||
|
}
|
||||||
|
room, _, _, err := ctl.meetingService.CreateRoom(c.Request.Context(), req.CallerID, &dto.CreateMeetingRoomRequest{
|
||||||
|
Title: title,
|
||||||
|
MaxMembers: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "创建云律会议失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := cloudLawMeetingPayload(room, 1)
|
||||||
|
payload["callId"] = req.CallID
|
||||||
|
payload["receiverId"] = req.ReceiverID
|
||||||
|
payload["receiverType"] = req.ReceiverType
|
||||||
|
payload["callType"] = req.CallType
|
||||||
|
utils.ResponseOK(c, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *MeetingController) CloudLawGetMeeting(c *gin.Context) {
|
||||||
|
if !ctl.requireCloudLawInternalToken(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Param("code")
|
||||||
|
if code == "" {
|
||||||
|
utils.ResponseBadRequest(c, "会议号不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room, _, onlineCount, err := ctl.meetingService.GetRoomForInternal(c.Request.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取云律会议详情失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, cloudLawMeetingPayload(room, onlineCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *MeetingController) CloudLawEndMeeting(c *gin.Context) {
|
||||||
|
if !ctl.requireCloudLawInternalToken(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Param("code")
|
||||||
|
if code == "" {
|
||||||
|
utils.ResponseBadRequest(c, "会议号不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ctl.meetingService.EndRoomForInternal(c.Request.Context(), code); err != nil {
|
||||||
|
ctl.handleError(c, err, "结束云律会议失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, gin.H{"roomCode": code, "code": code, "meetingCode": code})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateRoom POST /api/v1/meeting/rooms
|
// CreateRoom POST /api/v1/meeting/rooms
|
||||||
func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
||||||
userID, ok := requireUserID(c)
|
userID, ok := requireUserID(c)
|
||||||
|
|||||||
@@ -46,4 +46,11 @@ func RegisterRoutes(
|
|||||||
// 鉴权由 controller 内部用 X-Internal-Secret header + INTERNAL_WEBHOOK_SECRET 校验
|
// 鉴权由 controller 内部用 X-Internal-Secret header + INTERNAL_WEBHOOK_SECRET 校验
|
||||||
// 不走 JWT,因为调用方是 media-server 而非用户
|
// 不走 JWT,因为调用方是 media-server 而非用户
|
||||||
r.POST("/internal/meeting/recordings/failure", recordingCtrl.MediaServerFailureWebhook)
|
r.POST("/internal/meeting/recordings/failure", recordingCtrl.MediaServerFailureWebhook)
|
||||||
|
|
||||||
|
internalCloudLaw := r.Group("/internal/cloud-law")
|
||||||
|
{
|
||||||
|
internalCloudLaw.POST("/meetings", ctrl.CloudLawCreateMeeting)
|
||||||
|
internalCloudLaw.GET("/meetings/:code", ctrl.CloudLawGetMeeting)
|
||||||
|
internalCloudLaw.POST("/meetings/:code/end", ctrl.CloudLawEndMeeting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,39 @@ func (s *MeetingService) GetRoomByCode(ctx context.Context, userID int64, code s
|
|||||||
return room, participants, activeCount, nil
|
return room, participants, activeCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MeetingService) GetRoomForInternal(ctx context.Context, code string) (*model.MeetingRoom, []model.MeetingParticipant, int64, error) {
|
||||||
|
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, err
|
||||||
|
}
|
||||||
|
if room == nil {
|
||||||
|
return nil, nil, 0, ErrMeetingNotFound
|
||||||
|
}
|
||||||
|
participants, err := s.participantDAO.ListByRoom(ctx, room.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, err
|
||||||
|
}
|
||||||
|
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, err
|
||||||
|
}
|
||||||
|
return room, participants, activeCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MeetingService) EndRoomForInternal(ctx context.Context, code string) error {
|
||||||
|
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if room == nil {
|
||||||
|
return ErrMeetingNotFound
|
||||||
|
}
|
||||||
|
if room.Status == constants.MeetingStatusEnded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.EndRoom(ctx, room.HostID, code)
|
||||||
|
}
|
||||||
|
|
||||||
// JoinRoom 加入会议
|
// JoinRoom 加入会议
|
||||||
// 校验顺序:房间存在 → 未结束 → 单点参会 → 密码锁定 → 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined
|
// 校验顺序:房间存在 → 未结束 → 单点参会 → 密码锁定 → 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined
|
||||||
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
|
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
// admin 会议管理服务在 recording service 构造后再创建,从而支持"强制结束录制"兜底
|
// admin 会议管理服务在 recording service 构造后再创建,从而支持"强制结束录制"兜底
|
||||||
meetingManageService := service2.NewMeetingManageService(meetingManageDAO, meetingRecordingService)
|
meetingManageService := service2.NewMeetingManageService(meetingManageDAO, meetingRecordingService)
|
||||||
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
|
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
|
||||||
meetingController := controller7.NewMeetingController(meetingService)
|
meetingController := controller7.NewMeetingController(meetingService, meetingConfig)
|
||||||
meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService, meetingConfig)
|
meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService, meetingConfig)
|
||||||
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
|
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
|
||||||
meetingCleanupTask := task2.NewMeetingCleanupTask(meetingLifecycleService, meetingRoomDAO, meetingChatDAO)
|
meetingCleanupTask := task2.NewMeetingCleanupTask(meetingLifecycleService, meetingRoomDAO, meetingChatDAO)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ meeting:
|
|||||||
# media-server 失败回调共享密钥:必须与 media-server 端 INTERNAL_WEBHOOK_SECRET 环境变量一致
|
# media-server 失败回调共享密钥:必须与 media-server 端 INTERNAL_WEBHOOK_SECRET 环境变量一致
|
||||||
# 留空时 /internal/meeting/recordings/failure 全部 403(默认安全)
|
# 留空时 /internal/meeting/recordings/failure 全部 403(默认安全)
|
||||||
internal_webhook_secret: internal-webhook-12xqs
|
internal_webhook_secret: internal-webhook-12xqs
|
||||||
|
cloud_law_internal_token: cloud-law-internal-xx14
|
||||||
recording_transcode_enabled: true
|
recording_transcode_enabled: true
|
||||||
recording_transcode_ffmpeg_path: /home/ykf/jenkins/yuyin/ffmpeg/ffmpeg-7.0.2-amd64-static/ffmpeg
|
recording_transcode_ffmpeg_path: /home/ykf/jenkins/yuyin/ffmpeg/ffmpeg-7.0.2-amd64-static/ffmpeg
|
||||||
recording_transcode_timeout_seconds: 600
|
recording_transcode_timeout_seconds: 600
|
||||||
@@ -83,6 +83,7 @@ meeting:
|
|||||||
# media-server 失败回调共享密钥:必须与 docker-compose 中 media-server 服务的 INTERNAL_WEBHOOK_SECRET 一致
|
# media-server 失败回调共享密钥:必须与 docker-compose 中 media-server 服务的 INTERNAL_WEBHOOK_SECRET 一致
|
||||||
# 生产部署请通过环境变量 ECHOCHAT_MEETING_INTERNAL_WEBHOOK_SECRET 覆盖(viper.AutomaticEnv 已开启)
|
# 生产部署请通过环境变量 ECHOCHAT_MEETING_INTERNAL_WEBHOOK_SECRET 覆盖(viper.AutomaticEnv 已开启)
|
||||||
internal_webhook_secret: internal-webhook-12xqs
|
internal_webhook_secret: internal-webhook-12xqs
|
||||||
|
cloud_law_internal_token: cloud-law-internal-xx14
|
||||||
recording_transcode_enabled: true
|
recording_transcode_enabled: true
|
||||||
recording_transcode_ffmpeg_path: ffmpeg
|
recording_transcode_ffmpeg_path: ffmpeg
|
||||||
recording_transcode_timeout_seconds: 600
|
recording_transcode_timeout_seconds: 600
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type MeetingConfig struct {
|
|||||||
RecordingTranscodeFFmpegPath string `mapstructure:"recording_transcode_ffmpeg_path"`
|
RecordingTranscodeFFmpegPath string `mapstructure:"recording_transcode_ffmpeg_path"`
|
||||||
RecordingTranscodeTimeoutSeconds int `mapstructure:"recording_transcode_timeout_seconds"`
|
RecordingTranscodeTimeoutSeconds int `mapstructure:"recording_transcode_timeout_seconds"`
|
||||||
StaleRoomHours int `mapstructure:"stale_room_hours"` // 活跃超过此小时且无成员视为 stale,默认 4
|
StaleRoomHours int `mapstructure:"stale_room_hours"` // 活跃超过此小时且无成员视为 stale,默认 4
|
||||||
|
CloudLawInternalToken string `mapstructure:"cloud_law_internal_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaServerConfig Node media-server 接入配置(Phase 2e-2 Task 7)
|
// MediaServerConfig Node media-server 接入配置(Phase 2e-2 Task 7)
|
||||||
|
|||||||
Reference in New Issue
Block a user