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,138 @@
// Package controller 提供 meeting 模块的 HTTP 接口
package controller
import (
"net/http"
"github.com/echochat/backend/app/meeting/service"
"github.com/echochat/backend/pkg/middleware"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
)
// MeetingController 会议 REST 控制器
// Task 4 骨架阶段:所有 handler 均返回 501 Not Implemented便于路由自测与前端 mock
// Task 5/6/7 将在此填充请求解析、业务调用与错误映射
type MeetingController struct {
meetingService *service.MeetingService
}
// NewMeetingController 创建 MeetingController 实例
func NewMeetingController(meetingService *service.MeetingService) *MeetingController {
return &MeetingController{meetingService: meetingService}
}
// responseNotImplemented Task 4 占位响应Task 5/6/7 逐接口替换为真实实现
func responseNotImplemented(c *gin.Context, endpoint string) {
c.JSON(http.StatusNotImplemented, gin.H{
"code": http.StatusNotImplemented,
"message": "接口尚未实现",
"endpoint": endpoint,
})
}
// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false
func requireUserID(c *gin.Context) (int64, bool) {
userID, ok := middleware.GetCurrentUserID(c)
if !ok {
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
return 0, false
}
return userID, true
}
// CreateRoom POST /api/v1/meeting/rooms
func (ctl *MeetingController) CreateRoom(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms")
}
// GetRoom GET /api/v1/meeting/rooms/:code
func (ctl *MeetingController) GetRoom(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code")
}
// JoinRoom POST /api/v1/meeting/rooms/:code/join
func (ctl *MeetingController) JoinRoom(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/join")
}
// LeaveRoom POST /api/v1/meeting/rooms/:code/leave
func (ctl *MeetingController) LeaveRoom(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/leave")
}
// EndRoom POST /api/v1/meeting/rooms/:code/end
func (ctl *MeetingController) EndRoom(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/end")
}
// ListMyMeetings GET /api/v1/meeting/rooms?role=host|participant
func (ctl *MeetingController) ListMyMeetings(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "GET /api/v1/meeting/rooms")
}
// TransferHost POST /api/v1/meeting/rooms/:code/transfer-host
func (ctl *MeetingController) TransferHost(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/transfer-host")
}
// KickMember POST /api/v1/meeting/rooms/:code/kick
func (ctl *MeetingController) KickMember(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/kick")
}
// InviteUsers POST /api/v1/meeting/rooms/:code/invite
func (ctl *MeetingController) InviteUsers(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/invite")
}
// RedeemInvite POST /api/v1/meeting/invites/:token/redeem
func (ctl *MeetingController) RedeemInvite(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/invites/:token/redeem")
}
// SendChat POST /api/v1/meeting/rooms/:code/chats
func (ctl *MeetingController) SendChat(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/chats")
}
// ListChats GET /api/v1/meeting/rooms/:code/chats?after_id=&limit=
func (ctl *MeetingController) ListChats(c *gin.Context) {
if _, ok := requireUserID(c); !ok {
return
}
responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code/chats")
}

View File

@@ -0,0 +1,87 @@
package dao
import (
"context"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MeetingChatDAO 会议内文字聊天数据访问对象
type MeetingChatDAO struct {
db *gorm.DB
}
// NewMeetingChatDAO 创建实例
func NewMeetingChatDAO(db *gorm.DB) *MeetingChatDAO {
return &MeetingChatDAO{db: db}
}
// Create 写入一条会议聊天消息
// ID / CreatedAt 由数据库自增 + autoCreateTime 填充
func (d *MeetingChatDAO) Create(ctx context.Context, chat *model.MeetingChat) error {
funcName := "dao.meeting_chat_dao.Create"
err := d.db.WithContext(ctx).Create(chat).Error
if err != nil {
logs.Error(ctx, funcName, "写入会议聊天消息失败",
zap.Int64("room_id", chat.RoomID),
zap.Int64("user_id", chat.UserID),
zap.Error(err))
}
return err
}
// ListByRoom 按房间正序返回聊天历史(供会议室打开时加载)
// afterID 为游标id > afterID 的记录0 表示从头开始
// limit 默认 50上限 200
func (d *MeetingChatDAO) ListByRoom(ctx context.Context, roomID int64, afterID int64, limit int) ([]model.MeetingChat, error) {
funcName := "dao.meeting_chat_dao.ListByRoom"
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
q := d.db.WithContext(ctx).Where("room_id = ?", roomID)
if afterID > 0 {
q = q.Where("id > ?", afterID)
}
var list []model.MeetingChat
err := q.Order("id ASC").Limit(limit).Find(&list).Error
if err != nil {
logs.Error(ctx, funcName, "查询会议聊天失败",
zap.Int64("room_id", roomID), zap.Error(err))
}
return list, err
}
// DeleteByRoomIDs 按房间 ID 批量删除聊天消息(会议结束 24 小时后清理)
// 外层已有 meeting_rooms ON DELETE CASCADE此方法用于主动定时清理不删除 room 本身)
func (d *MeetingChatDAO) DeleteByRoomIDs(ctx context.Context, roomIDs []int64) (int64, error) {
funcName := "dao.meeting_chat_dao.DeleteByRoomIDs"
if len(roomIDs) == 0 {
return 0, nil
}
res := d.db.WithContext(ctx).
Where("room_id IN ?", roomIDs).
Delete(&model.MeetingChat{})
if res.Error != nil {
logs.Error(ctx, funcName, "批量清理聊天消息失败",
zap.Int("room_count", len(roomIDs)), zap.Error(res.Error))
}
return res.RowsAffected, res.Error
}
// CountByRoom 统计房间内聊天条数(供管理端观测)
func (d *MeetingChatDAO) CountByRoom(ctx context.Context, roomID int64) (int64, error) {
var count int64
err := d.db.WithContext(ctx).
Model(&model.MeetingChat{}).
Where("room_id = ?", roomID).
Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,255 @@
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}
}
// 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 {
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 {
return nil, err
}
return &p, nil
}
// TransferHost 事务内完成主持人转让
// 1) 旧 host 的 role 置为 Participant0
// 2) 新 host 的 role 置为 Host1
// 调用方应在同一上游事务中同时调 MeetingRoomDAO.UpdateHost 修改 meeting_rooms.host_id
// 本方法内部已自带事务,上游无需再包裹
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
}
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
}
// 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
}

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
}

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

