按 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
317 lines
11 KiB
Go
317 lines
11 KiB
Go
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-4:EndRoom 行锁 + 事务化 新增
|
||
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 置为 Participant(0)
|
||
// 2) 新 host 的 meeting_participants.role 置为 Host(1),且必须仍在会议中(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
|
||
}
|