feat(phase2e-2): Go meeting 模块 12 个 REST 接口业务逻辑全量落地(Task 5)
主要产出:
- 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
This commit is contained in:
168
backend/go-service/app/dto/meeting_dto.go
Normal file
168
backend/go-service/app/dto/meeting_dto.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package dto
|
||||
|
||||
// ====== 会议模块基础 DTO(Phase 2e-2)======
|
||||
|
||||
// MeetingRoomDTO 会议房间传输对象
|
||||
// 用于 CreateRoom / GetRoom / JoinRoom / ListMyMeetings 的响应体
|
||||
// 字段与 model.MeetingRoom 对齐,敏感字段(password_hash)不对外暴露
|
||||
type MeetingRoomDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
RoomCode string `json:"room_code"`
|
||||
Title string `json:"title"`
|
||||
HostID int64 `json:"host_id"`
|
||||
HostName string `json:"host_name,omitempty"`
|
||||
HostAvatar string `json:"host_avatar,omitempty"`
|
||||
Type int `json:"type"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
MaxMembers int `json:"max_members"`
|
||||
Status int `json:"status"`
|
||||
StatusLabel string `json:"status_label,omitempty"`
|
||||
ScheduledAt string `json:"scheduled_at,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
EndedAt string `json:"ended_at,omitempty"`
|
||||
EndedReason *string `json:"ended_reason,omitempty"`
|
||||
Settings string `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
OnlineCount int `json:"online_count"`
|
||||
}
|
||||
|
||||
// MeetingParticipantDTO 会议参与者传输对象
|
||||
// 用于 GetRoom 的成员列表响应;活跃用户(left_at=NULL)在前
|
||||
type MeetingParticipantDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
RoomID int64 `json:"room_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
UserAvatar string `json:"user_avatar,omitempty"`
|
||||
Role int `json:"role"`
|
||||
RoleLabel string `json:"role_label,omitempty"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
LeftAt string `json:"left_at,omitempty"`
|
||||
LeftReason *string `json:"left_reason,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// MeetingChatDTO 会议内聊天消息传输对象
|
||||
// 用于 SendChat 的响应 + ListChats 的列表项
|
||||
type MeetingChatDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
RoomID int64 `json:"room_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
UserAvatar string `json:"user_avatar,omitempty"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ====== REST API 请求 / 响应 ======
|
||||
|
||||
// CreateMeetingRoomRequest 创建即时会议请求
|
||||
// POST /api/v1/meeting/rooms
|
||||
type CreateMeetingRoomRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题
|
||||
Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储)
|
||||
MaxMembers int `json:"max_members" binding:"omitempty,min=2,max=8"` // 可选上限(MVP 硬上限 8,留参数为 Phase 2e-3 扩展)
|
||||
}
|
||||
|
||||
// CreateMeetingRoomResponse 创建会议响应
|
||||
type CreateMeetingRoomResponse struct {
|
||||
Room MeetingRoomDTO `json:"room"`
|
||||
}
|
||||
|
||||
// JoinMeetingRoomRequest 加入会议请求
|
||||
// POST /api/v1/meeting/rooms/:code/join
|
||||
type JoinMeetingRoomRequest struct {
|
||||
Password string `json:"password" binding:"omitempty,max=20"` // 入会密码(如房间设密则必传)
|
||||
}
|
||||
|
||||
// JoinMeetingRoomResponse 加入会议响应
|
||||
type JoinMeetingRoomResponse struct {
|
||||
Room MeetingRoomDTO `json:"room"`
|
||||
Participant MeetingParticipantDTO `json:"participant"`
|
||||
RouterID string `json:"router_id,omitempty"` // mediasoup Router ID(Task 7 接入 Node 后填充)
|
||||
}
|
||||
|
||||
// LeaveMeetingRoomResponse 离开会议响应
|
||||
type LeaveMeetingRoomResponse struct {
|
||||
Duration int `json:"duration"` // 本次参会时长(秒)
|
||||
}
|
||||
|
||||
// GetMeetingRoomResponse 会议详情响应
|
||||
// GET /api/v1/meeting/rooms/:code
|
||||
type GetMeetingRoomResponse struct {
|
||||
Room MeetingRoomDTO `json:"room"`
|
||||
Participants []MeetingParticipantDTO `json:"participants"`
|
||||
OnlineCount int `json:"online_count"`
|
||||
}
|
||||
|
||||
// ListMyMeetingsRequest 我的会议列表查询参数
|
||||
// GET /api/v1/meeting/rooms/mine
|
||||
type ListMyMeetingsRequest struct {
|
||||
Status *int `form:"status"` // 状态过滤:nil=全部,0/1/2
|
||||
BeforeID int64 `form:"before_id"` // 游标分页
|
||||
Limit int `form:"limit"` // 页大小,默认 20,最大 50
|
||||
}
|
||||
|
||||
// ListMyMeetingsResponse 我的会议列表响应
|
||||
type ListMyMeetingsResponse struct {
|
||||
List []MeetingRoomDTO `json:"list"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// InviteUsersRequest 邀请用户请求
|
||||
// POST /api/v1/meeting/rooms/:code/invite
|
||||
type InviteUsersRequest struct {
|
||||
InviteeIDs []int64 `json:"invitee_ids" binding:"required,min=1,max=50,dive,gt=0"` // 被邀请用户 ID 数组(去重后)
|
||||
}
|
||||
|
||||
// InviteUsersResponse 邀请结果响应
|
||||
type InviteUsersResponse struct {
|
||||
Pushed int `json:"pushed"` // 成功发送邀请通知的数量(在线+离线都算)
|
||||
Skipped int `json:"skipped"` // 跳过的数量(已在会中 / 重复 ID 等)
|
||||
}
|
||||
|
||||
// KickMemberRequest 踢人请求
|
||||
// POST /api/v1/meeting/rooms/:code/kick
|
||||
type KickMemberRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
// TransferHostRequest 主持人转让请求
|
||||
// POST /api/v1/meeting/rooms/:code/transfer-host
|
||||
type TransferHostRequest struct {
|
||||
TargetUserID int64 `json:"target_user_id" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
// SendMeetingChatRequest 发送会议内聊天请求
|
||||
// POST /api/v1/meeting/rooms/:code/chats
|
||||
type SendMeetingChatRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=500"`
|
||||
}
|
||||
|
||||
// SendMeetingChatResponse 发送聊天响应
|
||||
type SendMeetingChatResponse struct {
|
||||
Message MeetingChatDTO `json:"message"`
|
||||
}
|
||||
|
||||
// ListMeetingChatsRequest 会议内聊天列表查询参数
|
||||
// GET /api/v1/meeting/rooms/:code/chats
|
||||
type ListMeetingChatsRequest struct {
|
||||
BeforeID int64 `form:"before_id"` // 游标分页
|
||||
Limit int `form:"limit"` // 页大小,默认 30,最大 100
|
||||
}
|
||||
|
||||
// ListMeetingChatsResponse 会议内聊天列表响应
|
||||
type ListMeetingChatsResponse struct {
|
||||
List []MeetingChatDTO `json:"list"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// RedeemInviteTokenResponse 邀请 Token 兑换响应
|
||||
// POST /api/v1/meeting/invite-tokens/:token/redeem
|
||||
// 兑换成功仅返回会议号 + 邀请人,前端自行调 JoinRoom 入会
|
||||
type RedeemInviteTokenResponse struct {
|
||||
RoomCode string `json:"room_code"`
|
||||
InviterID int64 `json:"inviter_id"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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 4 骨架阶段:所有 handler 均返回 501 Not Implemented,便于路由自测与前端 mock
|
||||
// Task 5/6/7 将在此填充请求解析、业务调用与错误映射
|
||||
// Task 5 完成:填充 12 个接口的请求解析、业务调用、DTO 转换、错误码映射
|
||||
type MeetingController struct {
|
||||
meetingService *service.MeetingService
|
||||
}
|
||||
@@ -22,15 +27,6 @@ func NewMeetingController(meetingService *service.MeetingService) *MeetingContro
|
||||
return &MeetingController{meetingService: meetingService}
|
||||
}
|
||||
|
||||
// responseNotImplemented Task 4 占位响应,Task 5/6/7 逐接口替换为真实实现
|
||||
func responseNotImplemented(c *gin.Context, endpoint string) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"code": http.StatusNotImplemented,
|
||||
"message": "接口尚未实现",
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false
|
||||
func requireUserID(c *gin.Context) (int64, bool) {
|
||||
userID, ok := middleware.GetCurrentUserID(c)
|
||||
@@ -41,98 +37,385 @@ func requireUserID(c *gin.Context) (int64, bool) {
|
||||
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.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):
|
||||
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
|
||||
func participantToDTO(p *model.MeetingParticipant) *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")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatToDTO 将 model.MeetingChat 转 DTO
|
||||
func chatToDTO(m *model.MeetingChat) *dto.MeetingChatDTO {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.MeetingChatDTO{
|
||||
ID: m.ID,
|
||||
RoomID: m.RoomID,
|
||||
UserID: m.UserID,
|
||||
Content: m.Content,
|
||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
// ====== REST API ======
|
||||
|
||||
// CreateRoom POST /api/v1/meeting/rooms
|
||||
func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms")
|
||||
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
|
||||
}
|
||||
resp := dto.CreateMeetingRoomResponse{
|
||||
Room: *roomToDTO(room, 1),
|
||||
}
|
||||
_ = routerID // 当前 Noop 返回占位 RouterID,Task 7 后可拼入响应供前端订阅使用
|
||||
utils.ResponseCreated(c, resp)
|
||||
}
|
||||
|
||||
// GetRoom GET /api/v1/meeting/rooms/:code
|
||||
func (ctl *MeetingController) GetRoom(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code")
|
||||
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
|
||||
}
|
||||
parts := make([]dto.MeetingParticipantDTO, 0, len(participants))
|
||||
for i := range participants {
|
||||
parts = append(parts, *participantToDTO(&participants[i]))
|
||||
}
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/join")
|
||||
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
|
||||
}
|
||||
resp := dto.JoinMeetingRoomResponse{
|
||||
Room: *roomToDTO(room, 0),
|
||||
Participant: *participantToDTO(participant),
|
||||
RouterID: routerID,
|
||||
}
|
||||
utils.ResponseOK(c, resp)
|
||||
}
|
||||
|
||||
// LeaveRoom POST /api/v1/meeting/rooms/:code/leave
|
||||
func (ctl *MeetingController) LeaveRoom(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/leave")
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/end")
|
||||
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?role=host|participant
|
||||
// ListMyMeetings GET /api/v1/meeting/rooms/mine?status=&before_id=&limit=
|
||||
func (ctl *MeetingController) ListMyMeetings(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "GET /api/v1/meeting/rooms")
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/transfer-host")
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/kick")
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/invite")
|
||||
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/invites/:token/redeem
|
||||
// RedeemInvite POST /api/v1/meeting/invite-tokens/:token/redeem
|
||||
func (ctl *MeetingController) RedeemInvite(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/invites/:token/redeem")
|
||||
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) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/chats")
|
||||
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
|
||||
}
|
||||
utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg)})
|
||||
}
|
||||
|
||||
// ListChats GET /api/v1/meeting/rooms/:code/chats?after_id=&limit=
|
||||
// ListChats GET /api/v1/meeting/rooms/:code/chats?before_id=&limit=
|
||||
func (ctl *MeetingController) ListChats(c *gin.Context) {
|
||||
if _, ok := requireUserID(c); !ok {
|
||||
userID, ok := requireUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code/chats")
|
||||
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
|
||||
}
|
||||
list := make([]dto.MeetingChatDTO, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
list = append(list, *chatToDTO(&msgs[i]))
|
||||
}
|
||||
utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore})
|
||||
}
|
||||
|
||||
@@ -143,6 +143,9 @@ func (d *MeetingParticipantDAO) GetByRoomAndUser(ctx context.Context, roomID, us
|
||||
Where("room_id = ? AND user_id = ?", roomID, userID).
|
||||
First(&p).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
@@ -196,6 +199,9 @@ func (d *MeetingParticipantDAO) FindActiveByUser(ctx context.Context, userID int
|
||||
userID, constants.MeetingStatusEnded).
|
||||
First(&p).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
|
||||
@@ -39,27 +39,32 @@ func (d *MeetingRoomDAO) Create(ctx context.Context, room *model.MeetingRoom) er
|
||||
}
|
||||
|
||||
// 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 按会议号查询(入会流程的主要入口)
|
||||
// 返回 gorm.ErrRecordNotFound 时上层应转换为业务错误 ErrMeetingNotFound
|
||||
// 记录不存在时返回 (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) {
|
||||
logs.Error(ctx, funcName, "按会议号查询房间失败",
|
||||
zap.String("room_code", code), zap.Error(err))
|
||||
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
|
||||
|
||||
@@ -18,4 +18,8 @@ var MeetingSet = wire.NewSet(
|
||||
dao.NewMeetingChatDAO,
|
||||
service.NewMeetingService,
|
||||
controller.NewMeetingController,
|
||||
|
||||
// MediaOrchestrator 目前使用 Noop 实现(Task 7 将替换为 node_client.NodeClient)
|
||||
service.NewNoopMediaOrchestrator,
|
||||
wire.Bind(new(service.MediaOrchestrator), new(*service.NoopMediaOrchestrator)),
|
||||
)
|
||||
|
||||
@@ -15,21 +15,18 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth g
|
||||
{
|
||||
// 会议房间生命周期
|
||||
authed.POST("/rooms", ctrl.CreateRoom)
|
||||
authed.GET("/rooms", ctrl.ListMyMeetings)
|
||||
authed.GET("/rooms/mine", ctrl.ListMyMeetings)
|
||||
authed.GET("/rooms/:code", ctrl.GetRoom)
|
||||
authed.POST("/rooms/:code/join", ctrl.JoinRoom)
|
||||
authed.POST("/rooms/:code/leave", ctrl.LeaveRoom)
|
||||
authed.POST("/rooms/:code/end", ctrl.EndRoom)
|
||||
|
||||
// 主持人四件套(Task 7)
|
||||
authed.POST("/rooms/:code/transfer-host", ctrl.TransferHost)
|
||||
authed.POST("/rooms/:code/kick", ctrl.KickMember)
|
||||
|
||||
// 邀请(Task 5)
|
||||
authed.POST("/rooms/:code/invite", ctrl.InviteUsers)
|
||||
authed.POST("/invites/:token/redeem", ctrl.RedeemInvite)
|
||||
authed.POST("/invite-tokens/:token/redeem", ctrl.RedeemInvite)
|
||||
|
||||
// 会议内聊天(Task 6)
|
||||
authed.POST("/rooms/:code/chats", ctrl.SendChat)
|
||||
authed.GET("/rooms/:code/chats", ctrl.ListChats)
|
||||
}
|
||||
|
||||
@@ -29,3 +29,38 @@ type UserInfoResolver interface {
|
||||
type OnlineChecker interface {
|
||||
IsOnline(ctx context.Context, userID int64) bool
|
||||
}
|
||||
|
||||
// MediaOrchestrator 媒体服务器编排接口
|
||||
// Task 7 落地 Go → Node media-server HTTP Client 后由 node_client.NodeClient 实现
|
||||
// Task 5 阶段使用 NoopMediaOrchestrator 占位,仅返回假的 RouterID,不产生真实媒体资源
|
||||
// 语义:会议房间在 Go 侧落库后,通过该接口驱动 Node 端 mediasoup Router 创建与销毁
|
||||
type MediaOrchestrator interface {
|
||||
// CreateRouter 为会议房间创建 mediasoup Router
|
||||
// 入参:room_code 作为 Node 端聚合键;返回 Router ID 与推荐编解码参数(Task 7 对接时补充)
|
||||
CreateRouter(ctx context.Context, roomCode string) (routerID string, err error)
|
||||
|
||||
// CloseRouter 关闭会议房间对应的 mediasoup Router 及其下所有 transport/producer/consumer
|
||||
// 幂等:重复关闭不返回错误
|
||||
CloseRouter(ctx context.Context, roomCode string) error
|
||||
}
|
||||
|
||||
// NoopMediaOrchestrator 占位实现:Task 5 完成生命周期接口时使用
|
||||
// 返回伪造的 RouterID,所有操作仅写日志不调用 Node
|
||||
// Task 7 完成后全局 wire 切换到真实 NodeClient 实现
|
||||
type NoopMediaOrchestrator struct{}
|
||||
|
||||
// NewNoopMediaOrchestrator 构造占位的 MediaOrchestrator
|
||||
func NewNoopMediaOrchestrator() *NoopMediaOrchestrator {
|
||||
return &NoopMediaOrchestrator{}
|
||||
}
|
||||
|
||||
// CreateRouter 返回以 "noop-router-" 为前缀的伪造 RouterID
|
||||
// 调用方可据此区分真实 / 占位实现,便于调试与切换
|
||||
func (n *NoopMediaOrchestrator) CreateRouter(_ context.Context, roomCode string) (string, error) {
|
||||
return "noop-router-" + roomCode, nil
|
||||
}
|
||||
|
||||
// CloseRouter 占位实现:直接返回 nil
|
||||
func (n *NoopMediaOrchestrator) CloseRouter(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
// Package service 提供 meeting 模块的业务逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/echochat/backend/app/constants"
|
||||
"github.com/echochat/backend/app/dto"
|
||||
"github.com/echochat/backend/app/meeting/dao"
|
||||
"github.com/echochat/backend/app/meeting/model"
|
||||
notifyService "github.com/echochat/backend/app/notify/service"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
"github.com/echochat/backend/pkg/utils"
|
||||
"github.com/echochat/backend/pkg/ws"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Task 5 会继续填充具体业务逻辑时使用的哨兵错误,此处先集中定义占位
|
||||
// 命名与 group/notify 模块的 Err* 风格一致,控制器可直接 errors.Is 识别
|
||||
// Meeting 模块领域错误
|
||||
// 命名与 group/notify 模块的 Err* 风格一致,Controller 通过 errors.Is 识别后映射 HTTP 状态码
|
||||
var (
|
||||
ErrMeetingNotFound = errors.New("会议不存在")
|
||||
ErrMeetingEnded = errors.New("会议已结束")
|
||||
ErrMeetingFull = errors.New("会议已满员")
|
||||
ErrMeetingPasswordWrong = errors.New("会议密码错误")
|
||||
ErrNotInMeeting = errors.New("你不在此会议中")
|
||||
ErrMeetingNotFound = errors.New("会议不存在")
|
||||
ErrMeetingEnded = errors.New("会议已结束")
|
||||
ErrMeetingFull = errors.New("会议已满员")
|
||||
ErrMeetingPasswordWrong = errors.New("会议密码错误")
|
||||
ErrMeetingPasswordLocked = errors.New("密码连续错误次数过多,请稍后重试")
|
||||
ErrMeetingPasswordReq = errors.New("此会议需要密码")
|
||||
ErrNotInMeeting = errors.New("你不在此会议中")
|
||||
ErrAlreadyInMeeting = errors.New("你已在此会议中")
|
||||
ErrAlreadyInOtherMeeting = errors.New("你当前已在其他会议中")
|
||||
ErrNotMeetingHost = errors.New("仅主持人可执行此操作")
|
||||
ErrInviteTokenInvalid = errors.New("邀请链接已失效")
|
||||
ErrRoomCodeConflict = errors.New("会议号生成冲突,请重试")
|
||||
ErrNotImplemented = errors.New("功能尚未实现")
|
||||
ErrNotMeetingHost = errors.New("仅主持人可执行此操作")
|
||||
ErrInviteTokenInvalid = errors.New("邀请链接已失效")
|
||||
ErrRoomCodeConflict = errors.New("会议号生成冲突,请稍后重试")
|
||||
ErrKickSelfForbidden = errors.New("不能踢出自己")
|
||||
ErrTransferToSelf = errors.New("不能将主持人转让给自己")
|
||||
ErrTransferTargetInvalid = errors.New("目标用户不在会议中")
|
||||
)
|
||||
|
||||
// Redis key 前缀(设计文档 §5.4 - Redis 数据结构)
|
||||
const (
|
||||
redisKeyInvitePrefix = "echo:meeting:invite:" // 邀请 Token
|
||||
redisKeyPasswordLockPrefix = "echo:meeting:lock:" // 密码错误锁(code:user_id)
|
||||
redisPasswordAttemptPrefix = "echo:meeting:pwd_attempt:" // 密码错误计数
|
||||
)
|
||||
|
||||
// MeetingService 会议业务服务
|
||||
// Task 4 骨架阶段:仅完成依赖组装和方法签名占位,具体业务逻辑留待 Task 5/6/7 填充
|
||||
// 方法返回 ErrNotImplemented,Controller 将其映射为 501 响应,便于 Postman 与前端联调前观察路由完整性
|
||||
// Task 5 完成:会议生命周期、主持人管理、邀请、会议内聊天 12 个 REST API 全部落地
|
||||
// Task 6 会在此基础上追加 WS 信令事件处理器;Task 7 会把 mediaOrchestrator 的 Noop 实现替换为真实 Node HTTP Client
|
||||
type MeetingService struct {
|
||||
roomDAO *dao.MeetingRoomDAO
|
||||
participantDAO *dao.MeetingParticipantDAO
|
||||
@@ -38,13 +61,14 @@ type MeetingService struct {
|
||||
redis *redis.Client
|
||||
pubsub *ws.PubSub
|
||||
|
||||
notifyPusher NotifyPusher
|
||||
userResolver UserInfoResolver
|
||||
onlineChecker OnlineChecker
|
||||
notifyPusher NotifyPusher
|
||||
userResolver UserInfoResolver
|
||||
onlineChecker OnlineChecker
|
||||
mediaOrchestrator MediaOrchestrator
|
||||
}
|
||||
|
||||
// NewMeetingService 创建 MeetingService 实例
|
||||
// 依赖通过构造函数注入,接口依赖由上游 Wire 绑定到具体实现
|
||||
// 依赖通过构造函数注入;接口依赖由上游 Wire 绑定到具体实现(Task 7 之前 mediaOrchestrator 使用 NoopMediaOrchestrator)
|
||||
func NewMeetingService(
|
||||
roomDAO *dao.MeetingRoomDAO,
|
||||
participantDAO *dao.MeetingParticipantDAO,
|
||||
@@ -55,90 +79,752 @@ func NewMeetingService(
|
||||
notifyPusher NotifyPusher,
|
||||
userResolver UserInfoResolver,
|
||||
onlineChecker OnlineChecker,
|
||||
mediaOrchestrator MediaOrchestrator,
|
||||
) *MeetingService {
|
||||
return &MeetingService{
|
||||
roomDAO: roomDAO,
|
||||
participantDAO: participantDAO,
|
||||
chatDAO: chatDAO,
|
||||
db: db,
|
||||
redis: redis,
|
||||
pubsub: pubsub,
|
||||
notifyPusher: notifyPusher,
|
||||
userResolver: userResolver,
|
||||
onlineChecker: onlineChecker,
|
||||
roomDAO: roomDAO,
|
||||
participantDAO: participantDAO,
|
||||
chatDAO: chatDAO,
|
||||
db: db,
|
||||
redis: redis,
|
||||
pubsub: pubsub,
|
||||
notifyPusher: notifyPusher,
|
||||
userResolver: userResolver,
|
||||
onlineChecker: onlineChecker,
|
||||
mediaOrchestrator: mediaOrchestrator,
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 会议生命周期(Task 5 实现) ======
|
||||
// ====== 辅助:鉴权 / 会议号 / 广播 ======
|
||||
|
||||
// CreateRoom 创建会议房间
|
||||
// Task 5:生成 room_code、bcrypt 密码、持久化 + 写 Redis room 快照
|
||||
func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, title, password string, maxMembers int, settings map[string]interface{}) (*model.MeetingRoom, error) {
|
||||
return nil, ErrNotImplemented
|
||||
// assertIsActiveParticipant 确认用户是会议的活跃参会者;返回其 participant 记录供调用方复用
|
||||
func (s *MeetingService) assertIsActiveParticipant(ctx context.Context, roomID, userID int64) (*model.MeetingParticipant, error) {
|
||||
p, err := s.participantDAO.GetByRoomAndUser(ctx, roomID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil || !p.IsActive() {
|
||||
return nil, ErrNotInMeeting
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetRoomByCode 通过会议号查询房间详情(含当前成员列表)
|
||||
func (s *MeetingService) GetRoomByCode(ctx context.Context, code string) (*model.MeetingRoom, error) {
|
||||
return nil, ErrNotImplemented
|
||||
// assertIsHost 确认用户是会议当前主持人
|
||||
func (s *MeetingService) assertIsHost(ctx context.Context, room *model.MeetingRoom, userID int64) error {
|
||||
if room == nil {
|
||||
return ErrMeetingNotFound
|
||||
}
|
||||
if room.HostID != userID {
|
||||
return ErrNotMeetingHost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinRoom 用户加入会议(校验密码 / 容量 / 单点参会)
|
||||
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingParticipant, error) {
|
||||
return nil, ErrNotImplemented
|
||||
// generateUniqueRoomCode 生成唯一的 XXX-XXX-XXX 会议号,冲突最多重试 MeetingRoomCodeRetryMax 次
|
||||
func (s *MeetingService) generateUniqueRoomCode(ctx context.Context) (string, error) {
|
||||
for i := 0; i < constants.MeetingRoomCodeRetryMax; i++ {
|
||||
code, err := utils.GenerateMeetingRoomCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exists, err := s.roomDAO.ExistsCode(ctx, code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", ErrRoomCodeConflict
|
||||
}
|
||||
|
||||
// broadcastToActiveParticipants 向房间内所有活跃参会者广播 WS 事件
|
||||
// 调用 PubSub.Publish 支持多实例;可选排除发送者自身(excludeUserIDs)
|
||||
// 该辅助作为 Task 6 BroadcastToMeeting 的临时等效实现,签名保持兼容便于后续替换
|
||||
func (s *MeetingService) broadcastToActiveParticipants(ctx context.Context, roomID int64, event string, data interface{}, excludeUserIDs ...int64) {
|
||||
funcName := "service.meeting_service.broadcastToActiveParticipants"
|
||||
participants, err := s.participantDAO.ListActiveByRoom(ctx, roomID)
|
||||
if err != nil {
|
||||
logs.Warn(ctx, funcName, "拉取活跃参会者失败", zap.Int64("room_id", roomID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
exclude := make(map[int64]struct{}, len(excludeUserIDs))
|
||||
for _, id := range excludeUserIDs {
|
||||
exclude[id] = struct{}{}
|
||||
}
|
||||
msg := ws.NewPushMessage(event, data)
|
||||
for _, p := range participants {
|
||||
if _, skip := exclude[p.UserID]; skip {
|
||||
continue
|
||||
}
|
||||
if err := s.pubsub.PublishToUser(ctx, p.UserID, msg); err != nil {
|
||||
logs.Warn(ctx, funcName, "WS 广播失败", zap.Int64("user_id", p.UserID), zap.String("event", event), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 会议生命周期 ======
|
||||
|
||||
// CreateRoom 创建即时会议
|
||||
// 流程:生成唯一会议号 → bcrypt 密码 → 写入 meeting_rooms(status=Active + started_at=now)→ 主持人落 participant 表 → 驱动 mediasoup Router 创建
|
||||
func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
|
||||
funcName := "service.meeting_service.CreateRoom"
|
||||
|
||||
var err error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
logs.Warn(ctx, funcName, "创建会议失败", zap.Int64("host_id", hostID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
active, err := s.participantDAO.FindActiveByUser(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if active != nil {
|
||||
err = ErrAlreadyInOtherMeeting
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
code, err := s.generateUniqueRoomCode(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
var passwordHash *string
|
||||
if req.Password != "" {
|
||||
hash, hErr := utils.HashPassword(req.Password)
|
||||
if hErr != nil {
|
||||
err = fmt.Errorf("密码哈希失败: %w", hErr)
|
||||
return nil, nil, "", err
|
||||
}
|
||||
passwordHash = &hash
|
||||
}
|
||||
|
||||
maxMembers := req.MaxMembers
|
||||
if maxMembers <= 0 || maxMembers > constants.MeetingMVPMaxMembers {
|
||||
maxMembers = constants.MeetingMVPMaxMembers
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
room := &model.MeetingRoom{
|
||||
RoomCode: code,
|
||||
Title: req.Title,
|
||||
HostID: hostID,
|
||||
Type: constants.MeetingTypeInstant,
|
||||
PasswordHash: passwordHash,
|
||||
MaxMembers: maxMembers,
|
||||
Status: constants.MeetingStatusActive,
|
||||
StartedAt: &now,
|
||||
Settings: "{}",
|
||||
}
|
||||
if err = s.roomDAO.Create(ctx, room); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, hostID, constants.MeetingRoleHost)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
routerID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
|
||||
if mediaErr != nil {
|
||||
logs.Warn(ctx, funcName, "mediasoup Router 创建失败(Task 7 前为占位实现,不影响流程)",
|
||||
zap.String("room_code", code), zap.Error(mediaErr))
|
||||
routerID = ""
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "会议创建成功",
|
||||
zap.String("room_code", code), zap.Int64("host_id", hostID), zap.String("router_id", routerID))
|
||||
return room, participant, routerID, nil
|
||||
}
|
||||
|
||||
// GetRoomByCode 获取会议详情(当前用户必须为活跃参会者)
|
||||
func (s *MeetingService) GetRoomByCode(ctx context.Context, userID int64, code string) (*model.MeetingRoom, []model.MeetingParticipant, int64, error) {
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
if room == nil {
|
||||
return nil, nil, 0, ErrMeetingNotFound
|
||||
}
|
||||
if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
participants, err := s.participantDAO.ListByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
return room, participants, activeCount, nil
|
||||
}
|
||||
|
||||
// JoinRoom 加入会议
|
||||
// 校验顺序:房间存在 → 未结束 → 单点参会 → 密码锁定 → 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined
|
||||
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
|
||||
funcName := "service.meeting_service.JoinRoom"
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if room == nil {
|
||||
return nil, nil, "", ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return nil, nil, "", ErrMeetingEnded
|
||||
}
|
||||
|
||||
if existing, pErr := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID); pErr != nil {
|
||||
return nil, nil, "", pErr
|
||||
} else if existing != nil && existing.IsActive() {
|
||||
return nil, nil, "", ErrAlreadyInMeeting
|
||||
}
|
||||
|
||||
if active, aErr := s.participantDAO.FindActiveByUser(ctx, userID); aErr != nil {
|
||||
return nil, nil, "", aErr
|
||||
} else if active != nil && active.RoomID != room.ID {
|
||||
return nil, nil, "", ErrAlreadyInOtherMeeting
|
||||
}
|
||||
|
||||
if room.PasswordHash != nil && *room.PasswordHash != "" {
|
||||
lockKey := redisKeyPasswordLockPrefix + code + ":" + strconv.FormatInt(userID, 10)
|
||||
if locked, _ := s.redis.Exists(ctx, lockKey).Result(); locked > 0 {
|
||||
return nil, nil, "", ErrMeetingPasswordLocked
|
||||
}
|
||||
if password == "" {
|
||||
return nil, nil, "", ErrMeetingPasswordReq
|
||||
}
|
||||
if !utils.CheckPassword(password, *room.PasswordHash) {
|
||||
attemptKey := redisPasswordAttemptPrefix + code + ":" + strconv.FormatInt(userID, 10)
|
||||
attempts, _ := s.redis.Incr(ctx, attemptKey).Result()
|
||||
if attempts == 1 {
|
||||
s.redis.Expire(ctx, attemptKey, time.Duration(constants.MeetingPasswordLockSeconds)*time.Second)
|
||||
}
|
||||
if attempts >= int64(constants.MeetingPasswordMaxAttempts) {
|
||||
s.redis.Set(ctx, lockKey, 1, time.Duration(constants.MeetingPasswordLockSeconds)*time.Second)
|
||||
s.redis.Del(ctx, attemptKey)
|
||||
}
|
||||
return nil, nil, "", ErrMeetingPasswordWrong
|
||||
}
|
||||
s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if int(activeCount) >= room.MaxMembers {
|
||||
return nil, nil, "", ErrMeetingFull
|
||||
}
|
||||
|
||||
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
routerID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
|
||||
if mediaErr != nil {
|
||||
logs.Warn(ctx, funcName, "mediasoup Router 创建失败(占位实现)", zap.String("room_code", code), zap.Error(mediaErr))
|
||||
routerID = ""
|
||||
}
|
||||
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventMemberJoined, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"user_id": userID,
|
||||
"joined_at": participant.JoinedAt.Format("2006-01-02 15:04:05"),
|
||||
}, userID)
|
||||
|
||||
logs.Info(ctx, funcName, "用户加入会议成功", zap.String("room_code", code), zap.Int64("user_id", userID))
|
||||
return room, participant, routerID, nil
|
||||
}
|
||||
|
||||
// LeaveRoom 用户主动离会
|
||||
// 若离开者是 host 且房间内还有其他成员,触发主持人转让(最早加入者接任)
|
||||
// 若离开后房间空,设置 Redis room TTL=300s 等待重入复用
|
||||
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) error {
|
||||
return ErrNotImplemented
|
||||
// 若离开者为 host 且房间内还有其他活跃成员:事务内转让给最早加入者;若为空房:关闭房间(status=Ended, reason=empty_ttl)
|
||||
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) (int, error) {
|
||||
funcName := "service.meeting_service.LeaveRoom"
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if room == nil {
|
||||
return 0, ErrMeetingNotFound
|
||||
}
|
||||
participant, err := s.assertIsActiveParticipant(ctx, room.ID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
affected, err := s.participantDAO.LeaveRoom(ctx, room.ID, userID, constants.MeetingLeftReasonSelf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return 0, ErrNotInMeeting
|
||||
}
|
||||
|
||||
updated, err := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID)
|
||||
duration := 0
|
||||
if err == nil && updated != nil {
|
||||
duration = updated.Duration
|
||||
}
|
||||
|
||||
actives, err := s.participantDAO.ListActiveByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
return duration, err
|
||||
}
|
||||
|
||||
if participant.Role == constants.MeetingRoleHost && len(actives) > 0 {
|
||||
newHost := actives[0]
|
||||
if txErr := s.participantDAO.TransferHost(ctx, room.ID, userID, newHost.UserID); txErr != nil {
|
||||
logs.Warn(ctx, funcName, "主持人自动转让失败", zap.Error(txErr))
|
||||
} else {
|
||||
if uErr := s.roomDAO.UpdateHost(ctx, room.ID, newHost.UserID); uErr != nil {
|
||||
logs.Warn(ctx, funcName, "UpdateHost 失败", zap.Error(uErr))
|
||||
}
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventRoomHostChange, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"old_host_id": userID,
|
||||
"new_host_id": newHost.UserID,
|
||||
"auto_reason": "host_left",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(actives) == 0 {
|
||||
_, _ = s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonEmptyTTL, time.Now())
|
||||
_ = s.mediaOrchestrator.CloseRouter(ctx, code)
|
||||
}
|
||||
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventMemberLeft, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"user_id": userID,
|
||||
"reason": constants.MeetingLeftReasonSelf,
|
||||
}, userID)
|
||||
|
||||
logs.Info(ctx, funcName, "用户离会成功",
|
||||
zap.String("room_code", code), zap.Int64("user_id", userID), zap.Int("duration", duration))
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// EndRoom 主持人主动结束会议(status=ended + 全员 left_at + 触发 mediasoup 清理)
|
||||
// EndRoom 主持人结束会议
|
||||
// 将房间 status=Ended + reason=host_ended + 所有活跃成员 LeaveAllActive + 广播 meeting.room.ended + 关闭 mediasoup Router
|
||||
func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string) error {
|
||||
return ErrNotImplemented
|
||||
funcName := "service.meeting_service.EndRoom"
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if room == nil {
|
||||
return ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return ErrMeetingEnded
|
||||
}
|
||||
if err := s.assertIsHost(ctx, room, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activesBefore, _ := s.participantDAO.ListActiveByRoom(ctx, room.ID)
|
||||
|
||||
now := time.Now()
|
||||
if _, err := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonHostEnded, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.participantDAO.LeaveAllActive(ctx, room.ID, constants.MeetingLeftReasonHostEnd); err != nil {
|
||||
logs.Warn(ctx, funcName, "批量离会失败", zap.Int64("room_id", room.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"room_code": code,
|
||||
"ended_reason": constants.MeetingEndedReasonHostEnded,
|
||||
"ended_at": now.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
msg := ws.NewPushMessage(constants.MeetingWSEventRoomEnded, payload)
|
||||
for _, p := range activesBefore {
|
||||
if err := s.pubsub.PublishToUser(ctx, p.UserID, msg); err != nil {
|
||||
logs.Warn(ctx, funcName, "meeting.room.ended 广播失败", zap.Int64("user_id", p.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.mediaOrchestrator.CloseRouter(ctx, code); err != nil {
|
||||
logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err))
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "会议已结束", zap.String("room_code", code), zap.Int64("host_id", userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransferHost 主持人转让(仅当前 host 可调用)
|
||||
func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, newHostID int64) error {
|
||||
return ErrNotImplemented
|
||||
// ====== 主持人管理 ======
|
||||
|
||||
// TransferHost 主持人主动转让
|
||||
func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, targetUserID int64) error {
|
||||
funcName := "service.meeting_service.TransferHost"
|
||||
|
||||
if operatorID == targetUserID {
|
||||
return ErrTransferToSelf
|
||||
}
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if room == nil {
|
||||
return ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return ErrMeetingEnded
|
||||
}
|
||||
if err := s.assertIsHost(ctx, room, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := s.assertIsActiveParticipant(ctx, room.ID, targetUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotInMeeting) {
|
||||
return ErrTransferTargetInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.participantDAO.TransferHost(ctx, room.ID, operatorID, target.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.roomDAO.UpdateHost(ctx, room.ID, target.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventRoomHostChange, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"old_host_id": operatorID,
|
||||
"new_host_id": target.UserID,
|
||||
"auto_reason": "manual",
|
||||
})
|
||||
|
||||
logs.Info(ctx, funcName, "主持人转让成功",
|
||||
zap.String("room_code", code), zap.Int64("old_host", operatorID), zap.Int64("new_host", target.UserID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// KickMember 主持人踢出成员
|
||||
func (s *MeetingService) KickMember(ctx context.Context, operatorID int64, code string, targetUserID int64) error {
|
||||
return ErrNotImplemented
|
||||
funcName := "service.meeting_service.KickMember"
|
||||
|
||||
if operatorID == targetUserID {
|
||||
return ErrKickSelfForbidden
|
||||
}
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if room == nil {
|
||||
return ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return ErrMeetingEnded
|
||||
}
|
||||
if err := s.assertIsHost(ctx, room, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.assertIsActiveParticipant(ctx, room.ID, targetUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected, err := s.participantDAO.LeaveRoom(ctx, room.ID, targetUserID, constants.MeetingLeftReasonKicked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotInMeeting
|
||||
}
|
||||
|
||||
kickMsg := ws.NewPushMessage(constants.MeetingWSEventMemberKicked, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"user_id": targetUserID,
|
||||
"by": operatorID,
|
||||
})
|
||||
_ = s.pubsub.PublishToUser(ctx, targetUserID, kickMsg)
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventMemberLeft, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"user_id": targetUserID,
|
||||
"reason": constants.MeetingLeftReasonKicked,
|
||||
}, targetUserID)
|
||||
|
||||
logs.Info(ctx, funcName, "踢出成员成功",
|
||||
zap.String("room_code", code), zap.Int64("target_user_id", targetUserID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMyMeetings 我主持的 / 我参与的会议列表(含分页)
|
||||
func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, role string, offset, limit int) ([]model.MeetingRoom, int64, error) {
|
||||
return nil, 0, ErrNotImplemented
|
||||
// ListMyMeetings 我参与过的会议列表(包含主持)
|
||||
// 基于 MeetingParticipantDAO.ListByUser 获取参会记录后批量查询对应 Room
|
||||
// MVP 场景下数据量小(单用户 30 天内会议通常 <50 条),内存合并与状态过滤可接受
|
||||
// 后续 Phase 2f 观察量级后若有必要再落 DAO 层 JOIN 优化
|
||||
func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, statusFilter *int, beforeID int64, limit int) ([]model.MeetingRoom, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
// 多取一条用于判断 has_more;考虑到可能按 status 过滤,适度放大拉取倍数
|
||||
fetchSize := (limit + 1) * 2
|
||||
parts, _, err := s.participantDAO.ListByUser(ctx, userID, 0, fetchSize*3)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return []model.MeetingRoom{}, false, nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(parts))
|
||||
roomIDs := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if _, ok := seen[p.RoomID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p.RoomID] = struct{}{}
|
||||
roomIDs = append(roomIDs, p.RoomID)
|
||||
}
|
||||
|
||||
var rooms []model.MeetingRoom
|
||||
q := s.db.WithContext(ctx).Model(&model.MeetingRoom{}).Where("id IN ?", roomIDs)
|
||||
if statusFilter != nil {
|
||||
q = q.Where("status = ?", *statusFilter)
|
||||
}
|
||||
if beforeID > 0 {
|
||||
q = q.Where("id < ?", beforeID)
|
||||
}
|
||||
if err := q.Order("created_at DESC, id DESC").Limit(limit + 1).Find(&rooms).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
hasMore := false
|
||||
if len(rooms) > limit {
|
||||
hasMore = true
|
||||
rooms = rooms[:limit]
|
||||
}
|
||||
return rooms, hasMore, nil
|
||||
}
|
||||
|
||||
// ====== 邀请链接(Task 5) ======
|
||||
// ====== 邀请链接与邀请推送 ======
|
||||
|
||||
// CreateInviteToken 生成邀请链接 Token 并写 Redis(TTL 600s)
|
||||
func (s *MeetingService) CreateInviteToken(ctx context.Context, inviterID int64, code string, inviteeID int64) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
// invitePayload 存入 Redis 的邀请 Token 载荷
|
||||
type invitePayload struct {
|
||||
RoomCode string `json:"room_code"`
|
||||
InviterID int64 `json:"inviter_id"`
|
||||
InviteeID int64 `json:"invitee_id"` // 0 表示通用链接(当前 MVP 不用)
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// RedeemInviteToken 点击邀请链接时兑换 Token(校验 + 删除)
|
||||
func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
// InviteUsers 主持人或参会者邀请用户
|
||||
// 对每个 invitee 生成独立 Token 写 Redis(TTL 600s),并通过 NotifyPusher 推送 meeting_invite 通知
|
||||
// 离线用户走通知入库(NotifyService 内部负责 WS 推送或未读补偿)
|
||||
func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64) (int, int, error) {
|
||||
funcName := "service.meeting_service.InviteUsers"
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if room == nil {
|
||||
return 0, 0, ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return 0, 0, ErrMeetingEnded
|
||||
}
|
||||
if _, err := s.assertIsActiveParticipant(ctx, room.ID, inviterID); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
pushed := 0
|
||||
skipped := 0
|
||||
seen := make(map[int64]struct{}, len(inviteeIDs))
|
||||
payloads := make([]*notifyService.PushPayload, 0, len(inviteeIDs))
|
||||
|
||||
for _, invitee := range inviteeIDs {
|
||||
if invitee <= 0 || invitee == inviterID {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[invitee]; dup {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen[invitee] = struct{}{}
|
||||
|
||||
if p, _ := s.participantDAO.GetByRoomAndUser(ctx, room.ID, invitee); p != nil && p.IsActive() {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
token, err := utils.GenerateMeetingInviteToken()
|
||||
if err != nil {
|
||||
logs.Warn(ctx, funcName, "生成邀请 Token 失败", zap.Error(err))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
payload := invitePayload{
|
||||
RoomCode: code,
|
||||
InviterID: inviterID,
|
||||
InviteeID: invitee,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
buf, _ := json.Marshal(payload)
|
||||
if err := s.redis.Set(ctx, redisKeyInvitePrefix+token, string(buf),
|
||||
time.Duration(constants.MeetingInviteTokenTTL)*time.Second).Err(); err != nil {
|
||||
logs.Warn(ctx, funcName, "写入邀请 Token 到 Redis 失败", zap.Error(err))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
roomID := room.ID
|
||||
actor := inviterID
|
||||
extra := map[string]interface{}{
|
||||
"room_code": code,
|
||||
"invite_token": token,
|
||||
"room_title": room.Title,
|
||||
"has_password": room.PasswordHash != nil,
|
||||
}
|
||||
payloads = append(payloads, ¬ifyService.PushPayload{
|
||||
UserID: invitee,
|
||||
Type: constants.NotifyTypeMeetingInvite,
|
||||
Title: "会议邀请",
|
||||
Content: fmt.Sprintf("邀请你加入会议:%s", room.Title),
|
||||
ActorID: &actor,
|
||||
TargetType: "meeting",
|
||||
TargetID: &roomID,
|
||||
Extra: extra,
|
||||
})
|
||||
pushed++
|
||||
}
|
||||
|
||||
if len(payloads) > 0 {
|
||||
s.notifyPusher.PushBatch(ctx, payloads)
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "会议邀请推送完成",
|
||||
zap.String("room_code", code), zap.Int("pushed", pushed), zap.Int("skipped", skipped))
|
||||
return pushed, skipped, nil
|
||||
}
|
||||
|
||||
// InviteUsers 主持人邀请用户(批量,走 notify.Push 发送 meeting_invite 通知)
|
||||
func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64, groupIDs []int64) error {
|
||||
return ErrNotImplemented
|
||||
// RedeemInviteToken 点击邀请链接时兑换 Token
|
||||
// 成功:返回会议号 + 邀请人 ID + 是否有密码,前端据此决定弹出密码输入框并调 JoinRoom
|
||||
// Token 兑换后不立即删除,保留 60 秒冗余(用户可能刷新页面);过期走 Redis 原生 TTL
|
||||
func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (*dto.RedeemInviteTokenResponse, error) {
|
||||
raw, err := s.redis.Get(ctx, redisKeyInvitePrefix+token).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrInviteTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload invitePayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, ErrInviteTokenInvalid
|
||||
}
|
||||
|
||||
// InviteeID > 0 表示定向邀请:仅允许该用户兑换(防止链接转发给非预期收件人)
|
||||
if payload.InviteeID > 0 && payload.InviteeID != userID {
|
||||
return nil, ErrInviteTokenInvalid
|
||||
}
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, payload.RoomCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if room == nil || room.Status == constants.MeetingStatusEnded {
|
||||
return nil, ErrInviteTokenInvalid
|
||||
}
|
||||
|
||||
return &dto.RedeemInviteTokenResponse{
|
||||
RoomCode: payload.RoomCode,
|
||||
InviterID: payload.InviterID,
|
||||
HasPassword: room.PasswordHash != nil && *room.PasswordHash != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ====== 会议内聊天(Task 6) ======
|
||||
// ====== 会议内聊天 ======
|
||||
|
||||
// SendChatMessage 写入会议聊天 + 向房间内所有成员广播 WS meeting.chat.message
|
||||
// SendChatMessage 会议内发送文本消息
|
||||
func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code, content string) (*model.MeetingChat, error) {
|
||||
return nil, ErrNotImplemented
|
||||
funcName := "service.meeting_service.SendChatMessage"
|
||||
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if room == nil {
|
||||
return nil, ErrMeetingNotFound
|
||||
}
|
||||
if room.Status == constants.MeetingStatusEnded {
|
||||
return nil, ErrMeetingEnded
|
||||
}
|
||||
if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chat := &model.MeetingChat{
|
||||
RoomID: room.ID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
}
|
||||
if err := s.chatDAO.Create(ctx, chat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventChatMessage, map[string]interface{}{
|
||||
"room_code": code,
|
||||
"message_id": chat.ID,
|
||||
"user_id": userID,
|
||||
"content": content,
|
||||
"created_at": chat.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}, userID)
|
||||
|
||||
logs.Debug(ctx, funcName, "会议聊天已发送",
|
||||
zap.String("room_code", code), zap.Int64("user_id", userID), zap.Int64("message_id", chat.ID))
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
// ListChatMessages 加载会议聊天历史(游标分页)
|
||||
func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, afterID int64, limit int) ([]model.MeetingChat, error) {
|
||||
return nil, ErrNotImplemented
|
||||
// ListChatMessages 加载会议聊天历史(游标分页,按 created_at ASC 升序返回)
|
||||
func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, beforeID int64, limit int) ([]model.MeetingChat, bool, error) {
|
||||
room, err := s.roomDAO.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if room == nil {
|
||||
return nil, false, ErrMeetingNotFound
|
||||
}
|
||||
if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// DAO 当前接受 afterID(正向游标);ListChats 前端通常是"查更早的",这里直接用 beforeID 语义
|
||||
var chats []model.MeetingChat
|
||||
q := s.db.WithContext(ctx).Model(&model.MeetingChat{}).Where("room_id = ?", room.ID)
|
||||
if beforeID > 0 {
|
||||
q = q.Where("id < ?", beforeID)
|
||||
}
|
||||
if err := q.Order("created_at DESC, id DESC").Limit(limit + 1).Find(&chats).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
hasMore := false
|
||||
if len(chats) > limit {
|
||||
hasMore = true
|
||||
chats = chats[:limit]
|
||||
}
|
||||
return chats, hasMore, nil
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
meetingRoomDAO := dao6.NewMeetingRoomDAO(gormDB)
|
||||
meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB)
|
||||
meetingChatDAO := dao6.NewMeetingChatDAO(gormDB)
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, pubSub, notifyService, friendshipDAO, onlineService)
|
||||
noopMediaOrchestrator := service7.NewNoopMediaOrchestrator()
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, pubSub, notifyService, friendshipDAO, onlineService, noopMediaOrchestrator)
|
||||
meetingController := controller7.NewMeetingController(meetingService)
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingController)
|
||||
return app, nil
|
||||
|
||||
Reference in New Issue
Block a user