覆盖 Phase 2e-2 代码审查报告(docs/reviews/2026-04-23-phase2e-2-code-review.md) P2/Nit 收尾批次,均在本仓库完成闭环;剩余 5 项登记推迟至 Phase 2f/3。 ===== P2 七项 ===== - P2-1 cleanupUserResources 补 transport 清理 · media-server 新增 DELETE /internal/v1/transports/:id + transport.service.closeTransport · Go MediaOrchestrator 接口新增 CloseTransport;HTTP 实现按 doCloseRequest 走 4xx 幂等 + 指数退避 · meeting_signal_service.cleanupUserResources 新增 "transport:<id>" 分支 - P2-2 preview.vue 快速切设备竞态 · previewSeq 序号 + nextTick 后 stale 判断,丢弃过期结果 · onVideoChange/onAudioChange 走 scheduleRestartPreview 200ms 防抖 · onBeforeUnmount 清理 changeDebounceTimer - P2-3 room.vue onLoad redirectTo 后补 return · 引入 redirectingToJoin 守卫,跳转页不再执行 onMounted 初始化 - P2-6 generateUniqueRoomCode 重试上限监控 · 重试后成功:Warn 日志(码空间健康度告警) · 重试耗尽:Error 日志 + ErrRoomCodeConflict · 修正 logs.Error 调用签名(去掉多余的 nil) - P2-7 SendChatMessage 服务端长度 + 频率限制 · 新增 ErrChatContentEmpty / ErrChatContentTooLong / ErrChatRateLimited · utf8.RuneCountInString 校验 500 字符上限 · Redis INCR + EXPIRE 滑动窗口(30 条/60s,首次写入 EXPIRE 兜底) · controller.handleError 映射为 HTTP 400 - P2-8 MEETING_ENDED_REASON_LABEL 覆盖复核 · 新增前端专属常量 MEETING_ENDED_REASON_KICKED + 文案 · store/meeting.js _onMemberKicked 使用常量 · 同步修复后端 OnWSDisconnect 硬编码 "ws_disconnect" → constants.MeetingLeftReasonDisconnect - P2 已修 P2-1/2/3/6/7/8;P2-4(WS token 迁出 URL query)与 P2-5(Chat 服务拆分)登记推迟 ===== Nit 七项 ===== - Nit: kind:id 解析改用 strings.SplitN · cleanupUserResources / pushExistingRoomState 两处同步 - Nit: resourceTTL 中央化 · 新增 constants.MeetingResourceTrackTTLSeconds(3600) · meeting_signal_service.go resourceTTL 由 const 改 var 并引用常量 - Nit: ws/handler.go CheckOrigin 白名单 · NewHandler 新增 serverCfg 依赖;checkOrigin 支持同源放行 / dev 模式放行 / release 模式白名单严格匹配 · config.ServerConfig 新增 WSAllowedOrigins(逗号分隔)+ AllowedOrigins() / IsRelease() 辅助方法 · provider.go 新增 provideServerConfig,wire_gen.go 同步 - Nit: http_media_orchestrator.go 超时 + CreateRouter 重试 · 默认 TimeoutMS 5000→10000ms 兼容 Worker 冷启动 · 新增 CreateRouterRetry(默认 1 次,300ms 退避),仅对非 404 错误重试 · config.dev.yaml / config.docker.yaml 同步写入显式配置 - Nit: deploy-public.sh REDIS_PASSWORD × redis.conf 联动校验 · 检测 REDIS_PASSWORD 与 redis.conf 的 requirepass 配对一致性 · redis.conf 增加公网部署 requirepass 使用说明 - Nit: media-server internal-auth isPrivatePath 按 path 匹配 · 剔除 query/hash 后再与白名单 startsWith,避免 "?" 语义混淆 - Nit: mediasoup-client.js in-flight 锁走读确认 · finally 分支已覆盖 resolve/reject 两路,追加注释强化语义 - Nit 走读复核:_onMemberLeft 整槽关闭 vs _onProducerNew(closed=true) 精确匹配 producerId · 粒度正确,无需改动(登记结论) ===== 构建验证 ===== - go vet ./... / go build ./... 通过 - frontend npm run build:h5 通过(仅 uni-app legacy warning,无 error) - media-server npx tsc --noEmit 通过 ===== 审查追踪小节 ===== docs/reviews/2026-04-23-phase2e-2-code-review.md 追加 "Task 16 修复追踪(2026-04-24 更新)": - 已修复一览(本批次 14 处 + 历次 commitcdaa39d/ea2bf96/f5ae095/ 5ed14c2) - 推迟登记表(P2-4 / P2-5 / 端口收敛 / appData 校验 / RFC3339 时间格式,共 5 项) Made-with: Cursor
461 lines
14 KiB
Go
461 lines
14 KiB
Go
// Package controller 提供 meeting 模块的 HTTP 接口
|
||
package controller
|
||
|
||
import (
|
||
"errors"
|
||
"strconv"
|
||
|
||
"github.com/echochat/backend/app/constants"
|
||
"github.com/echochat/backend/app/dto"
|
||
"github.com/echochat/backend/app/meeting/model"
|
||
"github.com/echochat/backend/app/meeting/service"
|
||
"github.com/echochat/backend/pkg/logs"
|
||
"github.com/echochat/backend/pkg/middleware"
|
||
"github.com/echochat/backend/pkg/utils"
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// MeetingController 会议 REST 控制器
|
||
// Task 5 完成:填充 12 个接口的请求解析、业务调用、DTO 转换、错误码映射
|
||
type MeetingController struct {
|
||
meetingService *service.MeetingService
|
||
}
|
||
|
||
// NewMeetingController 创建 MeetingController 实例
|
||
func NewMeetingController(meetingService *service.MeetingService) *MeetingController {
|
||
return &MeetingController{meetingService: meetingService}
|
||
}
|
||
|
||
// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false
|
||
func requireUserID(c *gin.Context) (int64, bool) {
|
||
userID, ok := middleware.GetCurrentUserID(c)
|
||
if !ok {
|
||
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||
return 0, false
|
||
}
|
||
return userID, true
|
||
}
|
||
|
||
// handleError 将 service 层领域错误统一映射为 HTTP 响应
|
||
// 映射原则:
|
||
// - 资源不存在 → 404
|
||
// - 权限不足(非 host)→ 403
|
||
// - 业务规则违反(密码错误、已满、状态冲突等)→ 400
|
||
// - 其余未知错误 → 500
|
||
func (ctl *MeetingController) handleError(c *gin.Context, err error, fallbackMsg string) {
|
||
switch {
|
||
case errors.Is(err, service.ErrMeetingNotFound):
|
||
utils.ResponseNotFound(c, err.Error())
|
||
case errors.Is(err, service.ErrNotMeetingHost):
|
||
utils.ResponseForbidden(c, err.Error())
|
||
case errors.Is(err, service.ErrMediaServiceUnavailable):
|
||
// P0-3:媒体服务不可用应作为 5xx 返回,让前端走"稍后重试"提示,而非按"用户输入错误"处理
|
||
utils.ResponseError(c, err.Error())
|
||
case errors.Is(err, service.ErrMeetingEnded),
|
||
errors.Is(err, service.ErrMeetingFull),
|
||
errors.Is(err, service.ErrMeetingPasswordReq),
|
||
errors.Is(err, service.ErrMeetingPasswordWrong),
|
||
errors.Is(err, service.ErrMeetingPasswordLocked),
|
||
errors.Is(err, service.ErrNotInMeeting),
|
||
errors.Is(err, service.ErrAlreadyInMeeting),
|
||
errors.Is(err, service.ErrAlreadyInOtherMeeting),
|
||
errors.Is(err, service.ErrInviteTokenInvalid),
|
||
errors.Is(err, service.ErrRoomCodeConflict),
|
||
errors.Is(err, service.ErrKickSelfForbidden),
|
||
errors.Is(err, service.ErrTransferToSelf),
|
||
errors.Is(err, service.ErrTransferTargetInvalid),
|
||
// P2-7 会议聊天服务端校验失败:内容为空 / 超长 / 触发限流
|
||
// 当前使用 400(由 ResponseBadRequest 返回),保持与其余业务错误一致;
|
||
// 未来若需要精细区分(如 ErrChatRateLimited → 429、ErrChatContentTooLong → 413),可拆分分支
|
||
errors.Is(err, service.ErrChatContentEmpty),
|
||
errors.Is(err, service.ErrChatContentTooLong),
|
||
errors.Is(err, service.ErrChatRateLimited):
|
||
utils.ResponseBadRequest(c, err.Error())
|
||
default:
|
||
logs.Warn(c.Request.Context(), "controller.meeting_controller.handleError",
|
||
fallbackMsg, zap.Error(err))
|
||
utils.ResponseError(c, fallbackMsg)
|
||
}
|
||
}
|
||
|
||
// ====== DTO 转换 ======
|
||
|
||
// roomToDTO 将 model.MeetingRoom 转 DTO,host_name/avatar 交由上层按需补全
|
||
func roomToDTO(r *model.MeetingRoom, onlineCount int) *dto.MeetingRoomDTO {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
out := &dto.MeetingRoomDTO{
|
||
ID: r.ID,
|
||
RoomCode: r.RoomCode,
|
||
Title: r.Title,
|
||
HostID: r.HostID,
|
||
Type: r.Type,
|
||
HasPassword: r.PasswordHash != nil && *r.PasswordHash != "",
|
||
MaxMembers: r.MaxMembers,
|
||
Status: r.Status,
|
||
StatusLabel: constants.MeetingStatusMap[r.Status],
|
||
Settings: r.Settings,
|
||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
OnlineCount: onlineCount,
|
||
EndedReason: r.EndedReason,
|
||
}
|
||
if r.ScheduledAt != nil {
|
||
out.ScheduledAt = r.ScheduledAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
if r.StartedAt != nil {
|
||
out.StartedAt = r.StartedAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
if r.EndedAt != nil {
|
||
out.EndedAt = r.EndedAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// participantToDTO 将 model.MeetingParticipant 转 DTO
|
||
// userMap 可选:若提供则附带 user_name / user_avatar,便于前端 MemberPanel/VideoTile 直接渲染昵称头像
|
||
func participantToDTO(p *model.MeetingParticipant, userMap map[int64]service.UserDisplayInfo) *dto.MeetingParticipantDTO {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
out := &dto.MeetingParticipantDTO{
|
||
ID: p.ID,
|
||
RoomID: p.RoomID,
|
||
UserID: p.UserID,
|
||
Role: p.Role,
|
||
RoleLabel: constants.MeetingRoleMap[p.Role],
|
||
JoinedAt: p.JoinedAt.Format("2006-01-02 15:04:05"),
|
||
LeftReason: p.LeftReason,
|
||
Duration: p.Duration,
|
||
IsActive: p.IsActive(),
|
||
}
|
||
if p.LeftAt != nil {
|
||
out.LeftAt = p.LeftAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
if info, ok := userMap[p.UserID]; ok {
|
||
out.UserName = info.Name
|
||
out.UserAvatar = info.Avatar
|
||
}
|
||
return out
|
||
}
|
||
|
||
// chatToDTO 将 model.MeetingChat 转 DTO
|
||
// userMap 可选:若提供则附带 user_name / user_avatar,否则为空
|
||
func chatToDTO(m *model.MeetingChat, userMap map[int64]service.UserDisplayInfo) *dto.MeetingChatDTO {
|
||
if m == nil {
|
||
return nil
|
||
}
|
||
d := &dto.MeetingChatDTO{
|
||
ID: m.ID,
|
||
RoomID: m.RoomID,
|
||
UserID: m.UserID,
|
||
Content: m.Content,
|
||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
}
|
||
if info, ok := userMap[m.UserID]; ok {
|
||
d.UserName = info.Name
|
||
d.UserAvatar = info.Avatar
|
||
}
|
||
return d
|
||
}
|
||
|
||
// ====== REST API ======
|
||
|
||
// CreateRoom POST /api/v1/meeting/rooms
|
||
func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req dto.CreateMeetingRoomRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
room, _, routerID, err := ctl.meetingService.CreateRoom(c.Request.Context(), userID, &req)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "创建会议失败")
|
||
return
|
||
}
|
||
// Task 9:CreateRoom 响应携带 router_id + rtpCapabilities,供前端 Device.load 使用
|
||
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(room.RoomCode)
|
||
resp := dto.CreateMeetingRoomResponse{
|
||
Room: *roomToDTO(room, 1),
|
||
RouterID: routerID,
|
||
RtpCapabilities: rtpCaps,
|
||
}
|
||
utils.ResponseCreated(c, resp)
|
||
}
|
||
|
||
// GetRoom GET /api/v1/meeting/rooms/:code
|
||
func (ctl *MeetingController) GetRoom(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
room, participants, onlineCount, err := ctl.meetingService.GetRoomByCode(c.Request.Context(), userID, code)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "获取会议详情失败")
|
||
return
|
||
}
|
||
userIDs := make([]int64, 0, len(participants))
|
||
for i := range participants {
|
||
userIDs = append(userIDs, participants[i].UserID)
|
||
}
|
||
userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs)
|
||
|
||
parts := make([]dto.MeetingParticipantDTO, 0, len(participants))
|
||
for i := range participants {
|
||
parts = append(parts, *participantToDTO(&participants[i], userMap))
|
||
}
|
||
resp := dto.GetMeetingRoomResponse{
|
||
Room: *roomToDTO(room, int(onlineCount)),
|
||
Participants: parts,
|
||
OnlineCount: int(onlineCount),
|
||
}
|
||
utils.ResponseOK(c, resp)
|
||
}
|
||
|
||
// JoinRoom POST /api/v1/meeting/rooms/:code/join
|
||
func (ctl *MeetingController) JoinRoom(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
var req dto.JoinMeetingRoomRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
room, participant, routerID, err := ctl.meetingService.JoinRoom(c.Request.Context(), userID, code, req.Password)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "加入会议失败")
|
||
return
|
||
}
|
||
// Task 9:JoinRoom 响应携带 rtpCapabilities,供前端 mediasoup-client Device.load 直接使用
|
||
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(code)
|
||
userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{participant.UserID})
|
||
resp := dto.JoinMeetingRoomResponse{
|
||
Room: *roomToDTO(room, 0),
|
||
Participant: *participantToDTO(participant, userMap),
|
||
RouterID: routerID,
|
||
RtpCapabilities: rtpCaps,
|
||
}
|
||
utils.ResponseOK(c, resp)
|
||
}
|
||
|
||
// LeaveRoom POST /api/v1/meeting/rooms/:code/leave
|
||
func (ctl *MeetingController) LeaveRoom(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
duration, err := ctl.meetingService.LeaveRoom(c.Request.Context(), userID, code)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "离开会议失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, dto.LeaveMeetingRoomResponse{Duration: duration})
|
||
}
|
||
|
||
// EndRoom POST /api/v1/meeting/rooms/:code/end
|
||
func (ctl *MeetingController) EndRoom(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
if err := ctl.meetingService.EndRoom(c.Request.Context(), userID, code); err != nil {
|
||
ctl.handleError(c, err, "结束会议失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, gin.H{})
|
||
}
|
||
|
||
// ListMyMeetings GET /api/v1/meeting/rooms/mine?status=&before_id=&limit=
|
||
func (ctl *MeetingController) ListMyMeetings(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req dto.ListMyMeetingsRequest
|
||
if err := c.ShouldBindQuery(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
rooms, hasMore, err := ctl.meetingService.ListMyMeetings(c.Request.Context(), userID, req.Status, req.BeforeID, req.Limit)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "获取会议列表失败")
|
||
return
|
||
}
|
||
list := make([]dto.MeetingRoomDTO, 0, len(rooms))
|
||
for i := range rooms {
|
||
list = append(list, *roomToDTO(&rooms[i], 0))
|
||
}
|
||
utils.ResponseOK(c, dto.ListMyMeetingsResponse{List: list, HasMore: hasMore})
|
||
}
|
||
|
||
// TransferHost POST /api/v1/meeting/rooms/:code/transfer-host
|
||
func (ctl *MeetingController) TransferHost(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
var req dto.TransferHostRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
if err := ctl.meetingService.TransferHost(c.Request.Context(), userID, code, req.TargetUserID); err != nil {
|
||
ctl.handleError(c, err, "转让主持人失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, gin.H{})
|
||
}
|
||
|
||
// KickMember POST /api/v1/meeting/rooms/:code/kick
|
||
func (ctl *MeetingController) KickMember(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
var req dto.KickMemberRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
if err := ctl.meetingService.KickMember(c.Request.Context(), userID, code, req.UserID); err != nil {
|
||
ctl.handleError(c, err, "踢出成员失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, gin.H{})
|
||
}
|
||
|
||
// InviteUsers POST /api/v1/meeting/rooms/:code/invite
|
||
func (ctl *MeetingController) InviteUsers(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
var req dto.InviteUsersRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
pushed, skipped, err := ctl.meetingService.InviteUsers(c.Request.Context(), userID, code, req.InviteeIDs)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "发送邀请失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, dto.InviteUsersResponse{Pushed: pushed, Skipped: skipped})
|
||
}
|
||
|
||
// RedeemInvite POST /api/v1/meeting/invite-tokens/:token/redeem
|
||
func (ctl *MeetingController) RedeemInvite(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
token := c.Param("token")
|
||
if token == "" {
|
||
utils.ResponseBadRequest(c, "邀请 Token 不能为空")
|
||
return
|
||
}
|
||
resp, err := ctl.meetingService.RedeemInviteToken(c.Request.Context(), userID, token)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "兑换邀请链接失败")
|
||
return
|
||
}
|
||
utils.ResponseOK(c, resp)
|
||
}
|
||
|
||
// SendChat POST /api/v1/meeting/rooms/:code/chats
|
||
func (ctl *MeetingController) SendChat(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
var req dto.SendMeetingChatRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||
return
|
||
}
|
||
msg, err := ctl.meetingService.SendChatMessage(c.Request.Context(), userID, code, req.Content)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "发送会议聊天失败")
|
||
return
|
||
}
|
||
userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{msg.UserID})
|
||
utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg, userMap)})
|
||
}
|
||
|
||
// ListChats GET /api/v1/meeting/rooms/:code/chats?before_id=&limit=
|
||
func (ctl *MeetingController) ListChats(c *gin.Context) {
|
||
userID, ok := requireUserID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
code := c.Param("code")
|
||
if code == "" {
|
||
utils.ResponseBadRequest(c, "会议号不能为空")
|
||
return
|
||
}
|
||
beforeID, _ := strconv.ParseInt(c.Query("before_id"), 10, 64)
|
||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||
msgs, hasMore, err := ctl.meetingService.ListChatMessages(c.Request.Context(), userID, code, beforeID, limit)
|
||
if err != nil {
|
||
ctl.handleError(c, err, "获取会议聊天失败")
|
||
return
|
||
}
|
||
userIDs := make([]int64, 0, len(msgs))
|
||
for i := range msgs {
|
||
userIDs = append(userIDs, msgs[i].UserID)
|
||
}
|
||
userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs)
|
||
list := make([]dto.MeetingChatDTO, 0, len(msgs))
|
||
for i := range msgs {
|
||
list = append(list, *chatToDTO(&msgs[i], userMap))
|
||
}
|
||
utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore})
|
||
}
|