2026-04-30 02:30:32 +08:00

165 lines
5.7 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"
"strings"
roomeventsv1 "hyapp/api/proto/events/room/v1"
roomv1 "hyapp/api/proto/room/v1"
"hyapp/pkg/roomid"
"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")
}
if !roomid.ValidStringID(req.GetMeta().GetRoomId()) {
// 新房间必须能同时映射到腾讯云 IM GroupID 和 RTC strRoomId避免创建后无法进语音。
return nil, xerr.New(xerr.InvalidArgument, "room_id format is invalid")
}
mode := strings.TrimSpace(req.GetMode())
if mode == "" {
// mode 是房间状态对客户端和后续策略分发的基础维度,不能创建空模式房。
return nil, xerr.New(xerr.InvalidArgument, "mode is required")
}
actorUserID := req.GetMeta().GetActorUserId()
if actorUserID == 0 {
// CreateRoom 的 owner/host 默认来自已鉴权调用方,不允许匿名内部命令造房间。
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
}
// protobuf 请求先收敛为 command后续幂等、持久化和恢复都只认识 command 模型。
cmd := command.CreateRoom{
Base: baseFromMeta(req.GetMeta()),
OwnerUserID: actorUserID,
HostUserID: actorUserID,
SeatCount: req.GetSeatCount(),
Mode: mode,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
}
if cmd.SeatCount <= 0 {
// 没有麦位的房间不符合语音房 v1 的状态模型。
return nil, xerr.New(xerr.InvalidArgument, "seat_count must be positive")
}
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
if err != nil {
return nil, err
}
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(),
}
// 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",
VisibleRegionID: cmd.VisibleRegionID,
}); 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
}
s.projectRoomListBestEffort(ctx, snapshot)
// 持久化成功后再安装内存 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
}