Files
EchoChat/docs/api/frontend/meeting.md
bujinyuan e235e001b3 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
2026-04-21 16:39:59 +08:00

13 KiB
Raw Blame History

会议模块 API (Meeting) — Phase 2e-2 MVP

通用规范(认证、响应包络、通用错误码)见 README.md 会议内实时信令Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 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 §6.2 为单一事实来源SSOT


路径总览

# 方法 路径 业务 权限
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 拉取会议历史消息(游标分页) 当前活跃参会者

路径参数约定:code 为用户可见的 9 位会议号 XXX-XXX-XXX:token 为 32 位十六进制邀请令牌。


通用约定

响应包络

成功:

{ "code": 0, "message": "success", "data": { ... }, "trace_id": "...", "time": "2026-04-21 16:30:00" }

失败:

{ "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/meeting/rooms

请求体

字段 类型 必填 说明
title string 会议标题1~200 字符
password string 入会密码明文4~20 字符;服务端 bcrypt 哈希存储
max_members int 容量上限2~8MVP 硬上限 8超过将被截断为 8

响应 201 Created

{
  "code": 0,
  "data": {
    "room": {
      "id": 12,
      "room_code": "835-000-036",
      "title": "T5 验证会议",
      "host_id": 16,
      "type": 1,
      "has_password": false,
      "max_members": 4,
      "status": 1,
      "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. 我的会议列表

GET /api/v1/meeting/rooms/mine

查询参数

字段 类型 必填 说明
status int 过滤状态0=未开始 / 1=进行中 / 2=已结束;不传=全部
before_id int64 游标:仅返回 id < before_id 的记录
limit int 页大小,默认 20最大 50

响应 200 OK

{
  "code": 0,
  "data": {
    "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

{
  "code": 0,
  "data": {
    "room": { "id": 12, "room_code": "835-000-036", ... },
    "participants": [
      { "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/meeting/rooms/:code/join

请求体

字段 类型 必填 说明
password string 条件 房间 has_password=true 时必传

校验顺序:房间存在 → 未结束 → 单点参会(若已在其他活跃会议则 400→ 密码锁定5 次错误封禁 10 分钟)→ 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined

首次加入:创建 meeting_participants 新行。 复入(之前 left复用原行重置 left_at=NULLjoined_at=nowduration=0,避免审计表膨胀。

响应 200 OK

{
  "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/meeting/rooms/:code/leave

行为

  • participant.left_at = nowduration = EXTRACT(EPOCH FROM now - joined_at)
  • 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给最早加入的活跃成员,广播 meeting.host.changed
  • 若房间无剩余活跃成员 → 标记 status=2 ended_reason=empty_ttl 并触发 mediaOrchestrator.CloseRouter
  • 广播 meeting.member.left

响应 200 OK

{ "code": 0, "data": { "duration": 185 } }

6. 结束会议

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. 转让主持人

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. 踢出成员

POST /api/v1/meeting/rooms/:code/kick

请求体

字段 类型 必填 说明
user_id int64 被踢用户 ID

行为:标记 left_at=nowleft_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 秒(MeetingInviteTokenTTLvalue = {"room_code","inviter_id","invitee_id","has_password"}
  3. 通过 Phase 2e-1 notify.Pusher.PushBatch 推送 type=meeting_invite 通知,Extraroom_code / room_title / has_password / invite_token
  4. 离线被邀请者走通知入库,上线后由 WS 或未读轮询获得。

响应 200 OK

{ "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

{
  "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

{
  "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

{
  "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.mdmeeting.* 事件族。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 6WebSocket 信令协议(meeting.* 事件、mediasoup transport/producer/consumer 流转)
  • Task 7Go → Node HTTP 客户端,将 NoopMediaOrchestrator 替换为 HTTPMediaOrchestrator,接入真实 mediasoup Router
  • Task 13:通知卡片 UI 补齐 meeting_invite 内联按钮