视频会议保存
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Package controller 提供 admin 模块的 HTTP 入口(会议管理)
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/echochat/backend/app/admin/service"
|
||||
"github.com/echochat/backend/app/dto"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
"github.com/echochat/backend/pkg/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MeetingManageController 管理端会议管理 HTTP 控制器
|
||||
//
|
||||
// 路由(在 admin/router.go 中挂载到 /api/v1/admin/meetings 下,JWT + admin 角色):
|
||||
// - GET /api/v1/admin/meetings 分页列表 + 多条件筛选
|
||||
// - GET /api/v1/admin/meetings/stats 会议管理摘要统计
|
||||
// - GET /api/v1/admin/meetings/:id 会议详情(含参与者、录制列表)
|
||||
type MeetingManageController struct {
|
||||
svc *service.MeetingManageService
|
||||
}
|
||||
|
||||
// NewMeetingManageController Wire Provider
|
||||
func NewMeetingManageController(svc *service.MeetingManageService) *MeetingManageController {
|
||||
return &MeetingManageController{svc: svc}
|
||||
}
|
||||
|
||||
// GetMeetingList GET /api/v1/admin/meetings
|
||||
func (ctl *MeetingManageController) GetMeetingList(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req dto.AdminMeetingListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
utils.ResponseBadRequest(c, "参数格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := ctl.svc.GetMeetingList(ctx, &req)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, "获取会议列表失败")
|
||||
return
|
||||
}
|
||||
utils.ResponseSuccess(c, resp)
|
||||
}
|
||||
|
||||
// GetMeetingDetail GET /api/v1/admin/meetings/:id
|
||||
func (ctl *MeetingManageController) GetMeetingDetail(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
utils.ResponseBadRequest(c, "会议 ID 非法")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := ctl.svc.GetMeetingDetail(ctx, id)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, "获取会议详情失败")
|
||||
return
|
||||
}
|
||||
utils.ResponseSuccess(c, resp)
|
||||
}
|
||||
|
||||
// GetMeetingStats GET /api/v1/admin/meetings/stats
|
||||
func (ctl *MeetingManageController) GetMeetingStats(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
resp, err := ctl.svc.GetStats(ctx)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, "获取会议统计失败")
|
||||
return
|
||||
}
|
||||
utils.ResponseSuccess(c, resp)
|
||||
}
|
||||
|
||||
// handleError 统一业务错误映射
|
||||
func (ctl *MeetingManageController) handleError(c *gin.Context, err error, fallbackMsg string) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMeetingNotFound):
|
||||
utils.ResponseNotFound(c, err.Error())
|
||||
default:
|
||||
logs.Warn(c.Request.Context(), "controller.meeting_manage_controller.handleError",
|
||||
fallbackMsg, zap.Error(err))
|
||||
utils.ResponseError(c, fallbackMsg)
|
||||
}
|
||||
}
|
||||
286
backend/go-service/app/admin/dao/meeting_manage_dao.go
Normal file
286
backend/go-service/app/admin/dao/meeting_manage_dao.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// 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
|
||||
}
|
||||
@@ -23,4 +23,8 @@ var AdminSet = wire.NewSet(
|
||||
dao.NewMessageManageDAO,
|
||||
service.NewMessageManageService,
|
||||
controller.NewMessageManageController,
|
||||
// Phase B:会议管理(与录制审计共用一套接口)
|
||||
dao.NewMeetingManageDAO,
|
||||
service.NewMeetingManageService,
|
||||
controller.NewMeetingManageController,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ func RegisterRoutes(
|
||||
contactManageCtrl *controller.ContactManageController,
|
||||
groupManageCtrl *controller.GroupManageController,
|
||||
msgManageCtrl *controller.MessageManageController,
|
||||
meetingManageCtrl *controller.MeetingManageController,
|
||||
jwtAuth gin.HandlerFunc,
|
||||
) {
|
||||
// 管理端路由组:JWT 认证 + admin/super_admin 角色检查
|
||||
@@ -52,5 +53,11 @@ func RegisterRoutes(
|
||||
adminGroup.GET("/messages/:id", msgManageCtrl.GetMessageDetail)
|
||||
adminGroup.DELETE("/messages/:id", msgManageCtrl.DeleteMessage)
|
||||
adminGroup.PUT("/messages/:id/recall", msgManageCtrl.RecallMessage)
|
||||
|
||||
// 会议管理(Phase B:含录制审计)
|
||||
// 注意 /stats 必须放在 /:id 之前,否则会被 :id 路由吞掉(gin trie 优先级问题)
|
||||
adminGroup.GET("/meetings/stats", meetingManageCtrl.GetMeetingStats)
|
||||
adminGroup.GET("/meetings", meetingManageCtrl.GetMeetingList)
|
||||
adminGroup.GET("/meetings/:id", meetingManageCtrl.GetMeetingDetail)
|
||||
}
|
||||
}
|
||||
|
||||
268
backend/go-service/app/admin/service/meeting_manage_service.go
Normal file
268
backend/go-service/app/admin/service/meeting_manage_service.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user