Files
EchoChat/backend/go-service/app/meeting/controller/meeting_controller.go
2026-05-21 19:01:25 +08:00

565 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package controller 提供 meeting 模块的 HTTP 接口
package controller
import (
"errors"
"strconv"
"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
}
// NewMeetingController 创建 MeetingController 实例
func NewMeetingController(meetingService *service.MeetingService, meetingConfig *config.MeetingConfig) *MeetingController {
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
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),
// 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 转 DTOhost_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
}
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
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 9CreateRoom 响应携带 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 9JoinRoom 响应携带 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})
}