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

@@ -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": "产品需求讨论",
"type": 1,
"status": 1,
"host_id": 1,
"max_members": 50,
"created_at": "2026-02-27 10:00:00"
"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. 预约会议
## 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`**
```json
{
"code": 0,
"data": {
"list": [
{ "id": 12, "room_code": "835-000-036", "title": "T5 验证会议", "status": 1, ... }
],
"has_more": false
}
}
```
**说明**返回 host + 参会者两类记录合并后的最近会议 `id DESC` 排序
---
## 3. 获取会议信息
## 3. 会议详情
`GET /api/v1/meetings/:code`
`GET /api/v1/meeting/rooms/:code`
**权限** 需认证
**权限**调用方必须是该会议的当前活跃参会者host participantleft_at IS NULL)。
**路径参数:** `code` — 会议号
**响应 `200 OK`**
**成功响应:**
```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",
"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" }
]
}
"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/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` 内联按钮