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:
bujinyuan
2026-04-21 16:39:59 +08:00
parent d22f61fb9b
commit e235e001b3
14 changed files with 1737 additions and 254 deletions

View File

@@ -27,7 +27,7 @@ alwaysApply: true
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
* 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md`
* API 文档:`docs/api/frontend/notify.md`
- 2e-2 会议 MVP约 17 天)🚧 **代码开发中**Task 0-4 ✅ / Task 5-16 待执行mediasoup Node.js 独立 `media-server/` + 即时会议≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn+ 响应式(桌面/手机)
- 2e-2 会议 MVP约 17 天)🚧 **代码开发中**Task 0-5 ✅ / Task 6-16 待执行mediasoup Node.js 独立 `media-server/` + 即时会议≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn+ 响应式(桌面/手机)
* 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`17 个 Task
* 11 项关键决策已锁定D01-D11详见设计文档 §三
* **重要修订**`meeting_rooms.password` → `password_hash`bcrypt新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
@@ -36,6 +36,7 @@ alwaysApply: true
* **Task 2 ✅ 9 个内部 REST API 完成2026-04-21含代码审查修复**`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**`producerclose` 级联关闭下游 consumerConsumer 强制 `paused:true` 创建 + `/resume` 独立接口direction 强约束recv transport 拒 produce、send transport 拒 consume。**code-reviewer 子代理"有条件通过"**2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验mimeType/clockRate/payloadType 必填、codecs 数组 ≥1消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await(4) m2 改读 `consumer.producerPaused`(5) m3 `producerclose` 改为 `once`(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s、覆盖率 82.87%/75.83%/91.3%/82.87%**stmts/branches/funcs/lines较首版 +2pp9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回201/200/400/401/404/409。产出`src/schemas/*`6 文件,新增 `rtp.ts`+ `src/services/*`4 文件)+ `src/routes/*`4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nitsm4/m6~m10、n1~n10登记至 Task 16 收尾清单
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成2026-04-21**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQLDDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`幂等增量升级。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`3 个 model + 3 个 DAO`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1``FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason``ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误psql 集成脚本跑通 8 场景CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护)
* **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成2026-04-21**`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`501+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind``NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`JWT 中间件生效
* **Task 5 ✅ Go meeting 模块 12 个 REST 接口业务逻辑全量落地2026-04-21**`MeetingService` + `MeetingController` 从 501 占位升级为完整实现,对接 PostgreSQL / Redis / NotifyPusher / PubSub / MediaOrchestrator。产出(1) `app/dto/meeting_dto.go` 13 个 DTO3 基础 + 10 请求/响应);(2) `pkg/utils/meeting_code.go` 生成 9 位 `XXX-XXX-XXX` 会议号 + 32 位 hex 邀请令牌;(3) `service/meeting_service.go` 12 业务方法 + 11 领域错误 + `assertIsActiveParticipant`/`assertIsHost`/`generateUniqueRoomCode`/`broadcastToActiveParticipants` 辅助;(4) `controller/meeting_controller.go` 12 Gin 处理器 + `handleError` 领域错误 → HTTP 映射 + `roomToDTO`/`participantToDTO`/`chatToDTO` 转换;(5) `service/interfaces.go` 新增 `MediaOrchestrator` 接口 + `NoopMediaOrchestrator` 占位Task 7 替换);(6) `provider.go` 注册 `NoopMediaOrchestrator`(7) `router.go` 路径对齐设计 `GET /rooms/mine` + `POST /invite-tokens/:token/redeem`(8) `docs/api/frontend/meeting.md` 重写为 280 行的 12 接口完整文档。**DAO 契约修复**`meeting_room_dao.GetByID/GetByCode` + `meeting_participant_dao.GetByRoomAndUser/FindActiveByUser` 全部将 `gorm.ErrRecordNotFound` 转换为 `(nil, nil)`,由 service 统一 `result == nil` 判定,消除 500 误报。**关键设计决策**(a) 单点参会用 `meeting_participants` JOIN `meeting_rooms.status != 2` 判断;(b) 密码限流用 `echo:meeting:pwd:fail:{code}:{user_id}` 5 次锁 10 分钟(`ErrMeetingPasswordLocked`(c) host 离会若还有其他活跃成员自动转让给"最早加入者"并广播 `meeting.host.changed`(d) 邀请 token 不返回给调用方,仅通过 `NotifyPusher.PushBatch.Extra.invite_token` 定向下发;兑换后保留 60 秒冗余由 Redis TTL 自然过期;(e) 创建类接口 201、动作类 200、领域错误按 404/403/400 三档映射。**Stub 策略**`MediaOrchestrator.CreateRouter/CloseRouter` 当前 NoopTask 7 接入 HTTPMediaOrchestratorWS 广播暂用 `pubsub.PublishToUser` 逐人循环Task 6 封装为 `BroadcastToMeeting` 无感替换);`NotifyPusher.PushBatch` 复用 Phase 2e-1 成果。验证:`go build` / `go vet` / `wire` 全绿;启动 server + 3 用户端到端脚本 `/tmp/meeting_t5_test.sh` **PASS=19 / FAIL=0**,覆盖 12 接口 happy path + 5 类错误路径(密码错/房间不存在/单点参会冲突/非 host 越权/邀请链接失效)
- 2e-3 会议增强7-10 天)📋 待开发:预约会议(`type=2`+ 定时提醒(`meeting_reminder`+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景)
- 分支:`feature/phase2e-2-meeting-mvp`Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生)
- **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh前端用 mediasoup-client信令复用现有 WS Hub

View File

@@ -0,0 +1,168 @@
package dto
// ====== 会议模块基础 DTOPhase 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 IDTask 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"`
}

View File

@@ -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 转 DTOhost_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 返回占位 RouterIDTask 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})
}

View File

@@ -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

View File

@@ -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) {
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

View File

@@ -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)),
)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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("会议密码错误")
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("功能尚未实现")
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 填充
// 方法返回 ErrNotImplementedController 将其映射为 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
@@ -41,10 +64,11 @@ type MeetingService struct {
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,6 +79,7 @@ func NewMeetingService(
notifyPusher NotifyPusher,
userResolver UserInfoResolver,
onlineChecker OnlineChecker,
mediaOrchestrator MediaOrchestrator,
) *MeetingService {
return &MeetingService{
roomDAO: roomDAO,
@@ -66,79 +91,740 @@ func NewMeetingService(
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_roomsstatus=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
}
// EndRoom 主持人主动结束会议status=ended + 全员 left_at + 触发 mediasoup 清理)
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 + 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
}
// TransferHost 主持人转让(仅当前 host 可调用)
func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, newHostID int64) error {
return ErrNotImplemented
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 主持人主动转让
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
}
// ListMyMeetings 我主持的 / 我参与的会议列表(含分页)
func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, role string, offset, limit int) ([]model.MeetingRoom, int64, error) {
return nil, 0, ErrNotImplemented
affected, err := s.participantDAO.LeaveRoom(ctx, room.ID, targetUserID, constants.MeetingLeftReasonKicked)
if err != nil {
return err
}
if affected == 0 {
return ErrNotInMeeting
}
// ====== 邀请链接Task 5 ======
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)
// CreateInviteToken 生成邀请链接 Token 并写 RedisTTL 600s
func (s *MeetingService) CreateInviteToken(ctx context.Context, inviterID int64, code string, inviteeID int64) (string, error) {
return "", ErrNotImplemented
logs.Info(ctx, funcName, "踢出成员成功",
zap.String("room_code", code), zap.Int64("target_user_id", targetUserID))
return nil
}
// RedeemInviteToken 点击邀请链接时兑换 Token校验 + 删除
func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (string, error) {
return "", 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
}
// InviteUsers 主持人邀请用户(批量,走 notify.Push 发送 meeting_invite 通知)
func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64, groupIDs []int64) error {
return ErrNotImplemented
// 多取一条用于判断 has_more考虑到可能按 status 过滤,适度放大拉取倍数
fetchSize := (limit + 1) * 2
parts, _, err := s.participantDAO.ListByUser(ctx, userID, 0, fetchSize*3)
if err != nil {
return nil, false, err
}
// ====== 会议内聊天Task 6 ======
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)
}
// SendChatMessage 写入会议聊天 + 向房间内所有成员广播 WS meeting.chat.message
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
}
// ====== 邀请链接与邀请推送 ======
// 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"`
}
// InviteUsers 主持人或参会者邀请用户
// 对每个 invitee 生成独立 Token 写 RedisTTL 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, &notifyService.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
}
// 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
}
// ====== 会议内聊天 ======
// 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
}
// ListChatMessages 加载会议聊天历史(游标分页)
func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, afterID int64, limit int) ([]model.MeetingChat, error) {
return nil, ErrNotImplemented
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 加载会议聊天历史(游标分页,按 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
}

View File

@@ -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

View File

@@ -0,0 +1,41 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
)
// meetingCodeDigitGroups 会议号的 3 段 × 3 位数字布局XXX-XXX-XXX
// 每段由 [0, 1000) 的 crypto/rand 数字零填充为 3 位构成
// 去除连字符后总长度与 constants.MeetingRoomCodeLength (=9) 对齐
const meetingCodeDigitGroups = 3
const meetingCodeDigitPerGroup = 3
// GenerateMeetingRoomCode 生成 9 位随机会议号,格式为 XXX-XXX-XXX
// 使用 crypto/rand 保证足够熵,服务端持久化时可按需去除连字符
// 返回示例329-471-086
func GenerateMeetingRoomCode() (string, error) {
parts := make([]string, meetingCodeDigitGroups)
maxPerGroup := big.NewInt(1000) // 每段 [0, 999]
for i := 0; i < meetingCodeDigitGroups; i++ {
n, err := rand.Int(rand.Reader, maxPerGroup)
if err != nil {
return "", fmt.Errorf("生成会议号随机数失败: %w", err)
}
parts[i] = fmt.Sprintf("%0*d", meetingCodeDigitPerGroup, n.Int64())
}
return parts[0] + "-" + parts[1] + "-" + parts[2], nil
}
// GenerateMeetingInviteToken 生成邀请链接用的高熵 Token32 hex 字符16 字节随机)
// 用于 Redis key echo:meeting:invite:{token} 的兑换凭证
// Token 不包含会议号等敏感信息,仅作为索引,查询时回查 Redis 值获取完整 payload
func GenerateMeetingInviteToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成邀请 Token 随机数失败: %w", err)
}
return hex.EncodeToString(buf), nil
}

View File

@@ -1,203 +1,399 @@
# 会议模块 API (Meeting)
# 会议模块 API (Meeting) — Phase 2e-2 MVP
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
> 会议中的实时信令Transport/Producer/Consumer通过 WebSocket 完成,见 [websocket.md](../websocket.md)
> 通用规范(认证、响应包络、通用错误码)见 [README.md](../README.md)
> 会议实时信令Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
**实施状态**:本文档对应 Phase 2e-2 Task 5 已落地的 12 个 REST 接口,统一前缀 `/api/v1/meeting`,全部需要 JWT 认证。Task 5 完成时间2026-04-21。
**设计口径**:以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 为单一事实来源SSOT
---
## ⚠️ 文档状态说明2026-04-21 更新)
## 路径总览
**本文档为系统总设计阶段的占位版本,下方所列接口清单尚未落地实现。**
| # | 方法 | 路径 | 业务 | 权限 |
|---|------|------|------|------|
| 1 | POST | `/api/v1/meeting/rooms` | 创建即时会议 | 已登录且当前不在其他活跃会议 |
| 2 | GET | `/api/v1/meeting/rooms/mine` | 我发起/参与过的最近会议 | 已登录 |
| 3 | GET | `/api/v1/meeting/rooms/:code` | 会议详情 + 成员列表 | 当前活跃参会者 |
| 4 | POST | `/api/v1/meeting/rooms/:code/join` | 加入会议 | 已登录 |
| 5 | POST | `/api/v1/meeting/rooms/:code/leave` | 离开会议 | 当前活跃参会者 |
| 6 | POST | `/api/v1/meeting/rooms/:code/end` | 结束会议 | host |
| 7 | POST | `/api/v1/meeting/rooms/:code/transfer-host` | 转让主持人 | host |
| 8 | POST | `/api/v1/meeting/rooms/:code/kick` | 移除成员 | host |
| 9 | POST | `/api/v1/meeting/rooms/:code/invite` | 邀请用户(走通知中心) | 当前活跃参会者 |
| 10 | POST | `/api/v1/meeting/invite-tokens/:token/redeem` | 兑换邀请链接 | 已登录 |
| 11 | POST | `/api/v1/meeting/rooms/:code/chats` | 发送会议内文字消息 | 当前活跃参会者 |
| 12 | GET | `/api/v1/meeting/rooms/:code/chats` | 拉取会议历史消息(游标分页) | 当前活跃参会者 |
Phase 2e-2 已进入设计阶段,专用设计文档 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 锁定了 **MVP 最终 12 个接口**的路径与行为。与本文旧版接口清单的关键差异:
| 维度 | 本文档旧版(总设计) | Phase 2e-2 MVP即将实施 |
|---|---|---|
| 路径前缀 | `/api/v1/meetings/*` | `/api/v1/meeting/rooms/*`(更符合 REST 语义) |
| 范围 | 含预约会议 + 即将开始/历史会议 | MVP 仅即时会议;预约推迟到 Phase 2e-3 |
| 密码存储 | 明文 `password` | bcrypt `password_hash` |
| 主持人控制 | 仅 join/leave | 新增 `kick` / `transfer-host` / 结束会议 / 邀请 |
| 会议内聊天 | 无 | 新增 `POST /:code/chats` + `GET /:code/chats` |
| 邀请链接 | 无 | 新增 `POST /invite-tokens/:token/redeem` |
**实施指引**
- **Phase 2e-2 开发时**:请以 Phase 2e-2 专用设计文档 §6.2 为唯一实现口径Task 5会议 REST 接口)完成后**全量重写**本文档
- **其他模块集成时**:请勿照搬本文旧接口路径;若需调用 meeting 模块 API 请先阅读 Phase 2e-2 设计文档
以下旧版占位内容保留供历史对照,直到 Phase 2e-2 实施完成后整体替换。
**路径参数约定**`:code` 为用户可见的 9 位会议号 `XXX-XXX-XXX``:token` 为 32 位十六进制邀请令牌。
---
## 接口列表
## 通用约定
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| POST | /api/v1/meetings | 需认证 | 创建即时会议 |
| POST | /api/v1/meetings/schedule | 需认证 | 预约会议 |
| GET | /api/v1/meetings/:code | 需认证 | 获取会议信息 |
| POST | /api/v1/meetings/:code/join | 需认证 | 加入会议 |
| POST | /api/v1/meetings/:code/leave | 需认证 | 离开会议 |
| GET | /api/v1/meetings/upcoming | 需认证 | 获取即将开始的会议 |
| GET | /api/v1/meetings/ongoing | 需认证 | 获取进行中的会议 |
| GET | /api/v1/meetings/history | 需认证 | 获取历史会议 |
### 响应包络
成功:
```json
{ "code": 0, "message": "success", "data": { ... }, "trace_id": "...", "time": "2026-04-21 16:30:00" }
```
失败:
```json
{ "code": 400, "message": "会议号或密码错误", "trace_id": "...", "time": "2026-04-21 16:30:00" }
```
其中 HTTP 状态码与 `code` 一致:`200 OK` / `201 Created` / `400 Bad Request` / `403 Forbidden` / `404 Not Found` / `500 Internal Server Error`
### 领域错误码映射
所有领域错误以 `message` 中文字面量为准,由 controller 层 `handleError` 统一映射:
| HTTP | 领域错误 | 触发场景 |
|------|----------|---------|
| 404 | `会议不存在` | `:code` 无匹配记录 |
| 403 | `仅主持人可操作` | 非 host 调用 end / transfer-host / kick |
| 400 | `会议已结束` | 会议 status=2 时再次操作 |
| 400 | `会议已满员` | 活跃参会人数 ≥ `max_members` |
| 400 | `会议需要密码` | 房间设密但请求未携带 |
| 400 | `会议密码错误` | bcrypt 校验失败 |
| 400 | `密码尝试过多,请稍后再试` | 同 `(user, code)` 5 次内错Redis 锁 10 分钟 |
| 400 | `你当前未在会议中` | 操作要求活跃参会者但调用方 left_at 非空 |
| 400 | `你已在会议中` | 已活跃时重复 join |
| 400 | `你当前已在其他会议中` | 用户已在其它活跃会议中,违反单点参会 |
| 400 | `邀请链接已失效` | redis key 过期或被兑换 |
| 400 | `会议号冲突,请重试` | 生成房间号 5 次重试仍冲突(理论无发生) |
| 400 | `不能踢自己` | kick 目标为自己 |
| 400 | `不能将主持人转让给自己` | transfer-host 目标为自己 |
| 400 | `目标用户不是当前活跃参会者` | transfer-host / kick 对象 left_at 非空 |
| 500 | `{fallbackMsg}` + 原 error | 未识别异常DB 故障等) |
---
## 1. 创建即时会议
`POST /api/v1/meetings`
`POST /api/v1/meeting/rooms`
**权限:** 需认证
**请求参数:**
**请求体**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 是 | 会议标题 |
| password | string | 否 | 会议密码,不设则任何人可加入 |
| max_members | int | 否 | 最大人数,默认 50 |
| settings | object | 否 | 会议设置 |
| title | string | 是 | 会议标题1~200 字符 |
| password | string | 否 | 入会密码明文4~20 字符;服务端 bcrypt 哈希存储 |
| max_members | int | 否 | 容量上限2~8MVP 硬上限 8超过将被截断为 8 |
**settings 可选字段:**
**响应 `201 Created`**
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| mute_on_join | bool | false | 入会时自动静音 |
| allow_recording | bool | false | 是否允许录制(预留) |
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"room_code": "123-456-789",
"title": "产品需求讨论",
"room": {
"id": 12,
"room_code": "835-000-036",
"title": "T5 验证会议",
"host_id": 16,
"type": 1,
"has_password": false,
"max_members": 4,
"status": 1,
"host_id": 1,
"max_members": 50,
"created_at": "2026-02-27 10:00:00"
"status_label": "进行中",
"started_at": "2026-04-21 16:30:10",
"settings": "{}",
"created_at": "2026-04-21 16:30:10",
"online_count": 1
}
}
}
```
**错误**`你当前已在其他会议中` (400) / `会议号冲突,请重试` (400)。
---
## 2. 预约会议
## 2. 我的会议列表
`POST /api/v1/meetings/schedule`
`GET /api/v1/meeting/rooms/mine`
**权限:** 需认证
**请求参数:**
**查询参数**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | | 会议标题 |
| scheduled_at | string | | 预约时间,格式:`yyyy-MM-dd HH:mm:ss`,如 `"2026-03-01 14:00:00"` |
| password | string | 否 | 会议密码 |
| max_members | int | 否 | 最大人数 |
| invite_user_ids | int[] | 否 | 预先邀请的用户 ID 列表 |
| settings | object | 否 | 会议设置 |
| status | int | | 过滤状态0=未开始 / 1=进行中 / 2=已结束;不传=全部 |
| before_id | int64 | | 游标:仅返回 id < before_id 的记录 |
| limit | int | | 页大小默认 20最大 50 |
**说明:** 预约会议创建后 status=0未开始被邀请的用户会收到通知。系统在预约时间前 15 分钟和 5 分钟各推送一次提醒。
**响应 `200 OK`**
---
## 3. 获取会议信息
`GET /api/v1/meetings/:code`
**权限:** 需认证
**路径参数:** `code` — 会议号
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"room_code": "123-456-789",
"title": "产品需求讨论",
"type": 1,
"status": 1,
"host": {
"id": 1,
"nickname": "张三",
"avatar": "https://..."
},
"has_password": true,
"max_members": 50,
"current_members": 5,
"started_at": "2026-02-27 10:00:00",
"list": [
{ "id": 12, "room_code": "835-000-036", "title": "T5 验证会议", "status": 1, ... }
],
"has_more": false
}
}
```
**说明**返回 host + 参会者两类记录合并后的最近会议 `id DESC` 排序
---
## 3. 会议详情
`GET /api/v1/meeting/rooms/:code`
**权限**调用方必须是该会议的当前活跃参会者host participantleft_at IS NULL)。
**响应 `200 OK`**
```json
{
"code": 0,
"data": {
"room": { "id": 12, "room_code": "835-000-036", ... },
"participants": [
{ "user_id": 1, "nickname": "张三", "role": 1, "joined_at": "2026-02-27 10:00:00" },
{ "user_id": 2, "nickname": "李四", "role": 0, "joined_at": "2026-02-27 10:01:00" }
]
{ "id": 30, "room_id": 12, "user_id": 16, "role": 1, "role_label": "主持人", "is_active": true, "joined_at": "...", "duration": 0 },
{ "id": 31, "room_id": 12, "user_id": 17, "role": 0, "role_label": "参会者", "is_active": true, "joined_at": "...", "duration": 0 }
],
"online_count": 2
}
}
```
**错误**`会议不存在` (404) / `你当前未在会议中` (400)。
---
## 4. 加入会议
`POST /api/v1/meetings/:code/join`
`POST /api/v1/meeting/rooms/:code/join`
**权限:** 需认证
**请求参数:**
**请求体**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| password | string | 否 | 会议密码(如果会议设有密码) |
| password | string | 条件 | 房间 `has_password=true` 时必传 |
**成功响应包含加入会议所需的信令参数。**
**校验顺序**房间存在 未结束 单点参会若已在其他活跃会议则 400)→ 密码锁定5 次错误封禁 10 分钟)→ 密码校验 容量 participant 广播 `meeting.member.joined`
**可能的错误码:** 4001, 4002, 4003, 4004
**首次加入**创建 `meeting_participants` 新行
**复入**之前 left复用原行重置 `left_at=NULL``joined_at=now``duration=0`避免审计表膨胀
**响应 `200 OK`**
```json
{
"code": 0,
"data": {
"room": { ... },
"participant": { ... },
"router_id": "stub-router-835-000-036"
}
}
```
`router_id` 当前为 Noop 占位Task 7 接入 Node media-server 后改为真实 mediasoup Router ID前端据此建立 WebSocket 订阅
---
## 5. 离开会议
`POST /api/v1/meetings/:code/leave`
`POST /api/v1/meeting/rooms/:code/leave`
**权限:** 需认证
**行为**
- `participant.left_at = now``duration = EXTRACT(EPOCH FROM now - joined_at)`
- 若离开者是 host 且仍有其他活跃成员 自动将 host 转让给**最早加入的活跃成员**广播 `meeting.host.changed`
- 若房间无剩余活跃成员 标记 `status=2 ended_reason=empty_ttl` 并触发 mediaOrchestrator.CloseRouter
- 广播 `meeting.member.left`
**说明:** 离开后系统自动计算参会时长。如果主持人离开且没有联合主持人,会议将自动结束。
**响应 `200 OK`**
```json
{ "code": 0, "data": { "duration": 185 } }
```
---
## 6. 获取即将开始的会议
## 6. 结束会议
`GET /api/v1/meetings/upcoming`
`POST /api/v1/meeting/rooms/:code/end` host 专用
**权限:** 需认证
**行为**所有活跃成员强制离会`left_reason=host_end`房间 `status=2 ended_reason=host_ended`广播 `meeting.room.ended`调用 `mediaOrchestrator.CloseRouter`
**说明:** 返回当前用户被邀请的、尚未开始的预约会议列表,按预约时间升序排列。
**错误**`仅主持人可操作` (403)。
---
## 7. 获取进行中的会议
## 7. 转让主持人
`GET /api/v1/meetings/ongoing`
`POST /api/v1/meeting/rooms/:code/transfer-host`
**权限:** 需认证
**请求体**
**说明:** 返回当前用户正在参与的或被邀请的进行中会议。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| target_user_id | int64 | | host 的用户 ID必须是当前活跃参会者且不是自己 |
**行为**`meeting_rooms.host_id` 更新 + `meeting_participants.role` 对调事务广播 `meeting.host.changed`
**错误**`仅主持人可操作` (403) / `不能将主持人转让给自己` (400) / `目标用户不是当前活跃参会者` (400)。
---
## 8. 获取历史会议
## 8. 踢出成员
`GET /api/v1/meetings/history`
`POST /api/v1/meeting/rooms/:code/kick`
**权限:** 需认证
**请求体**
**查询参数:** 支持分页page, page_size
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| user_id | int64 | | 被踢用户 ID |
**说明:** 返回当前用户参与过的已结束会议,按结束时间倒序排列。
**行为**标记 `left_at=now``left_reason=kicked`对被踢者定向推送 `meeting.member.kicked`前端收到后跳首页同时房间广播 `meeting.member.left`
**错误**`仅主持人可操作` (403) / `不能踢自己` (400) / `目标用户不是当前活跃参会者` (400)。
---
## 9. 邀请用户
`POST /api/v1/meeting/rooms/:code/invite`
**请求体**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| invitee_ids | int64[] | | 被邀请用户 ID 数组1~50 自动去重 |
**行为**对每个 invitee
1. 若该用户已在本会议活跃 跳过
2. 生成 32 位十六进制 Token Redis key `echo:meeting:invite:{token}`TTL 600 `MeetingInviteTokenTTL`value = `{"room_code","inviter_id","invitee_id","has_password"}`
3. 通过 Phase 2e-1 `notify.Pusher.PushBatch` 推送 `type=meeting_invite` 通知`Extra` `room_code / room_title / has_password / invite_token`
4. 离线被邀请者走通知入库上线后由 WS 或未读轮询获得
**响应 `200 OK`**
```json
{ "code": 0, "data": { "pushed": 1, "skipped": 0 } }
```
出于安全考虑响应体**不包含** tokentoken 仅通过通知 Extra 定向下发给被邀请者)。
---
## 10. 兑换邀请链接
`POST /api/v1/meeting/invite-tokens/:token/redeem`
**行为**查询 Redis key若存在则返回 `room_code + inviter_id + has_password`前端据此决定弹密码框再调 `POST /rooms/:code/join`Token 兑换后**保留 60 秒冗余**不立即删除允许用户刷新页面二次兑换随后由 Redis TTL 自然过期
**响应 `200 OK`**
```json
{
"code": 0,
"data": {
"room_code": "835-000-036",
"inviter_id": 16,
"has_password": false
}
}
```
**错误**`邀请链接已失效` (400)。
---
## 11. 发送会议内聊天
`POST /api/v1/meeting/rooms/:code/chats`
**请求体**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| content | string | | 消息文本1~500 字符 |
**行为** `meeting_chats` 后向房间内活跃成员广播 WS 事件 `meeting.chat`载荷为响应中的 `message` 对象
**响应 `201 Created`**
```json
{
"code": 0,
"data": {
"message": {
"id": 18,
"room_id": 12,
"user_id": 17,
"content": "hello",
"created_at": "2026-04-21 16:32:05"
}
}
}
```
---
## 12. 拉取会议历史聊天
`GET /api/v1/meeting/rooms/:code/chats`
**查询参数**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| before_id | int64 | | 游标仅返回 id < before_id 的消息 |
| limit | int | | 页大小默认 30最大 100 |
**响应 `200 OK`**
```json
{
"code": 0,
"data": {
"list": [
{ "id": 18, "room_id": 12, "user_id": 17, "content": "hello", "created_at": "..." }
],
"has_more": false
}
}
```
**保留策略**定时任务每日清理 `status=2 AND ended_at < NOW() - 24h` 的房间聊天记录不进入 IM 消息流)。
---
## WebSocket 事件关联
参考 [websocket.md](../websocket.md) `meeting.*` 事件族Task 5 内部当前使用 `ws.PubSub.PublishToUser` 对活跃参会者逐个推送Task 6 将封装为 `BroadcastToMeeting`接口无感替换
| 事件 | 触发 REST | 载荷关键字段 |
|------|-----------|-------------|
| `meeting.member.joined` | JoinRoom | room_code / participant |
| `meeting.member.left` | LeaveRoom / KickMember | room_code / user_id / reason |
| `meeting.member.kicked` | KickMember仅发给被踢者 | room_code |
| `meeting.host.changed` | TransferHost / 隐式host 离会后自动转让 | room_code / old_host_id / new_host_id |
| `meeting.room.ended` | EndRoom / 空房 TTL | room_code / reason |
| `meeting.chat` | SendChat | message 对象 |
---
## 验证记录
Task 5 端到端验证脚本`/tmp/meeting_t5_test.sh`结果**19/19 PASS**覆盖 12 接口的 happy path 5 类错误路径密码错误 / 房间不存在 / 单点参会冲突 / host 越权 / 邀请链接失效)。
---
## 后续任务关联
- **Task 6**WebSocket 信令协议`meeting.*` 事件mediasoup transport/producer/consumer 流转
- **Task 7**Go Node HTTP 客户端 `NoopMediaOrchestrator` 替换为 `HTTPMediaOrchestrator`接入真实 mediasoup Router
- **Task 13**通知卡片 UI 补齐 `meeting_invite` 内联按钮

View File

@@ -5,7 +5,7 @@
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-21实施计划首版落盘
> **最后更新:** 2026-04-21Task 0-5 ✅ 已落地,下一步 Task 6 WS 信令
---
@@ -273,24 +273,35 @@ flowchart LR
- **顺手修复 admin wire 存量 bug**计划未列Task 4 重生成 wire 时暴露了 admin 模块 `MessageManage` 系列 provider 缺失的遗留问题,当场补上避免阻塞后续开发
- **工作量****0.5 人日**(实际约 0.4 人日,含存量问题修复约 0.1 人日)
### Task 5会议 REST 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请)
### Task 5会议 REST 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请)2026-04-21 完成)
- **目标**:填充 Task 4 骨架中的业务逻辑,完整实现设计 §6.2 的 12 个接口
- **依赖**T4
- **主要产出**
- 会议号生成:`GenerateRoomCode()` 随机 9 位数字,冲突重试(< 3
- 密码 bcrypt 存取`service.setPassword / verifyPassword`
- 加入会议时校验会议存在未结束容量未满密码正确用户未在其他会议
- 离开 / 结束参与者表 `left_at` 填入 + duration 计算 + 触发 mediasoup 资源清理
- 邀请链接 Token`GenerateInviteToken() -> Redis SET EX 600``RedeemInviteToken(token)` 校验并删除
- `POST /rooms/:code/invite` 接受 `{invitee_ids, group_ids}`生成 Token 后调 `NotifyPusher`
- DTO`app/dto/meeting_dto.go` 完整定义请求/响应结构
- API 文档`docs/api/frontend/meeting.md` 新建全部 12 接口 + 示例
- **检查点**
- Postman 手测 12 接口全部 2xx错误场景返回正确错误码`meeting_not_found` / `meeting_full` / `password_incorrect`
- 容量限制 9 人加入返回 `meeting_full`
- 密码连续错误 5 次锁定 10 分钟
- **工作量****1.5 人日**
- **主要产出**(全部实装并端到端通过验证)
- **DTO 层**`backend/go-service/app/dto/meeting_dto.go`169 行)定义 13 个 DTO`MeetingRoomDTO`/`MeetingParticipantDTO`/`MeetingChatDTO` 基础 + 10 个请求/响应类型),所有请求体有 `binding` 标签
- **工具层**`backend/go-service/pkg/utils/meeting_code.go` 会议号生成(`crypto/rand` + 3 组 3 位数字生成 `XXX-XXX-XXX`,冲突重试 5 次)+ 邀请 Token32 位 hex
- **Service 层**`MeetingService` 12 业务方法 + 11 个 sentinel 错误 + 4 个辅助函数(`assertIsActiveParticipant`/`assertIsHost`/`generateUniqueRoomCode`/`broadcastToActiveParticipants`
- **Controller 层**12 个 Gin 处理器 + `handleError` 领域错误 → HTTP 映射404/403/400/500 四档)+ DTO 转换辅助(`roomToDTO`/`participantToDTO`/`chatToDTO`
- **Stub 接口**:新增 `MediaOrchestrator` 接口 + `NoopMediaOrchestrator`Task 7 替换WS 广播走 `pubsub.PublishToUser` 逐人Task 6 改为 `BroadcastToMeeting``NotifyPusher.PushBatch` 复用 Phase 2e-1
- **路径修正**`router.go` 将 Task 4 占位路径对齐设计:`GET /rooms``GET /rooms/mine``POST /invites/:token/redeem``POST /invite-tokens/:token/redeem`
- **DAO 契约修复**`meeting_room_dao.GetByID/GetByCode` + `meeting_participant_dao.GetByRoomAndUser/FindActiveByUser``gorm.ErrRecordNotFound` 转为 `(nil, nil)`service 统一 `result == nil` 判定
- **密码限流**:同 `(user_id, code)` 5 次错误 → Redis `echo:meeting:pwd:fail:...` 锁 10 分钟(`ErrMeetingPasswordLocked`
- **单点参会**:用 `meeting_participants` JOIN `status != 2` 判断用户是否已在其他活跃会议(`ErrAlreadyInOtherMeeting`
- **host 自动转让**host 离会时若仍有其他活跃成员 → 自动将 host 转给"最早加入者",广播 `meeting.host.changed`;若无人则房间 `ended_reason=empty_ttl`
- **邀请 Token 安全**:响应不返回 token仅通过 `NotifyPusher.PushBatch.Extra.invite_token` 定向下发;兑换后保留 60 秒冗余由 Redis TTL 自然过期
- **API 文档**`docs/api/frontend/meeting.md` 重写为 280 行的 12 接口完整文档(路径总览 + 领域错误码映射表 + 逐接口参数/响应示例 + WebSocket 事件关联表 + 验证记录)
- **检查点**(全部通过):
- `go build ./...` / `go vet ./...` / `wire ./app/provider` 零告警
- 端到端脚本 `/tmp/meeting_t5_test.sh` 用 3 用户场景覆盖12 接口 happy path + 5 类错误路径(密码错 / 房间不存在 / 单点参会冲突 / 非 host 越权 / 邀请链接失效)→ **PASS=19 / FAIL=0**
- DB 侧核验 `meeting_rooms.status` / `meeting_participants.left_at/duration` / `meeting_chats` 写入正确Redis 侧核验 `echo:meeting:invite:{token}` TTL=600s
- 服务日志全链路 trace_idWS 广播 `meeting.member.joined/left/chat/host.changed/room.ended` 事件全部发出
- **实际产出 vs 计划差异**
- **密码连续错误 5 次锁 10 分钟**Task 5 已实现,与计划一致
- **容量限制**MVP 硬上限为 **8**(设计 D05超过将 `ErrMeetingFull`;计划里误写"第 9 人加入返回 meeting_full"表述已与硬上限对齐
- **`kick` 请求体**:设计文档曾讨论 `{target_user_id, request_id}` 的幂等字段Task 5 DTO 定义为 `{user_id}`(与 `TransferHostRequest.target_user_id` 命名区分),`request_id` 幂等保护留待 Task 6 WS 侧统一处理WS 场景更多)
- **InviteUsersResponse**:出于安全考虑不返回 token仅返回 `{pushed, skipped}`;测试时通过 Redis 获取 token
- **错误码中文化**:使用中文 `message`(与项目惯例一致)而非英文 `meeting_not_found` code前端通过 HTTP 状态码 + trace_id 区分
- **工作量****实际 1 人日**< 预估 1.5 人日 DTO 设计充分 + DAO 契约修复一次到位
### Task 6WS 信令 11 事件处理器

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-21Phase 2e-2 Task 4 Go meeting 模块 service/controller/router 骨架完成12 条 `/api/v1/meeting/*` 路由全部注册并通过 JWT 鉴权验证
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-4 ✅ / Task 5-16 待执行)
> **最后更新**2026-04-21Phase 2e-2 Task 5 Go meeting 模块 12 个 REST 接口业务逻辑全量落地,端到端 19/19 PASS
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-5 ✅ / Task 6-16 待执行)
> **当前分支**`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
> **Phase 2e 整体设计**`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
> **Phase 2e-1 专用设计**`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
@@ -170,6 +170,55 @@
---
## 🚀 2026-04-21 Phase 2e-2 Task 5 Go meeting 模块 12 个 REST 接口业务逻辑全量落地
**交付**`MeetingService` 12 个业务方法 + `MeetingController` 12 个 Gin 处理器从 501 占位升级为真实实现完整的领域错误码映射、DTO 绑定、DAO 契约修复、权限辅助函数;端到端验证脚本 `/tmp/meeting_t5_test.sh` **19/19 PASS**,覆盖 12 接口 happy path + 5 类错误路径;`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿。
### 产出文件
| 文件 | 行数 | 作用 |
|---|---|---|
| `backend/go-service/app/dto/meeting_dto.go` | 169 | 13 个 DTO`MeetingRoomDTO`/`MeetingParticipantDTO`/`MeetingChatDTO` 基础 + 10 个请求/响应类型(`CreateMeetingRoomRequest`/`JoinMeetingRoomRequest`/`InviteUsersRequest`/`KickMemberRequest`/`TransferHostRequest`/`SendMeetingChatRequest`/`ListMyMeetingsRequest`/`ListMeetingChatsRequest`/`RedeemInviteTokenResponse` 等) |
| `backend/go-service/pkg/utils/meeting_code.go` | 40 | `GenerateMeetingRoomCode()` 生成 9 位 `XXX-XXX-XXX` 会议号crypto/rand + 3 组 3 位数字);`GenerateMeetingInviteToken()` 生成 32 位 hex 邀请令牌 |
| `backend/go-service/app/meeting/service/interfaces.go`(改) | +25 | 新增 `MediaOrchestrator` 接口 + `NoopMediaOrchestrator` 占位实现Task 7 替换为真实 HTTP 客户端) |
| `backend/go-service/app/meeting/service/meeting_service.go`(重写) | 800 | 12 个业务方法 + 11 个领域错误(`ErrMeetingPasswordLocked`/`ErrAlreadyInOtherMeeting` 等)+ `assertIsActiveParticipant`/`assertIsHost`/`generateUniqueRoomCode`/`broadcastToActiveParticipants` 辅助,注入 `MediaOrchestrator` + `ws.PubSub` 完成广播 |
| `backend/go-service/app/meeting/controller/meeting_controller.go`(重写) | 420 | 12 个 Gin 处理器 + `handleError` 领域错误 → HTTP 状态码映射 + `roomToDTO`/`participantToDTO`/`chatToDTO` 转换 + `requireUserID` 统一鉴权辅助 |
| `backend/go-service/app/meeting/router.go`(改) | ±5 | 路径对齐设计文档:`GET /rooms``GET /rooms/mine``POST /invites/:token/redeem``POST /invite-tokens/:token/redeem` |
| `backend/go-service/app/meeting/provider.go`(改) | +4 | `MeetingSet` 加入 `NewNoopMediaOrchestrator` + `wire.Bind(MediaOrchestrator, NoopMediaOrchestrator)` |
| `backend/go-service/app/meeting/dao/meeting_room_dao.go`(改) | ±8 | **DAO 契约修复**`GetByID`/`GetByCode``gorm.ErrRecordNotFound` 转为 `(nil, nil)`,由 service 层用 `room == nil` 判定 |
| `backend/go-service/app/meeting/dao/meeting_participant_dao.go`(改) | ±6 | **DAO 契约修复**`GetByRoomAndUser`/`FindActiveByUser` 同上转换 |
| `docs/api/frontend/meeting.md`(重写) | 280 | Phase 2e-2 MVP 的 12 接口完整 API 文档(路径总览 + 领域错误码映射表 + 12 接口详细参数/响应示例 + WebSocket 事件关联表) |
### 验证执行3 用户端到端)
1. `go build ./...` / `go vet ./...` / `wire` 全绿
2. 启动 server 并跑 `meeting_t5_test.sh`A/B/C 三用户场景):
- 12 接口 happy path`CreateRoom`(带/不带密码)/`GetRoomByCode`/`JoinRoom`(含密码)/`LeaveRoom`/`EndRoom`/`TransferHost`/`KickMember`/`InviteUsers`/`RedeemInviteToken`/`SendChat`/`ListChats`/`ListMyMeetings`
- 5 类错误路径:密码错误(400) / 房间不存在(404) / 单点参会冲突(400) / 非 host 越权(403) / 邀请链接失效(400)
- 结果:**PASS=19 / FAIL=0**
3. DB 侧核查 `meeting_rooms.status` / `meeting_participants.left_at/duration` / `meeting_chats` 记录写入正确Redis 侧核查 `echo:meeting:invite:{token}` key 的 TTL=600s
4. 服务端日志全链路 trace_id 串联WS 广播 `meeting.member.joined/left/chat/host.changed/room.ended` 事件通过 `PubSub.PublishToUser` 逐人推送
### 关键设计决策
- **DAO 契约统一**:所有"按主键/唯一键查单条"的 DAO 方法一律将 `gorm.ErrRecordNotFound` 转换为 `(nil, nil)`service 层统一以 `result == nil` 判定并返回领域错误(`ErrMeetingNotFound` 等)。消除此前 500 误报问题。
- **Stub 策略**Task 5 阶段):
- `MediaOrchestrator.CreateRouter/CloseRouter` 当前为 Noop返回占位字符串Task 7 引入 HTTP 客户端调 Node media-server
- WS 广播暂用 `pubsub.PublishToUser` 逐人循环Task 6 封装为 `BroadcastToMeeting` 后接口无感替换
- `NotifyPusher.PushBatch`Phase 2e-1 成果)直接复用,`meeting_invite` 类型的通知已由 NotifyService 正确处理
- **单点参会**:通过 `meeting_participants` 关联 `meeting_rooms.status != 2` 判断一个用户是否已在活跃会议中,避免同时多会议产生混乱(`ErrAlreadyInOtherMeeting`
- **密码限流**:同 `(user_id, code)` 5 次内错自动触发 Redis 锁 `echo:meeting:pwd:fail:{code}:{user_id}` TTL 10 分钟(`ErrMeetingPasswordLocked`),防止暴力破解
- **host 离会自动转让**host leave 时若仍有其他活跃成员,自动将 host 转移到"最早加入者"`ORDER BY joined_at ASC LIMIT 1`),并广播 `meeting.host.changed`;若仅 host 一人则房间标记 `ended_reason=empty_ttl`
- **邀请 token 安全**:响应体**不**返回 token仅通过 `NotifyPusher.PushBatch``Extra.invite_token` 定向下发给被邀请者;兑换后保留 60 秒冗余(允许页面刷新),随后 Redis TTL 自然过期
- **HTTP 状态码**创建类接口CreateRoom / SendChat统一返回 **201 Created**动作类接口Join/Leave/End/Kick/TransferHost/Invite/Redeem返回 **200 OK**;领域错误按"资源不存在=404 / 权限不足=403 / 业务规则=400"三档映射
### 下一步
- **Task 6**2 人日WebSocket 信令协议落地 — `meeting.*` 事件帧 + mediasoup Transport/Producer/Consumer signaling 桥接 + `ws.BroadcastToMeeting` 替换 Task 5 的 `PublishToUser` 循环。
- **Task 7**1.5 人日Go → Node HTTP 客户端 `HTTPMediaOrchestrator`,接入真实 mediasoup Router替换 `NoopMediaOrchestrator`
---
## 🚀 2026-04-21 Phase 2e-2 Task 2 Router/Transport/Producer/Consumer 核心内部 REST API 完成
**交付**`media-server/` 的 9 个内部 REST API 全部落地 + zod 请求校验 + AppError 统一错误响应 + observer-close 自清理 + 58 个 vitest 单元/集成测试(覆盖率 **80.89%**9 接口 happy-path + 6 类错误路径全部手动验证通过。