278 lines
10 KiB
Go
278 lines
10 KiB
Go
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
|
||
}
|