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:
bujinyuan
2026-04-21 16:04:57 +08:00
parent 6e2e792db0
commit d22f61fb9b
23 changed files with 1474 additions and 70 deletions

View 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
}