View File

@@ -0,0 +1,21 @@
package meeting
import (
"github.com/echochat/backend/app/meeting/controller"
"github.com/echochat/backend/app/meeting/dao"
"github.com/echochat/backend/app/meeting/service"
"github.com/google/wire"
)
// MeetingSet 会议模块 Wire Provider Set
// 对外暴露:
// - *service.MeetingService —— 业务服务,供未来 ws handler / media-server 回调使用
// - *controller.MeetingController —— REST API 控制器
// 依赖的接口 NotifyPusher / UserInfoResolver / OnlineChecker 由上游 wire.Bind 绑定具体实现
var MeetingSet = wire.NewSet(
dao.NewMeetingRoomDAO,
dao.NewMeetingParticipantDAO,
dao.NewMeetingChatDAO,
service.NewMeetingService,
controller.NewMeetingController,
)

View File

@@ -0,0 +1,36 @@
// Package meeting 会议模块Phase 2e-2
package meeting
import (
"github.com/echochat/backend/app/meeting/controller"
"github.com/gin-gonic/gin"
)
// RegisterRoutes 注册 meeting 模块的全部前台路由
// 所有接口统一挂载在 /api/v1/meeting/* 前缀下,均需 JWT 认证
// 设计文档 §6.2 的 12 个接口 Task 4 骨架阶段仅搭路由层,业务实现留待 Task 5/6/7
func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth gin.HandlerFunc) {
authed := r.Group("/api/v1/meeting")
authed.Use(jwtAuth)
{
// 会议房间生命周期
authed.POST("/rooms", ctrl.CreateRoom)
authed.GET("/rooms", ctrl.ListMyMeetings)
authed.GET("/rooms/:code", ctrl.GetRoom)
authed.POST("/rooms/:code/join", ctrl.JoinRoom)
authed.POST("/rooms/:code/leave", ctrl.LeaveRoom)
authed.POST("/rooms/:code/end", ctrl.EndRoom)
// 主持人四件套Task 7
authed.POST("/rooms/:code/transfer-host", ctrl.TransferHost)
authed.POST("/rooms/:code/kick", ctrl.KickMember)
// 邀请Task 5
authed.POST("/rooms/:code/invite", ctrl.InviteUsers)
authed.POST("/invites/:token/redeem", ctrl.RedeemInvite)
// 会议内聊天Task 6
authed.POST("/rooms/:code/chats", ctrl.SendChat)
authed.GET("/rooms/:code/chats", ctrl.ListChats)
}
}

View File

@@ -0,0 +1,31 @@
// Package service 提供 meeting 模块的业务逻辑
package service
import (
"context"
authModel "github.com/echochat/backend/app/auth/model"
notifyService "github.com/echochat/backend/app/notify/service"
)
// NotifyPusher 通知推送接口
// 由 notify.service.NotifyService 隐式实现,供 meeting 模块注入使用
// 会议邀请、主持人变更、踢出会议等事件通过此接口落地通知中心
type NotifyPusher interface {
Push(ctx context.Context, payload *notifyService.PushPayload)
PushBatch(ctx context.Context, payloads []*notifyService.PushPayload)
}
// UserInfoResolver 查询用户昵称 / 头像的接口
// 由 contact.FriendshipDAO 隐式实现(已有 GetUsersByIDs 方法)
// 用于拉取会议邀请中主持人信息、成员列表头像等
type UserInfoResolver interface {
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
}
// OnlineChecker 查询用户在线状态的接口
// 由 ws.OnlineService 隐式实现(签名对齐现有实现:不返回 error查询失败按离线处理
// 用于会议邀请时判断收件人是否在线(离线则降级为仅通知入库,上线时通过未读补偿推送)
type OnlineChecker interface {
IsOnline(ctx context.Context, userID int64) bool
}

View File

