feat(phase2e-2): Go meeting 模块 DDL + Model + DAO + service/controller/router 骨架(Task 3+4)
Task 3 - 数据库与持久化层:
- init.sql 追加 3 张表(meeting_rooms / meeting_participants / meeting_chats)+ 9 索引 + 全量 COMMENT
- 新增 phase2e2_migration.sql 增量升级脚本(IF NOT EXISTS 幂等)
- app/constants/meeting.go 统一会议常量(类型/状态/角色/结束原因/离会原因/默认配置/WS 事件 8 组)
- app/meeting/model 3 个 model + GORM tag(TIMESTAMP(0) 对齐现有表风格)
- app/meeting/dao 3 个 DAO 共 24 个方法:
* meeting_room_dao:Create/Get/Exists/MarkStarted/MarkEnded(乐观锁)/UpdateHost/UpdateSettings/ListByHost/ListExpiredForCleanup
* meeting_participant_dao:JoinRoom(事务内复用旧记录)/LeaveRoom(EXTRACT(EPOCH) DB 时间)/LeaveAllActive/TransferHost(事务)/FindActiveByUser(JOIN 单点校验)/列表计数更新
* meeting_chat_dao:Create/ListByRoom(游标)/DeleteByRoomIDs/CountByRoom
- psql 真库跑通 CRUD + UNIQUE + CASCADE + 主持人转让事务 + duration=10s 精确匹配 8 场景
Task 4 - Go 侧 service/controller/router/wire 骨架:
- app/meeting/service/interfaces.go:NotifyPusher / UserInfoResolver / OnlineChecker 三接口(解耦 notify/contact/ws)
- app/meeting/service/meeting_service.go:MeetingService + 8 sentinel error + 17 个空方法(返回 ErrNotImplemented)
- app/meeting/controller/meeting_controller.go:12 个 Gin handler + responseNotImplemented(501)
- app/meeting/router.go:12 条路由挂 /api/v1/meeting/* 并套 jwtAuth(扁平化 router.go,与 group/notify 一致)
- app/meeting/provider.go:MeetingSet = wire.NewSet(DAO×3, Service, Controller)
- 全局 wire.go 挂入 MeetingSet + 3 条 wire.Bind(Notify/UserInfo/Online);App struct/NewApp 加字段;router/router.go 挂载
- 存量修复:admin/provider.go 补齐 MessageManage{DAO,Service,Controller} 解决旧版 wire 重生成报 no provider found
- 验证:go build ./... / go vet ./... / wire ./app/provider 零错误;GIN_MODE=debug 启动打印全部 12 条路由;无 token curl 3 条代表性路由均返回 401(JWT 中间件生效)
关键风格修正(偏离草案):
- 常量归入 app/constants/meeting.go 单文件(与 group.go / notify.go 同构),非草案 app/meeting/constants/
- TIMESTAMP(0) 取代草案 TIMESTAMPTZ 对齐所有现有表
- 冗余 idx_meeting_rooms_code 移除(UNIQUE 已自动建索引)
- router/provider 目录扁平化(app/meeting/router.go / app/meeting/provider.go)
- 延续项目 Go 侧"零 _test.go"风格,用代码审查 + psql 真库验证 + Playwright E2E 三层守护
文档同步:
- docs/progress/CURRENT_STATUS.md 新增 Task 3 / Task 4 交付段落
- .cursor/rules/project-context.mdc Phase 2e-2 进度推进至 Task 0-4 ✅
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 3 / Task 4 标记完成 + 实际产出 vs 计划差异
Made-with: Cursor
This commit is contained in:
177
backend/go-service/app/meeting/dao/meeting_room_dao.go
Normal file
177
backend/go-service/app/meeting/dao/meeting_room_dao.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Package dao 提供 meeting 模块的数据库访问操作
|
||||
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"
|
||||
)
|
||||
|
||||
// MeetingRoomDAO 会议房间数据访问对象
|
||||
type MeetingRoomDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewMeetingRoomDAO 创建 MeetingRoomDAO 实例
|
||||
func NewMeetingRoomDAO(db *gorm.DB) *MeetingRoomDAO {
|
||||
return &MeetingRoomDAO{db: db}
|
||||
}
|
||||
|
||||
// Create 新建一个会议房间
|
||||
// 由调用方预先生成 RoomCode 并确认唯一,本方法不做冲突重试
|
||||
func (d *MeetingRoomDAO) Create(ctx context.Context, room *model.MeetingRoom) error {
|
||||
funcName := "dao.meeting_room_dao.Create"
|
||||
logs.Info(ctx, funcName, "创建会议房间",
|
||||
zap.String("room_code", room.RoomCode), zap.Int64("host_id", room.HostID))
|
||||
|
||||
err := d.db.WithContext(ctx).Create(room).Error
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "创建会议房间失败",
|
||||
zap.String("room_code", room.RoomCode), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByID 按主键查询
|
||||
func (d *MeetingRoomDAO) GetByID(ctx context.Context, id int64) (*model.MeetingRoom, error) {
|
||||
var room model.MeetingRoom
|
||||
err := d.db.WithContext(ctx).First(&room, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &room, nil
|
||||
}
|
||||
|
||||
// GetByCode 按会议号查询(入会流程的主要入口)
|
||||
// 返回 gorm.ErrRecordNotFound 时上层应转换为业务错误 ErrMeetingNotFound
|
||||
func (d *MeetingRoomDAO) GetByCode(ctx context.Context, code string) (*model.MeetingRoom, error) {
|
||||
funcName := "dao.meeting_room_dao.GetByCode"
|
||||
|
||||
var room model.MeetingRoom
|
||||
err := d.db.WithContext(ctx).Where("room_code = ?", code).First(&room).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logs.Error(ctx, funcName, "按会议号查询房间失败",
|
||||
zap.String("room_code", code), zap.Error(err))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &room, nil
|
||||
}
|
||||
|
||||
// ExistsCode 会议号是否已被占用(用于创建会议时的 code 冲突重试)
|
||||
func (d *MeetingRoomDAO) ExistsCode(ctx context.Context, code string) (bool, error) {
|
||||
var count int64
|
||||
err := d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("room_code = ?", code).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// MarkStarted 记录会议实际开始时间(host 首次加入时调用)
|
||||
// 仅当 status=pending 且 started_at IS NULL 时才写入,避免重复覆盖
|
||||
func (d *MeetingRoomDAO) MarkStarted(ctx context.Context, id int64, startedAt time.Time) (int64, error) {
|
||||
funcName := "dao.meeting_room_dao.MarkStarted"
|
||||
res := d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("id = ? AND status = ? AND started_at IS NULL", id, constants.MeetingStatusPending).
|
||||
Updates(map[string]interface{}{
|
||||
"status": constants.MeetingStatusActive,
|
||||
"started_at": startedAt,
|
||||
})
|
||||
if res.Error != nil {
|
||||
logs.Error(ctx, funcName, "标记会议开始失败",
|
||||
zap.Int64("room_id", id), zap.Error(res.Error))
|
||||
}
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// MarkEnded 记录会议结束(status=2 + ended_at + ended_reason)
|
||||
// 乐观锁:只对 status != ended 的行生效,避免重复结束覆盖原始 reason
|
||||
func (d *MeetingRoomDAO) MarkEnded(ctx context.Context, id int64, reason string, endedAt time.Time) (int64, error) {
|
||||
funcName := "dao.meeting_room_dao.MarkEnded"
|
||||
res := d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("id = ? AND status != ?", id, constants.MeetingStatusEnded).
|
||||
Updates(map[string]interface{}{
|
||||
"status": constants.MeetingStatusEnded,
|
||||
"ended_at": endedAt,
|
||||
"ended_reason": reason,
|
||||
})
|
||||
if res.Error != nil {
|
||||
logs.Error(ctx, funcName, "标记会议结束失败",
|
||||
zap.Int64("room_id", id), zap.String("reason", reason), zap.Error(res.Error))
|
||||
}
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// UpdateHost 更新主持人(仅修改 meeting_rooms.host_id 字段,meeting_participants.role 由 ParticipantDAO.TransferHost 在同一事务中处理)
|
||||
func (d *MeetingRoomDAO) UpdateHost(ctx context.Context, id, newHostID int64) error {
|
||||
return d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("id = ?", id).
|
||||
Update("host_id", newHostID).Error
|
||||
}
|
||||
|
||||
// UpdateSettings 更新房间级配置(settings 字段整体替换)
|
||||
// 调用方需保证 settingsJSON 是合法 JSON 字符串
|
||||
func (d *MeetingRoomDAO) UpdateSettings(ctx context.Context, id int64, settingsJSON string) error {
|
||||
return d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("id = ?", id).
|
||||
Update("settings", settingsJSON).Error
|
||||
}
|
||||
|
||||
// ListByHost 按主持人查询会议列表(支持 status 过滤 + 分页)
|
||||
// status 传 -1 表示不限
|
||||
// 返回按 created_at DESC 排序,走 idx_meeting_rooms_host_status 索引
|
||||
func (d *MeetingRoomDAO) ListByHost(ctx context.Context, hostID int64, status int, offset, limit int) ([]model.MeetingRoom, int64, error) {
|
||||
funcName := "dao.meeting_room_dao.ListByHost"
|
||||
q := d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("host_id = ?", hostID)
|
||||
if status >= 0 {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
logs.Error(ctx, funcName, "计数失败", zap.Int64("host_id", hostID), zap.Error(err))
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var rooms []model.MeetingRoom
|
||||
err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&rooms).Error
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "查询失败", zap.Int64("host_id", hostID), zap.Error(err))
|
||||
}
|
||||
return rooms, total, err
|
||||
}
|
||||
|
||||
// ListExpiredForCleanup 返回已结束且超过指定小时数的房间 ID 列表
|
||||
// 供定时任务清理 meeting_chats 使用;room 本身保留归档不删
|
||||
// 返回 limit 上限用于分批处理,避免一次捞太多
|
||||
func (d *MeetingRoomDAO) ListExpiredForCleanup(ctx context.Context, hoursAgo int, limit int) ([]int64, error) {
|
||||
funcName := "dao.meeting_room_dao.ListExpiredForCleanup"
|
||||
cutoff := time.Now().Add(-time.Duration(hoursAgo) * time.Hour)
|
||||
|
||||
var ids []int64
|
||||
err := d.db.WithContext(ctx).
|
||||
Model(&model.MeetingRoom{}).
|
||||
Where("status = ? AND ended_at IS NOT NULL AND ended_at < ?",
|
||||
constants.MeetingStatusEnded, cutoff).
|
||||
Order("ended_at ASC").
|
||||
Limit(limit).
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "查询过期房间失败", zap.Error(err))
|
||||
}
|
||||
return ids, err
|
||||
}
|
||||
Reference in New Issue
Block a user