Files
EchoChat/backend/go-service/app/meeting/dao/meeting_room_dao.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

183 lines
6.5 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 dao 提供 meeting 模块的数据库访问操作
package dao
import (
"context"
"errors"
"time"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MeetingRoomDAO 会议房间数据访问对象
type MeetingRoomDAO struct {
db *gorm.DB
}
// NewMeetingRoomDAO 创建 MeetingRoomDAO 实例
func NewMeetingRoomDAO(db *gorm.DB) *MeetingRoomDAO {
return &MeetingRoomDAO{db: db}
}
// Create 新建一个会议房间
// 由调用方预先生成 RoomCode 并确认唯一,本方法不做冲突重试
func (d *MeetingRoomDAO) Create(ctx context.Context, room *model.MeetingRoom) error {
funcName := "dao.meeting_room_dao.Create"
logs.Info(ctx, funcName, "创建会议房间",
zap.String("room_code", room.RoomCode), zap.Int64("host_id", room.HostID))
err := d.db.WithContext(ctx).Create(room).Error
if err != nil {
logs.Error(ctx, funcName, "创建会议房间失败",
zap.String("room_code", room.RoomCode), zap.Error(err))
}
return err
}
// GetByID 按主键查询
// 记录不存在时返回 (nil, nil),便于上层直接用 room == nil 判空并返回业务错误
func (d *MeetingRoomDAO) GetByID(ctx context.Context, id int64) (*model.MeetingRoom, error) {
var room model.MeetingRoom
err := d.db.WithContext(ctx).First(&room, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &room, nil
}
// GetByCode 按会议号查询(入会流程的主要入口)
// 记录不存在时返回 (nil, nil),上层应通过 room == nil 判定并返回业务错误 ErrMeetingNotFound
func (d *MeetingRoomDAO) GetByCode(ctx context.Context, code string) (*model.MeetingRoom, error) {
funcName := "dao.meeting_room_dao.GetByCode"
var room model.MeetingRoom
err := d.db.WithContext(ctx).Where("room_code = ?", code).First(&room).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
logs.Error(ctx, funcName, "按会议号查询房间失败",
zap.String("room_code", code), zap.Error(err))
return nil, err
}
return &room, nil
}
// ExistsCode 会议号是否已被占用(用于创建会议时的 code 冲突重试)
func (d *MeetingRoomDAO) ExistsCode(ctx context.Context, code string) (bool, error) {
var count int64
err := d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("room_code = ?", code).
Count(&count).Error
return count > 0, err
}
// MarkStarted 记录会议实际开始时间host 首次加入时调用)
// 仅当 status=pending 且 started_at IS NULL 时才写入,避免重复覆盖
func (d *MeetingRoomDAO) MarkStarted(ctx context.Context, id int64, startedAt time.Time) (int64, error) {
funcName := "dao.meeting_room_dao.MarkStarted"
res := d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("id = ? AND status = ? AND started_at IS NULL", id, constants.MeetingStatusPending).
Updates(map[string]interface{}{
"status": constants.MeetingStatusActive,
"started_at": startedAt,
})
if res.Error != nil {
logs.Error(ctx, funcName, "标记会议开始失败",
zap.Int64("room_id", id), zap.Error(res.Error))
}
return res.RowsAffected, res.Error
}
// MarkEnded 记录会议结束status=2 + ended_at + ended_reason
// 乐观锁:只对 status != ended 的行生效,避免重复结束覆盖原始 reason
func (d *MeetingRoomDAO) MarkEnded(ctx context.Context, id int64, reason string, endedAt time.Time) (int64, error) {
funcName := "dao.meeting_room_dao.MarkEnded"
res := d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("id = ? AND status != ?", id, constants.MeetingStatusEnded).
Updates(map[string]interface{}{
"status": constants.MeetingStatusEnded,
"ended_at": endedAt,
"ended_reason": reason,
})
if res.Error != nil {
logs.Error(ctx, funcName, "标记会议结束失败",
zap.Int64("room_id", id), zap.String("reason", reason), zap.Error(res.Error))
}
return res.RowsAffected, res.Error
}
// UpdateHost 更新主持人(仅修改 meeting_rooms.host_id 字段meeting_participants.role 由 ParticipantDAO.TransferHost 在同一事务中处理)
func (d *MeetingRoomDAO) UpdateHost(ctx context.Context, id, newHostID int64) error {
return d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("id = ?", id).
Update("host_id", newHostID).Error
}
// UpdateSettings 更新房间级配置settings 字段整体替换)
// 调用方需保证 settingsJSON 是合法 JSON 字符串
func (d *MeetingRoomDAO) UpdateSettings(ctx context.Context, id int64, settingsJSON string) error {
return d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("id = ?", id).
Update("settings", settingsJSON).Error
}
// ListByHost 按主持人查询会议列表(支持 status 过滤 + 分页)
// status 传 -1 表示不限
// 返回按 created_at DESC 排序,走 idx_meeting_rooms_host_status 索引
func (d *MeetingRoomDAO) ListByHost(ctx context.Context, hostID int64, status int, offset, limit int) ([]model.MeetingRoom, int64, error) {
funcName := "dao.meeting_room_dao.ListByHost"
q := d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("host_id = ?", hostID)
if status >= 0 {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
logs.Error(ctx, funcName, "计数失败", zap.Int64("host_id", hostID), zap.Error(err))
return nil, 0, err
}
var rooms []model.MeetingRoom
err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&rooms).Error
if err != nil {
logs.Error(ctx, funcName, "查询失败", zap.Int64("host_id", hostID), zap.Error(err))
}
return rooms, total, err
}
// ListExpiredForCleanup 返回已结束且超过指定小时数的房间 ID 列表
// 供定时任务清理 meeting_chats 使用room 本身保留归档不删
// 返回 limit 上限用于分批处理,避免一次捞太多
func (d *MeetingRoomDAO) ListExpiredForCleanup(ctx context.Context, hoursAgo int, limit int) ([]int64, error) {
funcName := "dao.meeting_room_dao.ListExpiredForCleanup"
cutoff := time.Now().Add(-time.Duration(hoursAgo) * time.Hour)
var ids []int64
err := d.db.WithContext(ctx).
Model(&model.MeetingRoom{}).
Where("status = ? AND ended_at IS NOT NULL AND ended_at < ?",
constants.MeetingStatusEnded, cutoff).
Order("ended_at ASC").
Limit(limit).
Pluck("id", &ids).Error
if err != nil {
logs.Error(ctx, funcName, "查询过期房间失败", zap.Error(err))
}
return ids, err
}