主要产出:
- 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
183 lines
6.5 KiB
Go
183 lines
6.5 KiB
Go
// 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
|
||
}
|