// Package dao 提供 admin 模块的数据库访问操作(会议管理) package dao import ( "context" "errors" "fmt" "time" "github.com/echochat/backend/app/dto" meetingModel "github.com/echochat/backend/app/meeting/model" "github.com/echochat/backend/pkg/logs" "go.uber.org/zap" "gorm.io/gorm" ) // MeetingManageDAO 管理端会议数据访问对象 // // 设计要点: // - 列表查询走聚合 SQL:一次取 meeting_rooms + host 昵称 + 参与者数 + 录制数,避免 N+1 // - 仅做"查询/统计"层操作,写操作(结束会议、删除录制)放在 service 层并复用既有路径 // - 与 MessageManageDAO 保持一致的 ListXxx + 统计方法风格 type MeetingManageDAO struct { db *gorm.DB } // NewMeetingManageDAO 创建 MeetingManageDAO 实例 func NewMeetingManageDAO(db *gorm.DB) *MeetingManageDAO { return &MeetingManageDAO{db: db} } // MeetingListRow 列表行的聚合结果(DAO 内部结构,不外泄) // // 通过一次带 LEFT JOIN + GROUP BY 的查询拿齐: // - 主表 meeting_rooms 全部字段 // - 主持人昵称 / 头像(auth_users) // - 参与者数(meeting_participants 去重 user_id) // - 录制数(meeting_recordings 总条目) // // 注意:使用 GORM Raw + Scan,绕开 model 主键自动展开,方便聚合 type MeetingListRow struct { ID int64 `gorm:"column:id"` RoomCode string `gorm:"column:room_code"` Title string `gorm:"column:title"` Type int `gorm:"column:type"` Status int `gorm:"column:status"` HostID int64 `gorm:"column:host_id"` HostNickname string `gorm:"column:host_nickname"` HostAvatar string `gorm:"column:host_avatar"` MaxMembers int `gorm:"column:max_members"` ParticipantCount int `gorm:"column:participant_count"` RecordingCount int `gorm:"column:recording_count"` ScheduledAt *time.Time `gorm:"column:scheduled_at"` StartedAt *time.Time `gorm:"column:started_at"` EndedAt *time.Time `gorm:"column:ended_at"` EndedReason *string `gorm:"column:ended_reason"` CreatedAt time.Time `gorm:"column:created_at"` } // buildListWhere 把 AdminMeetingListRequest 拼成 WHERE 条件,返回 SQL 片段与参数 // 复用给 ListMeetings + CountMeetings 两个查询,保证条件一致 func (d *MeetingManageDAO) buildListWhere(req *dto.AdminMeetingListRequest) (string, []any) { conditions := []string{"1=1"} args := []any{} if req.Keyword != "" { conditions = append(conditions, "(mr.title ILIKE ? OR mr.room_code ILIKE ?)") kw := fmt.Sprintf("%%%s%%", req.Keyword) args = append(args, kw, kw) } if req.Status != nil { conditions = append(conditions, "mr.status = ?") args = append(args, *req.Status) } if req.HostID != nil { conditions = append(conditions, "mr.host_id = ?") args = append(args, *req.HostID) } if req.StartTime != "" { if t, err := time.Parse("2006-01-02", req.StartTime); err == nil { conditions = append(conditions, "mr.created_at >= ?") args = append(args, t) } } if req.EndTime != "" { if t, err := time.Parse("2006-01-02", req.EndTime); err == nil { conditions = append(conditions, "mr.created_at < ?") args = append(args, t.AddDate(0, 0, 1)) } } // HasRecording 通过 HAVING 在外层处理,这里不拼接 where := "" for i, c := range conditions { if i == 0 { where = c } else { where = where + " AND " + c } } return where, args } // ListMeetings 分页查询会议列表 // // 聚合 SQL 设计: // - LEFT JOIN auth_users:拿主持人昵称(host 可能已删除时为空,不影响行) // - LEFT JOIN meeting_participants:聚合参与者数(COUNT DISTINCT user_id) // - LEFT JOIN meeting_recordings:聚合录制数(COUNT recording.id) // - HasRecording 通过 HAVING recording_count > 0 二次过滤 // // 排序:created_at DESC(最新会议在前) func (d *MeetingManageDAO) ListMeetings(ctx context.Context, req *dto.AdminMeetingListRequest) ([]MeetingListRow, int64, error) { funcName := "dao.meeting_manage_dao.ListMeetings" logs.Debug(ctx, funcName, "查询会议列表") where, args := d.buildListWhere(req) page := req.Page if page <= 0 { page = 1 } pageSize := req.PageSize if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } // 子查询聚合 participant_count / recording_count,避免笛卡尔积放大 COUNT // 这里用相关子查询而非 JOIN+GROUP BY,便于 HAVING 与 COUNT(*) 分页对齐 baseSelect := ` SELECT mr.id, mr.room_code, mr.title, mr.type, mr.status, mr.host_id, COALESCE(u.nickname, '') AS host_nickname, COALESCE(u.avatar, '') AS host_avatar, mr.max_members, COALESCE((SELECT COUNT(DISTINCT mp.user_id) FROM meeting_participants mp WHERE mp.room_id = mr.id), 0) AS participant_count, COALESCE((SELECT COUNT(*) FROM meeting_recordings mrec WHERE mrec.room_id = mr.id), 0) AS recording_count, mr.scheduled_at, mr.started_at, mr.ended_at, mr.ended_reason, mr.created_at FROM meeting_rooms mr LEFT JOIN auth_users u ON u.id = mr.host_id WHERE ` + where // HasRecording 过滤:包成外层 SELECT * FROM (...) WHERE recording_count > 0 listSQL := baseSelect countSQL := `SELECT COUNT(*) FROM (` + baseSelect + `) AS sub` if req.HasRecording != nil && *req.HasRecording { listSQL = `SELECT * FROM (` + baseSelect + `) AS sub WHERE recording_count > 0` countSQL = `SELECT COUNT(*) FROM (` + listSQL + `) AS sub2` } listSQL += ` ORDER BY created_at DESC OFFSET ? LIMIT ?` var total int64 if err := d.db.WithContext(ctx).Raw(countSQL, args...).Scan(&total).Error; err != nil { logs.Error(ctx, funcName, "统计会议总数失败", zap.Error(err)) return nil, 0, err } pageArgs := append([]any{}, args...) pageArgs = append(pageArgs, (page-1)*pageSize, pageSize) var rows []MeetingListRow if err := d.db.WithContext(ctx).Raw(listSQL, pageArgs...).Scan(&rows).Error; err != nil { logs.Error(ctx, funcName, "查询会议列表失败", zap.Error(err)) return nil, 0, err } return rows, total, nil } // GetMeetingByID 取单条会议聚合行(含主持人昵称 + 聚合计数) // 供详情接口复用 MeetingListRow 投影,避免重复定义结构 func (d *MeetingManageDAO) GetMeetingByID(ctx context.Context, id int64) (*MeetingListRow, error) { funcName := "dao.meeting_manage_dao.GetMeetingByID" sql := ` SELECT mr.id, mr.room_code, mr.title, mr.type, mr.status, mr.host_id, COALESCE(u.nickname, '') AS host_nickname, COALESCE(u.avatar, '') AS host_avatar, mr.max_members, COALESCE((SELECT COUNT(DISTINCT mp.user_id) FROM meeting_participants mp WHERE mp.room_id = mr.id), 0) AS participant_count, COALESCE((SELECT COUNT(*) FROM meeting_recordings mrec WHERE mrec.room_id = mr.id), 0) AS recording_count, mr.scheduled_at, mr.started_at, mr.ended_at, mr.ended_reason, mr.created_at FROM meeting_rooms mr LEFT JOIN auth_users u ON u.id = mr.host_id WHERE mr.id = ? LIMIT 1 ` var row MeetingListRow err := d.db.WithContext(ctx).Raw(sql, id).Scan(&row).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } logs.Error(ctx, funcName, "查询会议详情失败", zap.Int64("id", id), zap.Error(err)) return nil, err } if row.ID == 0 { return nil, nil } return &row, nil } // ListParticipantsDetail 拉取会议的全部参与者(带用户昵称/头像) // // 按 joined_at ASC 排序,主持人先入会后退出时也按时间顺序展示,便于核对入会轨迹 func (d *MeetingManageDAO) ListParticipantsDetail(ctx context.Context, roomID int64) ([]ParticipantDetailRow, error) { funcName := "dao.meeting_manage_dao.ListParticipantsDetail" sql := ` SELECT mp.user_id, mp.role, mp.joined_at, mp.left_at, mp.left_reason, mp.duration, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar_url, '') AS avatar FROM meeting_participants mp LEFT JOIN auth_users u ON u.id = mp.user_id WHERE mp.room_id = ? ORDER BY mp.joined_at ASC, mp.id ASC ` var rows []ParticipantDetailRow if err := d.db.WithContext(ctx).Raw(sql, roomID).Scan(&rows).Error; err != nil { logs.Error(ctx, funcName, "查询会议参与者失败", zap.Int64("room_id", roomID), zap.Error(err)) return nil, err } return rows, nil } // ParticipantDetailRow 参与者明细投影 type ParticipantDetailRow struct { UserID int64 `gorm:"column:user_id"` Role int `gorm:"column:role"` JoinedAt time.Time `gorm:"column:joined_at"` LeftAt *time.Time `gorm:"column:left_at"` LeftReason *string `gorm:"column:left_reason"` Duration int `gorm:"column:duration"` Nickname string `gorm:"column:nickname"` Avatar string `gorm:"column:avatar"` } // ListRecordingsByRoom 拉取会议下的全部录制(admin 视角,按 started_at DESC) // // 复用 meeting/model.MeetingRecording,不另起 DTO;service 层再投影成 AdminMeetingRecordingDTO func (d *MeetingManageDAO) ListRecordingsByRoom(ctx context.Context, roomID int64) ([]meetingModel.MeetingRecording, error) { var list []meetingModel.MeetingRecording err := d.db.WithContext(ctx). Where("room_id = ?", roomID). Order("started_at DESC, id DESC"). Find(&list).Error return list, err } // CountTotal 历史会议总数 func (d *MeetingManageDAO) CountTotal(ctx context.Context) (int64, error) { var count int64 err := d.db.WithContext(ctx).Model(&meetingModel.MeetingRoom{}).Count(&count).Error return count, err } // CountActive 当前进行中(status=1)的会议数 func (d *MeetingManageDAO) CountActive(ctx context.Context) (int64, error) { var count int64 err := d.db.WithContext(ctx). Model(&meetingModel.MeetingRoom{}). Where("status = ?", 1). Count(&count).Error return count, err } // CountToday 今日创建的会议数(按 created_at) func (d *MeetingManageDAO) CountToday(ctx context.Context) (int64, error) { var count int64 today := time.Now().Truncate(24 * time.Hour) err := d.db.WithContext(ctx). Model(&meetingModel.MeetingRoom{}). Where("created_at >= ?", today). Count(&count).Error return count, err } // CountRecordings 录制总数 func (d *MeetingManageDAO) CountRecordings(ctx context.Context) (int64, error) { var count int64 err := d.db.WithContext(ctx).Model(&meetingModel.MeetingRecording{}).Count(&count).Error return count, err }