2026-04-27 02:29:42 +08:00

154 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"fmt"
roomeventsv1 "hyapp/api/proto/events/room/v1"
roomv1 "hyapp/api/proto/room/v1"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/cell"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
// CreateRoom 创建一个新的房间执行单元,并把首个快照和事件落盘。
func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" {
// 创建房间必须指定 room_id后续 lease、meta、snapshot 都依赖它。
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
// protobuf 请求先收敛为 command后续幂等、持久化和恢复都只认识 command 模型。
cmd := command.CreateRoom{
Base: baseFromMeta(req.GetMeta()),
OwnerUserID: req.GetOwnerUserId(),
HostUserID: req.GetHostUserId(),
SeatCount: req.GetSeatCount(),
Mode: req.GetMode(),
}
if cmd.SeatCount <= 0 {
// 没有麦位的房间不符合语音房 v1 的状态模型。
return nil, xerr.New(xerr.InvalidArgument, "seat_count must be positive")
}
if cmd.OwnerUserID == 0 || cmd.HostUserID == 0 {
// owner/host 是默认管理员集合来源,不能缺失。
return nil, xerr.New(xerr.InvalidArgument, "owner and host are required")
}
if seen, err := s.repository.HasCommand(ctx, cmd.RoomID(), cmd.ID()); err != nil {
return nil, err
} else if seen {
// CreateRoom 幂等重试也要确保腾讯云 IM 群组存在,避免上次创建在外部桥接处失败。
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
return nil, err
}
// 重复 CreateRoom 不再写入状态,直接返回当前快照保证命令幂等。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return nil, err
}
return &roomv1.CreateRoomResponse{
Result: commandResult(false, snapshot.GetVersion(), s.clock.Now()),
Room: snapshot,
}, nil
}
// 创建房间前先获得 owner lease避免两个节点同时创建同一个 room_id。
lease, err := s.directory.EnsureOwner(ctx, cmd.RoomID(), s.nodeID, s.clock.Now(), s.leaseTTL)
if err != nil {
return nil, err
}
if lease.NodeID != s.nodeID {
// 有其他有效 owner 时当前节点不能创建或接管该房间。
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
}
if _, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID()); err != nil {
return nil, err
} else if exists {
// meta 已存在但 command 未命中,说明 room_id 已被其他创建命令占用。
return nil, xerr.New(xerr.Conflict, "room already exists")
}
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
// 房间创建必须先保证外部群组可用。
return nil, err
}
// 创建态直接初始化 RoomStateowner 作为首个在线用户进入房间。
now := s.clock.Now()
roomState := state.NewRoomState(cmd.RoomID(), cmd.OwnerUserID, cmd.HostUserID, cmd.SeatCount, cmd.Mode)
roomState.Version = 1
roomState.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
UserID: cmd.ActorUserID(),
Role: "owner",
JoinedAtMS: now.UnixMilli(),
LastSeenAtMS: now.UnixMilli(),
}
payload, err := command.Serialize(cmd)
if err != nil {
return nil, err
}
// RoomCreated 进入 outbox供 activity/audit 等房间外系统异步消费。
createdEvent, err := outbox.Build(cmd.RoomID(), "RoomCreated", roomState.Version, now, &roomeventsv1.RoomCreated{
OwnerUserId: cmd.OwnerUserID,
HostUserId: cmd.HostUserID,
SeatCount: cmd.SeatCount,
Mode: cmd.Mode,
})
if err != nil {
return nil, err
}
// 首次创建按 meta -> command log -> outbox -> snapshot 的顺序落盘,保证恢复有基础元数据和首版本命令。
if err := s.repository.SaveRoomMeta(ctx, RoomMeta{
RoomID: cmd.RoomID(),
OwnerUserID: cmd.OwnerUserID,
HostUserID: cmd.HostUserID,
SeatCount: cmd.SeatCount,
Mode: cmd.Mode,
Status: "active",
}); err != nil {
return nil, err
}
if err := s.repository.SaveCommand(ctx, CommandRecord{
RoomID: cmd.RoomID(),
RoomVersion: roomState.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: payload,
Replayable: true,
CreatedAt: now,
}); err != nil {
return nil, err
}
if err := s.repository.SaveOutbox(ctx, []outbox.Record{createdEvent}); err != nil {
return nil, err
}
snapshot := roomState.ToProto()
if err := s.persistSnapshot(ctx, snapshot); err != nil {
return nil, err
}
// 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。
s.installCell(cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))
return &roomv1.CreateRoomResponse{
Result: commandResult(true, snapshot.GetVersion(), now),
Room: snapshot,
}, nil
}