@@ -0,0 +1,144 @@
package service
import (
"context"
"errors"
"github.com/echochat/backend/app/meeting/dao"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/ws"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Task 5 会继续填充具体业务逻辑时使用的哨兵错误,此处先集中定义占位
// 命名与 group/notify 模块的 Err* 风格一致,控制器可直接 errors.Is 识别
var (
ErrMeetingNotFound = errors.New("会议不存在")
ErrMeetingEnded = errors.New("会议已结束")
ErrMeetingFull = errors.New("会议已满员")
ErrMeetingPasswordWrong = errors.New("会议密码错误")
ErrNotInMeeting = errors.New("你不在此会议中")
ErrAlreadyInOtherMeeting = errors.New("你当前已在其他会议中")
ErrNotMeetingHost = errors.New("仅主持人可执行此操作")
ErrInviteTokenInvalid = errors.New("邀请链接已失效")
ErrRoomCodeConflict = errors.New("会议号生成冲突,请重试")
ErrNotImplemented = errors.New("功能尚未实现")
)
// MeetingService 会议业务服务
// Task 4 骨架阶段:仅完成依赖组装和方法签名占位,具体业务逻辑留待 Task 5/6/7 填充
// 方法返回 ErrNotImplementedController 将其映射为 501 响应,便于 Postman 与前端联调前观察路由完整性
type MeetingService struct {
roomDAO *dao.MeetingRoomDAO
participantDAO *dao.MeetingParticipantDAO
chatDAO *dao.MeetingChatDAO
db *gorm.DB
redis *redis.Client
pubsub *ws.PubSub
notifyPusher NotifyPusher
userResolver UserInfoResolver
onlineChecker OnlineChecker
}
// NewMeetingService 创建 MeetingService 实例
// 依赖通过构造函数注入,接口依赖由上游 Wire 绑定到具体实现
func NewMeetingService(
roomDAO *dao.MeetingRoomDAO,
participantDAO *dao.MeetingParticipantDAO,
chatDAO *dao.MeetingChatDAO,
db *gorm.DB,
redis *redis.Client,
pubsub *ws.PubSub,
notifyPusher NotifyPusher,
userResolver UserInfoResolver,
onlineChecker OnlineChecker,
) *MeetingService {
return &MeetingService{
roomDAO: roomDAO,
participantDAO: participantDAO,
chatDAO: chatDAO,
db: db,
redis: redis,
pubsub: pubsub,
notifyPusher: notifyPusher,
userResolver: userResolver,
onlineChecker: onlineChecker,
}
}
// ====== 会议生命周期Task 5 实现) ======
// CreateRoom 创建会议房间
// Task 5生成 room_code、bcrypt 密码、持久化 + 写 Redis room 快照
func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, title, password string, maxMembers int, settings map[string]interface{}) (*model.MeetingRoom, error) {
return nil, ErrNotImplemented
}
// GetRoomByCode 通过会议号查询房间详情(含当前成员列表)
func (s *MeetingService) GetRoomByCode(ctx context.Context, code string) (*model.MeetingRoom, error) {
return nil, ErrNotImplemented
}
// JoinRoom 用户加入会议(校验密码 / 容量 / 单点参会)
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingParticipant, error) {
return nil, ErrNotImplemented
}
// LeaveRoom 用户主动离会
// 若离开者是 host 且房间内还有其他成员,触发主持人转让(最早加入者接任)
// 若离开后房间空,设置 Redis room TTL=300s 等待重入复用
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) error {
return ErrNotImplemented
}
// EndRoom 主持人主动结束会议status=ended + 全员 left_at + 触发 mediasoup 清理)
func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string) error {
return ErrNotImplemented
}
// TransferHost 主持人转让(仅当前 host 可调用)
func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, newHostID int64) error {
return ErrNotImplemented
}
// KickMember 主持人踢出成员
func (s *MeetingService) KickMember(ctx context.Context, operatorID int64, code string, targetUserID int64) error {
return ErrNotImplemented
}
// ListMyMeetings 我主持的 / 我参与的会议列表(含分页)
func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, role string, offset, limit int) ([]model.MeetingRoom, int64, error) {
return nil, 0, ErrNotImplemented
}
// ====== 邀请链接Task 5 ======
// CreateInviteToken 生成邀请链接 Token 并写 RedisTTL 600s
func (s *MeetingService) CreateInviteToken(ctx context.Context, inviterID int64, code string, inviteeID int64) (string, error) {
return "", ErrNotImplemented
}
// RedeemInviteToken 点击邀请链接时兑换 Token校验 + 删除)
func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (string, error) {
return "", ErrNotImplemented
}
// InviteUsers 主持人邀请用户(批量,走 notify.Push 发送 meeting_invite 通知)
func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64, groupIDs []int64) error {
return ErrNotImplemented
}
// ====== 会议内聊天Task 6 ======
// SendChatMessage 写入会议聊天 + 向房间内所有成员广播 WS meeting.chat.message
func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code, content string) (*model.MeetingChat, error) {
return nil, ErrNotImplemented
}
// ListChatMessages 加载会议聊天历史(游标分页)
func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, afterID int64, limit int) ([]model.MeetingChat, error) {
return nil, ErrNotImplemented
}