- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data - frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号) - frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer - frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings - frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理 - frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制) - backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities - 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地 Made-with: Cursor
428 lines
12 KiB
Go
428 lines
12 KiB
Go
// 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/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
|
||
}
|
||
|
||
// NewMeetingController 创建 MeetingController 实例
|
||
func NewMeetingController(meetingService *service.MeetingService) *MeetingController {
|
||
return &MeetingController{meetingService: meetingService}
|
||
}
|
||
|
||
// 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.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):
|
||
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
|
||
func participantToDTO(p *model.MeetingParticipant) *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")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// chatToDTO 将 model.MeetingChat 转 DTO
|
||
func chatToDTO(m *model.MeetingChat) *dto.MeetingChatDTO {
|
||
if m == nil {
|
||
return nil
|
||
}
|
||
return &dto.MeetingChatDTO{
|
||
ID: m.ID,
|
||
RoomID: m.RoomID,
|
||
UserID: m.UserID,
|
||
Content: m.Content,
|
||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
}
|
||
}
|
||
|
||
// ====== REST API ======
|
||
|
||
// 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
|
||
}
|
||
parts := make([]dto.MeetingParticipantDTO, 0, len(participants))
|
||
for i := range participants {
|
||
parts = append(parts, *participantToDTO(&participants[i]))
|
||
}
|
||
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)
|
||
resp := dto.JoinMeetingRoomResponse{
|
||
Room: *roomToDTO(room, 0),
|
||
Participant: *participantToDTO(participant),
|
||
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
|
||
}
|
||
utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg)})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
list := make([]dto.MeetingChatDTO, 0, len(msgs))
|
||
for i := range msgs {
|
||
list = append(list, *chatToDTO(&msgs[i]))
|
||
}
|
||
utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore})
|
||
}
|