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,19 @@
package model
import "time"
// MeetingChat 会议内文字聊天消息模型,对应 meeting_chats 表
// 独立于 im_messages不进入常规 IM 消息流
// 清理策略会议结束status=2后 24 小时由定时任务批量 DELETE
type MeetingChat struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识
RoomID int64 `json:"room_id" gorm:"not null;index:idx_meeting_chats_room_created,priority:1"` // 所属房间 ID
UserID int64 `json:"user_id" gorm:"not null"` // 发送者用户 ID
Content string `json:"content" gorm:"type:text;not null"` // 消息内容,纯文本
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_chats_room_created,priority:2,sort:asc"` // 发送时间
}
// TableName 指定数据库表名
func (MeetingChat) TableName() string {
return "meeting_chats"
}

View File

@@ -0,0 +1,27 @@
package model
import "time"
// MeetingParticipant 会议参与者模型,对应 meeting_participants 表
// 一次入会对应一条记录重入left_at 已填的情况下再次加入)通过 UPDATE 复用
// 主持人转让通过事务:旧 host.role=0 + 新 host.role=1
type MeetingParticipant struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 参与者记录唯一标识
RoomID int64 `json:"room_id" gorm:"not null;uniqueIndex:uk_meeting_participants_room_user,priority:1;index:idx_meeting_participants_room,priority:1"` // 所属房间 ID与 user_id 组合唯一
UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:uk_meeting_participants_room_user,priority:2;index:idx_meeting_participants_user,priority:1"` // 用户 ID
Role int `json:"role" gorm:"not null;default:0"` // 0=普通1=主持人MVP 仅此两档2=联合主持人
JoinedAt time.Time `json:"joined_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_participants_room,priority:2,sort:asc;index:idx_meeting_participants_user,priority:2,sort:desc"` // 加入时间
LeftAt *time.Time `json:"left_at" gorm:"type:timestamp(0)"` // 离开时间NULL 表示仍在会议中
LeftReason *string `json:"left_reason" gorm:"size:20"` // 离会原因self / kicked / host_end / empty_ttl / disconnect
Duration int `json:"duration" gorm:"not null;default:0"` // 本次参会时长left_at 写入时同步计算
}
// TableName 指定数据库表名
func (MeetingParticipant) TableName() string {
return "meeting_participants"
}
// IsActive 参与者当前是否仍在会议中left_at 为 NULL
func (p *MeetingParticipant) IsActive() bool {
return p.LeftAt == nil
}

View File

@@ -0,0 +1,30 @@
// Package model 提供 meeting 模块的数据库模型
package model
import "time"
// MeetingRoom 会议房间模型,对应 meeting_rooms 表
// 记录会议的创建信息、主持人、容量、状态与生命周期时间点
// 对外使用 RoomCodeXXX-XXX-XXX寻址内部使用 ID主键寻址
type MeetingRoom struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 会议房间唯一标识
RoomCode string `json:"room_code" gorm:"size:20;not null;uniqueIndex"` // 用户可见的会议号 XXX-XXX-XXX全局唯一
Title string `json:"title" gorm:"size:200;not null"` // 会议标题
HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID
Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约
PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离
MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数MVP 应用层强制 8
Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束
ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL
StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间
EndedAt *time.Time `json:"ended_at" gorm:"type:timestamp(0)"` // 实际结束时间
EndedReason *string `json:"ended_reason" gorm:"size:20"` // 结束原因host_ended / empty_ttl / admin_force / system_error
Settings string `json:"settings" gorm:"type:jsonb;not null;default:'{}'"` // JSON 字符串,保存房间级开关
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_rooms_host_status,priority:3,sort:desc"` // 创建时间
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 更新时间
}
// TableName 指定数据库表名
func (MeetingRoom) TableName() string {
return "meeting_rooms"
}