fix(meeting): Task 16 修复 code-reviewer 审计 P0 四项 + 落盘审查报告

code-reviewer 子代理(docs/reviews/2026-04-23-phase2e-2-code-review.md)对 Phase 2e-2
整体做全栈审计,暴露 P0 × 4 / P1 × 8 / P2 × 8 / Nit × 11。本次先修 P0:

- P0-1 / P0-2 媒体资源归属越权:
  新增 assertOwnsResource(roomCode, userID, kind, id) helper,用 resourceTrackKey Redis Set
  SIsMember 校验 transport/producer/consumer 归属;统一接入 OnTransportConnect /
  OnProduceStart / OnConsumeStart / OnConsumeResume / OnProducerClose 五个信令入口,
  一次封死同类横向越权漏洞(Redis 查询失败时 fail-closed,避免抖动开口子)。
  新增 service.ErrResourceNotOwned 错误常量。
- P0-3 CreateRoom Router 失败吞错:
  旧实现 mediaErr != nil 仅 logs.Warn 继续返回成功 → zombie 房间 + 入会必败 +
  一人一会议名额被占。改为 fail-closed 补偿:LeaveRoom + MarkEnded(system_error) +
  返回新 service.ErrMediaServiceUnavailable,controller.handleError 映射为 500。
- P0-4 会议密码明文走 URL:
  join.vue onNext 旧逻辑把密码拼 ?password=xxx,浏览器历史 / DevTools 留痕。
  新增 meetingStore.draftJoinPayload 内存态草稿,join.vue 只跳转
  ?mode=join&code=xxx,preview.vue onLoad 读取后立即 draftJoinPayload = null 清空,
  邀请链接里的 password 参数一律忽略。

验证:
- cd backend/go-service && go build ./... 通过
- cd frontend && npm run build:h5 通过
- 仅剩 1 个预先存在 unusedparams warning,与本次改动无关

P1 / P2 / Nit 清单保留在审查报告,按顺序继续处理。

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-23 16:34:13 +08:00
parent 790996feaf
commit cdaa39d686
7 changed files with 301 additions and 29 deletions

View File

@@ -39,6 +39,12 @@ var (
ErrKickSelfForbidden = errors.New("不能踢出自己")
ErrTransferToSelf = errors.New("不能将主持人转让给自己")
ErrTransferTargetInvalid = errors.New("目标用户不在会议中")
// ErrResourceNotOwned 媒体资源归属校验失败:用户试图操作不属于自己的 transport/producer/consumer
// 发生场景Web 端用户抓到他人 producerID 后调 meeting.producer.close、或跨用户挂 consumer 等横向越权尝试
ErrResourceNotOwned = errors.New("媒体资源归属校验失败,禁止操作他人资源")
// ErrMediaServiceUnavailable 媒体服务当前不可用Router 创建失败 / Node 宕机等)
// 用于 CreateRoom / JoinRoom 的补偿路径,将前台错误与"用户输入错误"区分开
ErrMediaServiceUnavailable = errors.New("媒体服务暂时不可用,请稍后重试")
)
// Redis key 前缀(设计文档 §5.4 - Redis 数据结构)
@@ -272,11 +278,26 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.
}
// Task 7 起 CreateRouter 对接真实 mediasoupTask 8 起仅在会议首次创建时调一次(房间级资源)
// P0-3 修复(审计报告):
// 旧版在 Router 创建失败时仅 `logs.Warn` 继续返回成功,导致 DB 存在 active 房间但 mediasoup 端无 Router
// 所有入会者后续 transport.create 必失败;同时占用"一人一会议"名额,用户无法新建。
// 现改为 fail-closedRouter 失败 → 补偿 LeaveRoom + MarkEnded(system_error) → 返回 ErrMediaServiceUnavailable 让前端提示重试。
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 = ""
logs.Warn(ctx, funcName, "mediasoup Router 创建失败,执行补偿回滚",
zap.String("room_code", code), zap.Int64("host_id", hostID), zap.Error(mediaErr))
if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, hostID, constants.MeetingLeftReasonSelf); leaveErr != nil {
logs.Warn(ctx, funcName, "补偿阶段 LeaveRoom 失败(已记录,清理任务会兜底)",
zap.Int64("room_id", room.ID), zap.Int64("host_id", hostID), zap.Error(leaveErr))
}
if _, markErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonSystemError, time.Now()); markErr != nil {
logs.Warn(ctx, funcName, "补偿阶段 MarkEnded 失败(已记录,清理任务会兜底)",
zap.Int64("room_id", room.ID), zap.Error(markErr))
}
err = ErrMediaServiceUnavailable
return nil, nil, "", err
}
logs.Info(ctx, funcName, "会议创建成功",