Files
EchoChat/backend/go-service/app/meeting/dao/meeting_participant_dao.go
bujinyuan ea2bf96c2f 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
2026-04-23 16:53:30 +08:00

317 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dao
import (
"context"
"errors"
"time"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MeetingParticipantDAO 参与者数据访问对象
type MeetingParticipantDAO struct {
db *gorm.DB
}
// NewMeetingParticipantDAO 创建实例
func NewMeetingParticipantDAO(db *gorm.DB) *MeetingParticipantDAO {
return &MeetingParticipantDAO{db: db}
}
// WithTx 返回绑定到指定事务句柄的新 DAO 实例
// 所有现有方法复用 db 字段,因此替换 db 为 tx 可让 DAO 方法参与上游事务。
// Task 16 P1-4EndRoom 行锁 + 事务化 新增
func (d *MeetingParticipantDAO) WithTx(tx *gorm.DB) *MeetingParticipantDAO {
return &MeetingParticipantDAO{db: tx}
}
// JoinRoom 记录用户加入会议
// 语义:
// - 若 (room_id, user_id) 不存在 → 创建新行role 取参数值joined_at = NOW()
// - 若 (room_id, user_id) 已存在且 left_at != NULL历史离会→ 重置 left_at/left_reason/duration
// joined_at 刷新为当前时间视为全新一次参会role 保持原值(角色变更走 UpdateRole
// - 若 (room_id, user_id) 已存在且 left_at = NULL仍在会议中→ 返回 ErrAlreadyInMeeting
//
// 返回最新持久化的参与者记录
var (
// ErrAlreadyInMeeting 用户已在会议中(未 left_at
ErrAlreadyInMeeting = errors.New("user already in meeting")
)
// JoinRoom 加入会议(幂等 + 重入复用记录)
func (d *MeetingParticipantDAO) JoinRoom(ctx context.Context, roomID, userID int64, role int) (*model.MeetingParticipant, error) {
funcName := "dao.meeting_participant_dao.JoinRoom"
logs.Info(ctx, funcName, "加入会议",
zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Int("role", role))
var result model.MeetingParticipant
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.MeetingParticipant
findErr := tx.Where("room_id = ? AND user_id = ?", roomID, userID).First(&existing).Error
if errors.Is(findErr, gorm.ErrRecordNotFound) {
now := time.Now()
result = model.MeetingParticipant{
RoomID: roomID,
UserID: userID,
Role: role,
JoinedAt: now,
Duration: 0,
}
return tx.Create(&result).Error
}
if findErr != nil {
return findErr
}
if existing.LeftAt == nil {
return ErrAlreadyInMeeting
}
now := time.Now()
updates := map[string]interface{}{
"joined_at": now,
"left_at": nil,
"left_reason": nil,
"duration": 0,
}
if err := tx.Model(&model.MeetingParticipant{}).
Where("id = ?", existing.ID).
Updates(updates).Error; err != nil {
return err
}
result = existing
result.JoinedAt = now
result.LeftAt = nil
result.LeftReason = nil
result.Duration = 0
return nil
})
if err != nil && !errors.Is(err, ErrAlreadyInMeeting) {
logs.Error(ctx, funcName, "加入会议失败",
zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Error(err))
}
return &result, err
}
// LeaveRoom 记录用户离会
// 计算 duration = NOW() - joined_at写入 left_at / left_reason
// 已离会left_at != NULL的记录幂等忽略返回受影响行数 0
func (d *MeetingParticipantDAO) LeaveRoom(ctx context.Context, roomID, userID int64, reason string) (int64, error) {
funcName := "dao.meeting_participant_dao.LeaveRoom"
now := time.Now()
res := d.db.WithContext(ctx).
Model(&model.MeetingParticipant{}).
Where("room_id = ? AND user_id = ? AND left_at IS NULL", roomID, userID).
Updates(map[string]interface{}{
"left_at": now,
"left_reason": reason,
// duration 使用 SQL 表达式保证数据库时间一致
"duration": gorm.Expr("EXTRACT(EPOCH FROM (? - joined_at))::INT", now),
})
if res.Error != nil {
logs.Error(ctx, funcName, "离开会议失败",
zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Error(res.Error))
}
return res.RowsAffected, res.Error
}
// LeaveAllActive 批量将会议内所有仍活跃的参与者置为离会(用于会议结束时兜底)
// reason 建议传 host_end / empty_ttl 等房间级原因
func (d *MeetingParticipantDAO) LeaveAllActive(ctx context.Context, roomID int64, reason string) (int64, error) {
funcName := "dao.meeting_participant_dao.LeaveAllActive"
now := time.Now()
res := d.db.WithContext(ctx).
Model(&model.MeetingParticipant{}).
Where("room_id = ? AND left_at IS NULL", roomID).
Updates(map[string]interface{}{
"left_at": now,
"left_reason": reason,
"duration": gorm.Expr("EXTRACT(EPOCH FROM (? - joined_at))::INT", now),
})
if res.Error != nil {
logs.Error(ctx, funcName, "批量离会失败",
zap.Int64("room_id", roomID), zap.Error(res.Error))
}
return res.RowsAffected, res.Error
}
// GetByRoomAndUser 查询单个参与者记录
func (d *MeetingParticipantDAO) GetByRoomAndUser(ctx context.Context, roomID, userID int64) (*model.MeetingParticipant, error) {
var p model.MeetingParticipant
err := d.db.WithContext(ctx).
Where("room_id = ? AND user_id = ?", roomID, userID).
First(&p).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &p, nil
}
// ListActiveByRoom 返回房间内所有仍未离会的参与者left_at IS NULL
// 按 joined_at ASC 排序,供"最早加入者接任主持人"使用
func (d *MeetingParticipantDAO) ListActiveByRoom(ctx context.Context, roomID int64) ([]model.MeetingParticipant, error) {
funcName := "dao.meeting_participant_dao.ListActiveByRoom"
var list []model.MeetingParticipant
err := d.db.WithContext(ctx).
Where("room_id = ? AND left_at IS NULL", roomID).
Order("joined_at ASC").
Find(&list).Error
if err != nil {
logs.Error(ctx, funcName, "查询在线参与者失败",
zap.Int64("room_id", roomID), zap.Error(err))
}
return list, err
}
// CountActiveByRoom 房间内当前在线人数(不含已离会)
// 用于加入会议时的容量校验
func (d *MeetingParticipantDAO) CountActiveByRoom(ctx context.Context, roomID int64) (int64, error) {
var count int64
err := d.db.WithContext(ctx).
Model(&model.MeetingParticipant{}).
Where("room_id = ? AND left_at IS NULL", roomID).
Count(&count).Error
return count, err
}
// ListByRoom 房间内所有历史参与者(含已离会),按 joined_at ASC 排序
func (d *MeetingParticipantDAO) ListByRoom(ctx context.Context, roomID int64) ([]model.MeetingParticipant, error) {
var list []model.MeetingParticipant
err := d.db.WithContext(ctx).
Where("room_id = ?", roomID).
Order("joined_at ASC").
Find(&list).Error
return list, err
}
// FindActiveByUser 查询用户当前仍在进行中的会议left_at IS NULL且 room.status != ended
// 用于"用户不能同时在多个会议中"的业务校验
// 返回 gorm.ErrRecordNotFound 表示当前空闲
func (d *MeetingParticipantDAO) FindActiveByUser(ctx context.Context, userID int64) (*model.MeetingParticipant, error) {
var p model.MeetingParticipant
err := d.db.WithContext(ctx).
Joins("JOIN meeting_rooms ON meeting_rooms.id = meeting_participants.room_id").
Where("meeting_participants.user_id = ? AND meeting_participants.left_at IS NULL AND meeting_rooms.status != ?",
userID, constants.MeetingStatusEnded).
First(&p).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &p, nil
}
// TransferHost 事务内完成主持人转让Task 16 P1-1 强化为跨表原子事务)
// 同一事务内完成三步:
// 1) 旧 host 的 meeting_participants.role 置为 Participant0
// 2) 新 host 的 meeting_participants.role 置为 Host1且必须仍在会议中left_at IS NULL
// 3) meeting_rooms.host_id 原子更新为新 host
//
// 背景(审计 P1-1旧实现把 1+2 放在 DAO 事务3 由 service 层另起 SQL 执行;
// 两步非原子 → 第 3 步失败会造成 participant.role 与 room.host_id 不一致,
// 进而 assertIsHost以 room.host_id 为准)永久拒绝老新 host 的所有主持人操作。
// 现合并到单一事务,任一子步骤失败自动回滚,跨表一致性得到保证。
func (d *MeetingParticipantDAO) TransferHost(ctx context.Context, roomID, oldHostID, newHostID int64) error {
funcName := "dao.meeting_participant_dao.TransferHost"
logs.Info(ctx, funcName, "转让主持人",
zap.Int64("room_id", roomID),
zap.Int64("old_host_id", oldHostID),
zap.Int64("new_host_id", newHostID))
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.MeetingParticipant{}).
Where("room_id = ? AND user_id = ? AND role = ?",
roomID, oldHostID, constants.MeetingRoleHost).
Update("role", constants.MeetingRoleParticipant).Error; err != nil {
return err
}
if err := tx.Model(&model.MeetingParticipant{}).
Where("room_id = ? AND user_id = ? AND left_at IS NULL",
roomID, newHostID).
Update("role", constants.MeetingRoleHost).Error; err != nil {
return err
}
if err := tx.Model(&model.MeetingRoom{}).
Where("id = ?", roomID).
Update("host_id", newHostID).Error; err != nil {
return err
}
return nil
})
}
// UpdateRole 更新指定参与者角色(非转让场景,例如直接指派联合主持人)
func (d *MeetingParticipantDAO) UpdateRole(ctx context.Context, roomID, userID int64, role int) error {
return d.db.WithContext(ctx).
Model(&model.MeetingParticipant{}).
Where("room_id = ? AND user_id = ?", roomID, userID).
Update("role", role).Error
}
// ListJoinedRoomsByUser 用户参与过的会议列表JOIN meeting_rooms支持 status 过滤 + 游标分页)
// Task 16 P1-3替代 "ListByUser + 内存合并 + ListByIDs" 的 N+1 查询路径
// 返回按 meeting_rooms.created_at DESC, meeting_rooms.id DESC 排序;
// 使用 DISTINCT 避免参与者多次 join/leave 同一会议导致的行重复。
// 由调用方自行传 limit+1 判断 has_more
func (d *MeetingParticipantDAO) ListJoinedRoomsByUser(
ctx context.Context,
userID int64,
statusFilter *int,
beforeID int64,
limit int,
) ([]model.MeetingRoom, error) {
if limit <= 0 {
return []model.MeetingRoom{}, nil
}
funcName := "dao.meeting_participant_dao.ListJoinedRoomsByUser"
q := d.db.WithContext(ctx).
Table("meeting_rooms AS r").
Select("DISTINCT r.*").
Joins("INNER JOIN meeting_participants AS p ON p.room_id = r.id").
Where("p.user_id = ?", userID)
if statusFilter != nil {
q = q.Where("r.status = ?", *statusFilter)
}
if beforeID > 0 {
q = q.Where("r.id < ?", beforeID)
}
var rooms []model.MeetingRoom
if err := q.Order("r.created_at DESC, r.id DESC").Limit(limit).Find(&rooms).Error; err != nil {
logs.Error(ctx, funcName, "JOIN 查询用户会议失败", zap.Int64("user_id", userID), zap.Error(err))
return nil, err
}
return rooms, nil
}
// ListByUser 用户历史会议列表(分页,按加入时间倒序)
func (d *MeetingParticipantDAO) ListByUser(ctx context.Context, userID int64, offset, limit int) ([]model.MeetingParticipant, int64, error) {
q := d.db.WithContext(ctx).
Model(&model.MeetingParticipant{}).
Where("user_id = ?", userID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.MeetingParticipant
err := q.Order("joined_at DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}