// Package controller 提供 meeting 模块的 HTTP 接口 package controller import ( "errors" "strconv" "strings" authSvc "github.com/echochat/backend/app/auth/service" "github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/dto" "github.com/echochat/backend/app/meeting/model" "github.com/echochat/backend/app/meeting/service" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/middleware" "github.com/echochat/backend/pkg/utils" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // MeetingController 会议 REST 控制器 // Task 5 完成:填充 12 个接口的请求解析、业务调用、DTO 转换、错误码映射 type MeetingController struct { meetingService *service.MeetingService meetingConfig *config.MeetingConfig authService *authSvc.AuthService } // NewMeetingController 创建 MeetingController 实例 func NewMeetingController(meetingService *service.MeetingService, meetingConfig *config.MeetingConfig, authService *authSvc.AuthService) *MeetingController { return &MeetingController{meetingService: meetingService, meetingConfig: meetingConfig, authService: authService} } type cloudLawCreateMeetingRequest struct { CallID string `json:"callId"` CallerID int64 `json:"callerId"` CallerPhone string `json:"callerPhone"` CallerName string `json:"callerName"` ReceiverID int64 `json:"receiverId"` ReceiverType string `json:"receiverType"` Title string `json:"title"` CallType string `json:"callType"` } type cloudLawJoinMeetingRequest struct { ReceiverID int64 `json:"receiverId"` ReceiverType string `json:"receiverType"` ReceiverPhone string `json:"receiverPhone"` ReceiverName string `json:"receiverName"` CallID string `json:"callId"` } type cloudLawSessionRequest struct { Phone string `json:"phone"` Name string `json:"name"` } 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 func requireUserID(c *gin.Context) (int64, bool) { userID, ok := middleware.GetCurrentUserID(c) if !ok { utils.ResponseUnauthorized(c, "无法获取当前用户信息") return 0, false } return userID, true } // handleError 将 service 层领域错误统一映射为 HTTP 响应 // 映射原则: // - 资源不存在 → 404 // - 权限不足(非 host)→ 403 // - 业务规则违反(密码错误、已满、状态冲突等)→ 400 // - 其余未知错误 → 500 func (ctl *MeetingController) handleError(c *gin.Context, err error, fallbackMsg string) { switch { case errors.Is(err, service.ErrMeetingNotFound): utils.ResponseNotFound(c, err.Error()) case errors.Is(err, service.ErrNotMeetingHost): utils.ResponseForbidden(c, err.Error()) case errors.Is(err, service.ErrMediaServiceUnavailable): // P0-3:媒体服务不可用应作为 5xx 返回,让前端走"稍后重试"提示,而非按"用户输入错误"处理 utils.ResponseError(c, err.Error()) case errors.Is(err, service.ErrMeetingEnded), errors.Is(err, service.ErrMeetingFull), errors.Is(err, service.ErrMeetingPasswordReq), errors.Is(err, service.ErrMeetingPasswordWrong), errors.Is(err, service.ErrMeetingPasswordLocked), errors.Is(err, service.ErrNotInMeeting), errors.Is(err, service.ErrAlreadyInMeeting), errors.Is(err, service.ErrAlreadyInOtherMeeting), errors.Is(err, service.ErrInviteTokenInvalid), errors.Is(err, service.ErrRoomCodeConflict), errors.Is(err, service.ErrKickSelfForbidden), errors.Is(err, service.ErrTransferToSelf), errors.Is(err, service.ErrTransferTargetInvalid), errors.Is(err, service.ErrCloudLawCallerPhoneRequired), errors.Is(err, service.ErrCloudLawCallerPhoneInvalid), // P2-7 会议聊天服务端校验失败:内容为空 / 超长 / 触发限流 // 当前使用 400(由 ResponseBadRequest 返回),保持与其余业务错误一致; // 未来若需要精细区分(如 ErrChatRateLimited → 429、ErrChatContentTooLong → 413),可拆分分支 errors.Is(err, service.ErrChatContentEmpty), errors.Is(err, service.ErrChatContentTooLong), errors.Is(err, service.ErrChatRateLimited): utils.ResponseBadRequest(c, err.Error()) default: logs.Warn(c.Request.Context(), "controller.meeting_controller.handleError", fallbackMsg, zap.Error(err)) utils.ResponseError(c, fallbackMsg) } } // ====== DTO 转换 ====== // roomToDTO 将 model.MeetingRoom 转 DTO,host_name/avatar 交由上层按需补全 func roomToDTO(r *model.MeetingRoom, onlineCount int) *dto.MeetingRoomDTO { if r == nil { return nil } out := &dto.MeetingRoomDTO{ ID: r.ID, RoomCode: r.RoomCode, Title: r.Title, HostID: r.HostID, Type: r.Type, HasPassword: r.PasswordHash != nil && *r.PasswordHash != "", MaxMembers: r.MaxMembers, Status: r.Status, StatusLabel: constants.MeetingStatusMap[r.Status], Settings: r.Settings, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), OnlineCount: onlineCount, EndedReason: r.EndedReason, } if r.ScheduledAt != nil { out.ScheduledAt = r.ScheduledAt.Format("2006-01-02 15:04:05") } if r.StartedAt != nil { out.StartedAt = r.StartedAt.Format("2006-01-02 15:04:05") } if r.EndedAt != nil { out.EndedAt = r.EndedAt.Format("2006-01-02 15:04:05") } return out } // participantToDTO 将 model.MeetingParticipant 转 DTO // userMap 可选:若提供则附带 user_name / user_avatar,便于前端 MemberPanel/VideoTile 直接渲染昵称头像 func participantToDTO(p *model.MeetingParticipant, userMap map[int64]service.UserDisplayInfo) *dto.MeetingParticipantDTO { if p == nil { return nil } out := &dto.MeetingParticipantDTO{ ID: p.ID, RoomID: p.RoomID, UserID: p.UserID, Role: p.Role, RoleLabel: constants.MeetingRoleMap[p.Role], JoinedAt: p.JoinedAt.Format("2006-01-02 15:04:05"), LeftReason: p.LeftReason, Duration: p.Duration, IsActive: p.IsActive(), } if p.LeftAt != nil { out.LeftAt = p.LeftAt.Format("2006-01-02 15:04:05") } if info, ok := userMap[p.UserID]; ok { out.UserName = info.Name out.UserAvatar = info.Avatar } return out } // chatToDTO 将 model.MeetingChat 转 DTO // userMap 可选:若提供则附带 user_name / user_avatar,否则为空 func chatToDTO(m *model.MeetingChat, userMap map[int64]service.UserDisplayInfo) *dto.MeetingChatDTO { if m == nil { return nil } d := &dto.MeetingChatDTO{ ID: m.ID, RoomID: m.RoomID, UserID: m.UserID, Content: m.Content, CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"), } if info, ok := userMap[m.UserID]; ok { d.UserName = info.Name d.UserAvatar = info.Avatar } return d } // ====== 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 } req.CallerPhone = strings.TrimSpace(req.CallerPhone) req.CallerName = strings.TrimSpace(req.CallerName) if req.CallerPhone == "" { utils.ResponseBadRequest(c, "callerPhone不能为空") return } title := req.Title if title == "" { title = "云律立即连线" } hostUser, created, err := ctl.meetingService.EnsureCloudLawUserByPhone(c.Request.Context(), req.CallerPhone, req.CallerName) if err != nil { ctl.handleError(c, err, "创建云律会议主持人失败") return } room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{ Title: title, MaxMembers: 2, }) if err != nil { ctl.handleError(c, err, "创建云律会议失败") return } payload := cloudLawMeetingPayload(room, 0) payload["callId"] = req.CallID payload["callerId"] = req.CallerID payload["callerPhone"] = req.CallerPhone payload["echoChatHostUserId"] = strconv.FormatInt(hostUser.ID, 10) payload["echoChatHostUserCreated"] = created 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) CloudLawSession(c *gin.Context) { if !ctl.requireCloudLawInternalToken(c) { return } var req cloudLawSessionRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } req.Phone = strings.TrimSpace(req.Phone) req.Name = strings.TrimSpace(req.Name) if req.Phone == "" { utils.ResponseBadRequest(c, "phone不能为空") return } user, _, err := ctl.meetingService.EnsureCloudLawUserByPhone(c.Request.Context(), req.Phone, req.Name) if err != nil { ctl.handleError(c, err, "创建云律 EchoChat 用户失败") return } resp, err := ctl.authService.BuildLoginResponseForUser(c.Request.Context(), user, c.ClientIP(), constants.ClientTypeFrontend) if err != nil { utils.ResponseError(c, "创建云律 EchoChat 登录态失败") return } utils.ResponseOK(c, resp) } func (ctl *MeetingController) CloudLawJoinMeeting(c *gin.Context) { if !ctl.requireCloudLawInternalToken(c) { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req cloudLawJoinMeetingRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } req.ReceiverPhone = strings.TrimSpace(req.ReceiverPhone) req.ReceiverName = strings.TrimSpace(req.ReceiverName) if req.ReceiverPhone == "" { utils.ResponseBadRequest(c, "receiverPhone不能为空") return } user, created, err := ctl.meetingService.EnsureCloudLawUserByPhone(c.Request.Context(), req.ReceiverPhone, req.ReceiverName) if err != nil { ctl.handleError(c, err, "创建云律会议接单人失败") return } room, participant, _, err := ctl.meetingService.JoinRoomForInternal(c.Request.Context(), user.ID, code) if err != nil { ctl.handleError(c, err, "云律接单人加入会议失败") return } payload := cloudLawMeetingPayload(room, 0) payload["callId"] = req.CallID payload["receiverId"] = req.ReceiverID payload["receiverType"] = req.ReceiverType payload["echoChatParticipantUserId"] = strconv.FormatInt(user.ID, 10) payload["echoChatParticipantUserCreated"] = created if participant != nil { payload["participantId"] = strconv.FormatInt(participant.ID, 10) } utils.ResponseOK(c, payload) } 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 func (ctl *MeetingController) CreateRoom(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } var req dto.CreateMeetingRoomRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } room, _, routerID, err := ctl.meetingService.CreateRoom(c.Request.Context(), userID, &req) if err != nil { ctl.handleError(c, err, "创建会议失败") return } // Task 9:CreateRoom 响应携带 router_id + rtpCapabilities,供前端 Device.load 使用 _, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(room.RoomCode) resp := dto.CreateMeetingRoomResponse{ Room: *roomToDTO(room, 1), RouterID: routerID, RtpCapabilities: rtpCaps, } utils.ResponseCreated(c, resp) } // GetRoom GET /api/v1/meeting/rooms/:code func (ctl *MeetingController) GetRoom(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } room, participants, onlineCount, err := ctl.meetingService.GetRoomByCode(c.Request.Context(), userID, code) if err != nil { ctl.handleError(c, err, "获取会议详情失败") return } userIDs := make([]int64, 0, len(participants)) for i := range participants { userIDs = append(userIDs, participants[i].UserID) } userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs) parts := make([]dto.MeetingParticipantDTO, 0, len(participants)) for i := range participants { parts = append(parts, *participantToDTO(&participants[i], userMap)) } resp := dto.GetMeetingRoomResponse{ Room: *roomToDTO(room, int(onlineCount)), Participants: parts, OnlineCount: int(onlineCount), } utils.ResponseOK(c, resp) } // JoinRoom POST /api/v1/meeting/rooms/:code/join func (ctl *MeetingController) JoinRoom(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req dto.JoinMeetingRoomRequest if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } room, participant, routerID, err := ctl.meetingService.JoinRoom(c.Request.Context(), userID, code, req.Password) if err != nil { ctl.handleError(c, err, "加入会议失败") return } // Task 9:JoinRoom 响应携带 rtpCapabilities,供前端 mediasoup-client Device.load 直接使用 _, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(code) userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{participant.UserID}) resp := dto.JoinMeetingRoomResponse{ Room: *roomToDTO(room, 0), Participant: *participantToDTO(participant, userMap), RouterID: routerID, RtpCapabilities: rtpCaps, } utils.ResponseOK(c, resp) } // LeaveRoom POST /api/v1/meeting/rooms/:code/leave func (ctl *MeetingController) LeaveRoom(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } duration, err := ctl.meetingService.LeaveRoom(c.Request.Context(), userID, code) if err != nil { ctl.handleError(c, err, "离开会议失败") return } utils.ResponseOK(c, dto.LeaveMeetingRoomResponse{Duration: duration}) } // EndRoom POST /api/v1/meeting/rooms/:code/end func (ctl *MeetingController) EndRoom(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } if err := ctl.meetingService.EndRoom(c.Request.Context(), userID, code); err != nil { ctl.handleError(c, err, "结束会议失败") return } utils.ResponseOK(c, gin.H{}) } // ListMyMeetings GET /api/v1/meeting/rooms/mine?status=&before_id=&limit= func (ctl *MeetingController) ListMyMeetings(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } var req dto.ListMyMeetingsRequest if err := c.ShouldBindQuery(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } rooms, hasMore, err := ctl.meetingService.ListMyMeetings(c.Request.Context(), userID, req.Status, req.BeforeID, req.Limit) if err != nil { ctl.handleError(c, err, "获取会议列表失败") return } list := make([]dto.MeetingRoomDTO, 0, len(rooms)) for i := range rooms { list = append(list, *roomToDTO(&rooms[i], 0)) } utils.ResponseOK(c, dto.ListMyMeetingsResponse{List: list, HasMore: hasMore}) } // TransferHost POST /api/v1/meeting/rooms/:code/transfer-host func (ctl *MeetingController) TransferHost(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req dto.TransferHostRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } if err := ctl.meetingService.TransferHost(c.Request.Context(), userID, code, req.TargetUserID); err != nil { ctl.handleError(c, err, "转让主持人失败") return } utils.ResponseOK(c, gin.H{}) } // KickMember POST /api/v1/meeting/rooms/:code/kick func (ctl *MeetingController) KickMember(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req dto.KickMemberRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } if err := ctl.meetingService.KickMember(c.Request.Context(), userID, code, req.UserID); err != nil { ctl.handleError(c, err, "踢出成员失败") return } utils.ResponseOK(c, gin.H{}) } // InviteUsers POST /api/v1/meeting/rooms/:code/invite func (ctl *MeetingController) InviteUsers(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req dto.InviteUsersRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } pushed, skipped, err := ctl.meetingService.InviteUsers(c.Request.Context(), userID, code, req.InviteeIDs) if err != nil { ctl.handleError(c, err, "发送邀请失败") return } utils.ResponseOK(c, dto.InviteUsersResponse{Pushed: pushed, Skipped: skipped}) } // RedeemInvite POST /api/v1/meeting/invite-tokens/:token/redeem func (ctl *MeetingController) RedeemInvite(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } token := c.Param("token") if token == "" { utils.ResponseBadRequest(c, "邀请 Token 不能为空") return } resp, err := ctl.meetingService.RedeemInviteToken(c.Request.Context(), userID, token) if err != nil { ctl.handleError(c, err, "兑换邀请链接失败") return } utils.ResponseOK(c, resp) } // SendChat POST /api/v1/meeting/rooms/:code/chats func (ctl *MeetingController) SendChat(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } var req dto.SendMeetingChatRequest if err := c.ShouldBindJSON(&req); err != nil { utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) return } msg, err := ctl.meetingService.SendChatMessage(c.Request.Context(), userID, code, req.Content) if err != nil { ctl.handleError(c, err, "发送会议聊天失败") return } userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{msg.UserID}) utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg, userMap)}) } // ListChats GET /api/v1/meeting/rooms/:code/chats?before_id=&limit= func (ctl *MeetingController) ListChats(c *gin.Context) { userID, ok := requireUserID(c) if !ok { return } code := c.Param("code") if code == "" { utils.ResponseBadRequest(c, "会议号不能为空") return } beforeID, _ := strconv.ParseInt(c.Query("before_id"), 10, 64) limit, _ := strconv.Atoi(c.Query("limit")) msgs, hasMore, err := ctl.meetingService.ListChatMessages(c.Request.Context(), userID, code, beforeID, limit) if err != nil { ctl.handleError(c, err, "获取会议聊天失败") return } userIDs := make([]int64, 0, len(msgs)) for i := range msgs { userIDs = append(userIDs, msgs[i].UserID) } userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs) list := make([]dto.MeetingChatDTO, 0, len(msgs)) for i := range msgs { list = append(list, *chatToDTO(&msgs[i], userMap)) } utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore}) }