165 lines
5.7 KiB
Go
165 lines
5.7 KiB
Go
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
|
||
}
|