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 } // 创建态直接初始化 RoomState,owner 作为首个在线用户进入房间。 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 }