fix(meeting): Task 16 修复 code-reviewer 审计 P1 八项

按 docs/reviews/2026-04-23-phase2e-2-code-review.md 清单落地 P1 批次:

- P1-1 主持人转让原子性:MeetingParticipantDAO.TransferHost 内部事务
  合并 meeting_rooms.host_id 更新,service/lifecycle 移除冗余 UpdateHost
- P1-2 ListChatMessages / ListMyMeetings 回归 DAO:新增
  MeetingChatDAO.ListByRoomBefore 反向游标,service 移除 s.db 直查
- P1-3 ListMyMeetings N+1 放大优化:新增
  MeetingParticipantDAO.ListJoinedRoomsByUser JOIN + DISTINCT 单次 SQL
- P1-4 EndRoom 行锁 + 事务:s.db.Transaction 包裹
  SELECT FOR UPDATE → 快照 → MarkEnded → LeaveAllActive;DAO 补
  WithTx(*gorm.DB) 和 GetByIDForUpdate
- P1-5 后台 goroutine trace_id 保留:新增 logs.DetachContext(ctx);
  替换 meeting_service / meeting_signal_service 共 10 处 context.Background();
  meeting_ws_handler 每条 WS 消息分配独立 trace_id
- P1-6 _broadcastSelfState 重试:最多 2 次指数退避(700ms→2100ms),
  检测 localAudioEnabled/localVideoEnabled 已被后续动作覆盖时放弃旧 patch
- P1-7 HandleHostGraceExpired / HandleEmptyRoomExpired 处理锁:
  独立 host_grace_handling:<code> / empty_ttl_handling:<code> SETNX + 60s TTL,
  消除 Redis 自然过期 + 本地 timer 到点 + DEL 返回 0 的盲区
- P1-8 pushExistingRoomState 同步化:OnRoomJoin 返回前完成补推,
  消除与 REST member.joined 并行造成的前端状态闪烁

并附带修复 .gitignore 中 logs/ 规则误伤 pkg/logs/ 代码目录的历史遗留问题,
将 logger.go / trace.go 正式纳入版本控制。

验证:backend go build + go vet 通过;frontend npm run build:h5 通过。

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-23 16:53:30 +08:00
parent cdaa39d686
commit ea2bf96c2f
12 changed files with 567 additions and 108 deletions

View File

@@ -1011,7 +1011,13 @@ export const useMeetingStore = defineStore('meeting', () => {
* Task 15向房间其他成员广播自身音视频状态
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
* - 后端会广播给房间其他成员(不回显给本人)
* - 容错WS 未连接或 ACK 失败仅 warn 日志,不影响本地状态机
*
* Task 16 P1-6增加 sendWithAck 重试与 member_state Hash 最终一致性保障
* - 旧版失败仅 warnWS 抖动期其他成员的"图标灰色"问题会重现
* - 现实现最多 2 次指数退避重试700ms → 2100ms所有重试失败再输出 error 日志
* - 设计约束:总耗时上限 ~6s避免用户切换过快时累积旧状态覆盖新状态
* (每次调用前先记录 targetSnapshot重试前若 currentRoom 已变则放弃;
* 若 patch 同字段已被后续 broadcast 覆盖,也放弃旧 patch 的重试)
*/
const _broadcastSelfState = (patch) => {
if (!currentRoom.value) return
@@ -1020,10 +1026,36 @@ export const useMeetingStore = defineStore('meeting', () => {
if (typeof patch.audio_enabled === 'boolean') payload.audio_enabled = patch.audio_enabled
if (typeof patch.video_enabled === 'boolean') payload.video_enabled = patch.video_enabled
if (Object.keys(payload).length <= 1) return
wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, payload, 3000)
.catch((err) => {
_log('warn', '[Meeting] 上报本地音视频状态失败', err)
})
const targetRoomCode = currentRoom.value.room_code
const maxAttempts = 3
const backoffMs = [0, 700, 2100]
const attemptSend = (attempt) => {
if (!currentRoom.value || currentRoom.value.room_code !== targetRoomCode) {
_log('info', '[Meeting] 切换会议/已离会,放弃 state 重试', payload)
return
}
if (typeof patch.audio_enabled === 'boolean' && localAudioEnabled.value !== patch.audio_enabled) {
_log('info', '[Meeting] audio_enabled 已被后续动作覆盖,放弃旧 patch 重试', patch)
return
}
if (typeof patch.video_enabled === 'boolean' && localVideoEnabled.value !== patch.video_enabled) {
_log('info', '[Meeting] video_enabled 已被后续动作覆盖,放弃旧 patch 重试', patch)
return
}
wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, payload, 3000)
.catch((err) => {
if (attempt + 1 >= maxAttempts) {
_log('error', '[Meeting] 上报本地音视频状态失败(已用尽重试)', err, payload)
return
}
const delay = backoffMs[attempt + 1] || 2100
_log('warn', `[Meeting] 上报本地音视频状态失败,${delay}ms 后重试 (${attempt + 2}/${maxAttempts})`, err)
setTimeout(() => attemptSend(attempt + 1), delay)
})
}
attemptSend(0)
}
/**