package service import ( "context" "fmt" "strings" "unicode/utf8" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/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" ) const ( roomExtTitleKey = "title" roomExtCoverURLKey = "cover_url" roomExtDescriptionKey = "description" roomExtShortIDKey = "room_short_id" // defaultRoomAvatar 是房间头像缺省值,语义上代表系统默认资源,不绑定外部 CDN 地址。 defaultRoomAvatar = "hyapp://room/default-avatar" // RoomExt 的展示字段最终会进入 room_list_entries 和客户端快照,服务端在入口裁剪长度边界。 maxRoomNameRunes = 128 maxRoomAvatarRunes = 512 maxRoomDescriptionRunes = 512 ) // roomProfileInput 是创建房间时需要落到 RoomExt 的展示资料集合。 type roomProfileInput struct { Name string Avatar string Description string ShortID string } // CreateRoom 创建一个新的房间执行单元,并把首个快照和事件落盘。 func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) 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") } profile, err := normalizeCreateRoomProfile(req) if err != nil { return nil, err } seatCount, err := s.resolveRoomSeatCount(ctx, req.GetSeatCount()) if err != nil { return nil, err } actorUserID := req.GetMeta().GetActorUserId() if actorUserID == 0 { // CreateRoom 的 owner/host 默认来自已鉴权调用方,不允许匿名内部命令造房间。 return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required") } if s.isDraining() { return nil, xerr.New(xerr.Unavailable, "room service is draining") } // protobuf 请求先收敛为 command,后续幂等、持久化和恢复都只认识 command 模型。 cmd := command.CreateRoom{ Base: baseFromMeta(req.GetMeta()), OwnerUserID: actorUserID, HostUserID: actorUserID, SeatCount: seatCount, Mode: mode, VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), RoomName: profile.Name, RoomAvatar: profile.Avatar, RoomDescription: profile.Description, RoomShortID: normalizeRoomShortID(req.GetRoomShortId(), actorUserID), } 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 } // CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。 s.projectRoomPresenceBestEffort(ctx, snapshot) return &roomv1.CreateRoomResponse{ Result: commandResult(false, snapshot.GetVersion(), s.clock.Now()), Room: snapshot, }, nil } // 创建房间前先获得 owner lease,避免两个节点同时创建同一个 room_id。 lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(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 existing, exists, err := s.repository.GetRoomMetaByOwner(ctx, cmd.OwnerUserID); err != nil { return nil, err } else if exists { // 一个用户只能创建一个房间;加入、主持或管理别人的房间不受这个 owner 限制影响。 return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID)) } if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil { // 房间创建必须先保证外部群组可用。 return nil, err } if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); 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 applyRoomProfileExt(roomState, roomProfileInput{ Name: cmd.RoomName, Avatar: cmd.RoomAvatar, Description: cmd.RoomDescription, ShortID: cmd.RoomShortID, }) 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, RoomName: cmd.RoomName, RoomAvatar: cmd.RoomAvatar, RoomDescription: cmd.RoomDescription, }) if err != nil { return nil, err } // 首次创建按 meta -> command log -> outbox -> snapshot 的顺序落盘,保证恢复有基础元数据和首版本命令。 if err := s.repository.SaveRoomMeta(ctx, RoomMeta{ AppCode: cmd.AppCode, RoomID: cmd.RoomID(), OwnerUserID: cmd.OwnerUserID, HostUserID: cmd.HostUserID, SeatCount: cmd.SeatCount, Mode: cmd.Mode, Status: state.RoomStatusActive, VisibleRegionID: cmd.VisibleRegionID, RoomShortID: cmd.RoomShortID, }); err != nil { return nil, err } if err := s.repository.SaveMutation(ctx, MutationCommit{ Command: CommandRecord{ AppCode: cmd.AppCode, RoomID: cmd.RoomID(), RoomVersion: roomState.Version, CommandID: cmd.ID(), CommandType: cmd.Type(), Payload: payload, Replayable: true, OwnerNodeID: s.nodeID, LeaseToken: lease.LeaseToken, CreatedAtMS: now.UnixMilli(), }, OutboxRecords: []outbox.Record{createdEvent}, }); err != nil { return nil, err } snapshot := snapshotWithApp(ctx, roomState.ToProto()) if err := s.persistSnapshot(ctx, snapshot); err != nil { return nil, err } s.projectRoomListBestEffort(ctx, snapshot) // owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。 s.projectRoomPresenceBestEffort(ctx, snapshot) // 持久化成功后再安装内存 Cell,避免内存有房间但恢复来源不完整。 s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit))) return &roomv1.CreateRoomResponse{ Result: commandResult(true, snapshot.GetVersion(), now), Room: snapshot, }, nil } func normalizeRoomShortID(raw string, actorUserID int64) string { if value := strings.TrimSpace(raw); value != "" { return value } return fmt.Sprintf("%d", actorUserID) } // normalizeCreateRoomProfile 固定创建房间展示资料的入口语义,避免 gateway 和恢复路径各自猜默认值。 func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest) (roomProfileInput, error) { name := strings.TrimSpace(req.GetRoomName()) if name == "" { // 房间名是创建页必填字段,也是列表卡片的最小可展示信息。 return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_name is required") } if utf8.RuneCountInString(name) > maxRoomNameRunes { // 使用 rune 计数避免中文房名被字节长度误伤;DB 和列表读模型仍按保守上限收敛。 return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_name is too long") } avatar := strings.TrimSpace(req.GetRoomAvatar()) if avatar == "" { // 默认头像由 room-service 决定,客户端和 gateway 不需要复制同一套兜底策略。 avatar = defaultRoomAvatar } if utf8.RuneCountInString(avatar) > maxRoomAvatarRunes { // cover_url 会投影到 room_list_entries.cover_url,超长值必须在写入前拒绝。 return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_avatar is too long") } description := strings.TrimSpace(req.GetRoomDescription()) if utf8.RuneCountInString(description) > maxRoomDescriptionRunes { // 简介暂时只服务房间详情,不允许无限大字符串进入 snapshot/command log。 return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_description is too long") } return roomProfileInput{Name: name, Avatar: avatar, Description: description}, nil } // applyRoomProfileExt 把房间展示资料写入 RoomExt,保持 RoomState 主结构只承载高频核心字段。 func applyRoomProfileExt(roomState *state.RoomState, profile roomProfileInput) { if roomState.RoomExt == nil { // 恢复旧快照或测试态可能拿到 nil map,写入前必须补齐容器。 roomState.RoomExt = make(map[string]string) } roomState.RoomExt[roomExtTitleKey] = profile.Name roomState.RoomExt[roomExtCoverURLKey] = profile.Avatar roomState.RoomExt[roomExtDescriptionKey] = profile.Description roomState.RoomExt[roomExtShortIDKey] = profile.ShortID }