// Package service 提供 admin 模块的业务服务(会议管理) package service import ( "context" "errors" "time" adminDAO "github.com/echochat/backend/app/admin/dao" "github.com/echochat/backend/app/dto" meetingModel "github.com/echochat/backend/app/meeting/model" "github.com/echochat/backend/pkg/logs" "go.uber.org/zap" ) // ErrMeetingNotFound 会议记录不存在 var ErrMeetingNotFound = errors.New("会议记录不存在") // MeetingManageService 管理端会议查询服务 // // 仅做"读"路径:列表 / 详情 / 统计。 // "强制结束会议" 这种写操作直接复用 meeting/service.MeetingService.EndRoom 即可(admin_force 路径), // 暂不在本服务里重复包装,避免与现有 RBAC/中间件冲突。 type MeetingManageService struct { dao *adminDAO.MeetingManageDAO } // NewMeetingManageService Wire Provider func NewMeetingManageService(dao *adminDAO.MeetingManageDAO) *MeetingManageService { return &MeetingManageService{dao: dao} } // ---------- public methods ---------- // GetMeetingList 查询会议列表(分页 + 多条件) func (s *MeetingManageService) GetMeetingList(ctx context.Context, req *dto.AdminMeetingListRequest) (*dto.AdminMeetingListResponse, error) { funcName := "service.meeting_manage_service.GetMeetingList" logs.Debug(ctx, funcName, "获取会议列表") if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } rows, total, err := s.dao.ListMeetings(ctx, req) if err != nil { return nil, err } list := make([]dto.AdminMeetingDTO, 0, len(rows)) for i := range rows { list = append(list, rowToDTO(&rows[i])) } return &dto.AdminMeetingListResponse{ Total: total, List: list, Page: req.Page, PageSize: req.PageSize, }, nil } // GetMeetingDetail 查询会议详情(含参与者 + 录制) func (s *MeetingManageService) GetMeetingDetail(ctx context.Context, id int64) (*dto.AdminMeetingDetailResponse, error) { funcName := "service.meeting_manage_service.GetMeetingDetail" logs.Debug(ctx, funcName, "获取会议详情", zap.Int64("id", id)) row, err := s.dao.GetMeetingByID(ctx, id) if err != nil { return nil, err } if row == nil { return nil, ErrMeetingNotFound } participants, err := s.dao.ListParticipantsDetail(ctx, row.ID) if err != nil { logs.Warn(ctx, funcName, "拉取参与者明细失败(继续返回详情)", zap.Int64("room_id", row.ID), zap.Error(err)) participants = nil } recordings, err := s.dao.ListRecordingsByRoom(ctx, row.ID) if err != nil { logs.Warn(ctx, funcName, "拉取录制列表失败(继续返回详情)", zap.Int64("room_id", row.ID), zap.Error(err)) recordings = nil } pDTOs := make([]dto.AdminMeetingParticipantDTO, 0, len(participants)) for i := range participants { pDTOs = append(pDTOs, participantToDTO(&participants[i])) } rDTOs := make([]dto.AdminMeetingRecordingDTO, 0, len(recordings)) for i := range recordings { rDTOs = append(rDTOs, recordingToDTO(&recordings[i])) } return &dto.AdminMeetingDetailResponse{ AdminMeetingDTO: rowToDTO(row), Participants: pDTOs, Recordings: rDTOs, }, nil } // GetStats 获取会议管理摘要统计(卡片) func (s *MeetingManageService) GetStats(ctx context.Context) (*dto.AdminMeetingStatsResponse, error) { funcName := "service.meeting_manage_service.GetStats" logs.Debug(ctx, funcName, "获取会议统计") total, err := s.dao.CountTotal(ctx) if err != nil { return nil, err } active, err := s.dao.CountActive(ctx) if err != nil { return nil, err } today, err := s.dao.CountToday(ctx) if err != nil { return nil, err } recordings, err := s.dao.CountRecordings(ctx) if err != nil { return nil, err } return &dto.AdminMeetingStatsResponse{ TotalCount: total, ActiveCount: active, TodayCount: today, RecordingCount: recordings, }, nil } // ---------- DTO 投影辅助 ---------- const datetimeLayout = "2006-01-02 15:04:05" // formatTimePtr 把 *time.Time 转成展示字符串;nil 返回空串 func formatTimePtr(t *time.Time) string { if t == nil { return "" } return t.Format(datetimeLayout) } // formatStrPtr 把 *string 转成普通 string;nil 返回空串 func formatStrPtr(s *string) string { if s == nil { return "" } return *s } // typeLabel 1=即时 2=预约 func typeLabel(t int) string { switch t { case 1: return "即时会议" case 2: return "预约会议" default: return "未知" } } // statusLabel 0=未开始 1=进行中 2=已结束(与 meeting_rooms.status 对齐) func statusLabel(s int) string { switch s { case 0: return "未开始" case 1: return "进行中" case 2: return "已结束" default: return "未知" } } // roleLabel 0=普通 1=主持人 2=联合主持人 func roleLabel(r int) string { switch r { case 1: return "主持人" case 2: return "联合主持人" default: return "参会人" } } // computeDuration 计算会议时长(秒) // - 已结束:ended_at - started_at // - 进行中:now - started_at // - 未开始:0 func computeDuration(row *adminDAO.MeetingListRow) int { if row.StartedAt == nil { return 0 } end := time.Now() if row.EndedAt != nil { end = *row.EndedAt } d := end.Sub(*row.StartedAt) if d < 0 { return 0 } return int(d.Seconds()) } func rowToDTO(row *adminDAO.MeetingListRow) dto.AdminMeetingDTO { return dto.AdminMeetingDTO{ ID: row.ID, RoomCode: row.RoomCode, Title: row.Title, Type: row.Type, TypeLabel: typeLabel(row.Type), Status: row.Status, StatusLabel: statusLabel(row.Status), HostID: row.HostID, HostNickname: row.HostNickname, HostAvatar: row.HostAvatar, MaxMembers: row.MaxMembers, ParticipantCount: row.ParticipantCount, RecordingCount: row.RecordingCount, DurationSec: computeDuration(row), ScheduledAt: formatTimePtr(row.ScheduledAt), StartedAt: formatTimePtr(row.StartedAt), EndedAt: formatTimePtr(row.EndedAt), EndedReason: formatStrPtr(row.EndedReason), CreatedAt: row.CreatedAt.Format(datetimeLayout), } } func participantToDTO(p *adminDAO.ParticipantDetailRow) dto.AdminMeetingParticipantDTO { return dto.AdminMeetingParticipantDTO{ UserID: p.UserID, Nickname: p.Nickname, Avatar: p.Avatar, Role: p.Role, RoleLabel: roleLabel(p.Role), JoinedAt: p.JoinedAt.Format(datetimeLayout), LeftAt: formatTimePtr(p.LeftAt), LeftReason: formatStrPtr(p.LeftReason), Duration: p.Duration, } } func recordingToDTO(r *meetingModel.MeetingRecording) dto.AdminMeetingRecordingDTO { out := dto.AdminMeetingRecordingDTO{ ID: r.ID, StartedBy: r.StartedBy, Status: r.Status, FileURL: r.FileURL, SizeBytes: r.SizeBytes, DurationSec: r.DurationSec, StartedAt: r.StartedAt.Format(datetimeLayout), StoppedAt: formatTimePtr(r.StoppedAt), } // 仅 failed 暴露 failure_reason;recording 状态下 FailureReason 字段被复用为本地路径,不应外泄 if r.Status == meetingModel.MeetingRecordingStatusFailed { out.FailureReason = r.FailureReason } return out }