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:
@@ -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~8(MVP 硬上限 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 或 participant,left_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 } }
|
||||
```
|
||||
|
||||
出于安全考虑,响应体**不包含** token(token 仅通过通知 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` 内联按钮
|
||||
|
||||
@@ -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-21(Task 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 次)+ 邀请 Token(32 位 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_id;WS 广播 `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 6:WS 信令 11 事件处理器
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EchoChat 项目开发进度
|
||||
|
||||
> **最后更新**:2026-04-21(Phase 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-21(Phase 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 类错误路径全部手动验证通过。
|
||||
|
||||
Reference in New Issue
Block a user