Files
EchoChat/backend/go-service/app/meeting/controller/meeting_controller.go
bujinyuan e235e001b3 feat(phase2e-2): Go meeting 模块 12 个 REST 接口业务逻辑全量落地(Task 5)
主要产出:
- DTO 层:app/dto/meeting_dto.go 完整定义 13 个 DTO(请求/响应/基础共用)
- Service 层:MeetingService 12 业务方法 + 11 个 sentinel 错误 + 4 辅助
  - CreateRoom/JoinRoom/LeaveRoom/EndRoom 核心生命周期
  - KickMember/TransferHost/GetRoom/ListMyMeetings 会议管理
  - InviteUsers(+ NotifyPusher)/ RedeemInviteToken 邀请链路
  - SendChat/ListChats 会议内聊天
- Controller 层:12 handler + handleError 领域错误 → HTTP 映射
- 工具层:pkg/utils/meeting_code.go(XXX-XXX-XXX + invite token)
- Stub 接口:MediaOrchestrator(Task 7 替换)+ NoopMediaOrchestrator
- 路径修正:/rooms → /rooms/mine、/invites → /invite-tokens 对齐设计
- DAO 契约修复:GetByID/GetByCode/GetByRoomAndUser/FindActiveByUser
  将 gorm.ErrRecordNotFound 转为 (nil, nil),service 统一 nil 判定
- 安全强化:密码 bcrypt + 5 次错误锁 10 分钟;邀请 token 仅通过
  NotifyPusher.Extra 定向下发,响应不回传
- host 自动转让:host 离会时将最早加入者提升为 host
- 单点参会:用户同一时间仅能在一个活跃会议
- API 文档:docs/api/frontend/meeting.md 重写为 12 接口完整规范
- Wire 依赖注入:MediaOrchestrator + NoopMediaOrchestrator provider

验证:
- go build / go vet / wire 零告警
- 端到端 3 用户场景:12 happy path + 5 错误路径 PASS=19 / FAIL=0
  覆盖密码错/房间不存在/单点冲突/越权/邀请失效

文档同步:
- docs/progress/CURRENT_STATUS.md 增补 Task 5 章节
- .cursor/rules/project-context.mdc 更新阶段状态
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md 标记 T5 

下一步:Task 6(WS 信令协议 + BroadcastToMeeting 替换 PublishToUser 循环)

Made-with: Cursor
2026-04-21 16:39:59 +08:00

422 lines
12 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/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 转 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
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
}
resp := dto.CreateMeetingRoomResponse{
Room: *roomToDTO(room, 1),
}
_ = routerID // 当前 Noop 返回占位 RouterIDTask 7 后可拼入响应供前端订阅使用
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
}
resp := dto.JoinMeetingRoomResponse{
Room: *roomToDTO(room, 0),
Participant: *participantToDTO(participant),
RouterID: routerID,
}
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})
}