2026-05-12 20:07:49 +08:00

324 lines
12 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"
"log/slog"
"strings"
"time"
"unicode/utf8"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/logx"
"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())
startedAt := time.Now()
var idempotencyMS int64
var leaseMS int64
var saveMutationMS int64
var snapshotMS int64
var projectionMS int64
var saveRoomMetaMS int64
if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" {
// 创建房间必须指定 room_id后续 lease、meta、snapshot 都依赖它。
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
if strings.TrimSpace(req.GetMeta().GetCommandId()) == "" {
// 创建房间同样写 command logcommand_id 必须来自客户端用户动作,不能用 request_id 代替。
return nil, xerr.New(xerr.InvalidArgument, "command_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),
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return nil, err
}
if seen {
// 重复 CreateRoom 不再写入状态,直接返回当前快照保证命令幂等。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return nil, err
}
// CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。
projectionStartedAt := time.Now()
s.projectRoomPresenceBestEffort(ctx, snapshot)
projectionMS = elapsedMS(projectionStartedAt)
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), false, elapsedMS(startedAt), idempotencyMS, 0, 0, 0, projectionMS, 0, 0, 0, false, false)
return &roomv1.CreateRoomResponse{
Result: commandResult(false, snapshot.GetVersion(), s.clock.Now()),
Room: snapshot,
}, nil
}
// 创建房间前先获得 owner lease避免两个节点同时创建同一个 room_id。
leaseStartedAt := time.Now()
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, cmd.RoomID()), s.nodeID, s.clock.Now(), s.leaseTTL)
leaseMS = elapsedMS(leaseStartedAt)
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.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); 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.VisibleRegionID = cmd.VisibleRegionID
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 的顺序落盘,保证恢复有基础元数据和首版本命令。
saveMetaStartedAt := time.Now()
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
}
saveRoomMetaMS = elapsedMS(saveMetaStartedAt)
saveStartedAt := time.Now()
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
}
saveMutationMS = elapsedMS(saveStartedAt)
snapshot := snapshotWithApp(ctx, roomState.ToProto())
snapshotStartedAt := time.Now()
if err := s.persistSnapshot(ctx, snapshot); err != nil {
return nil, err
}
snapshotMS = elapsedMS(snapshotStartedAt)
projectionStartedAt := time.Now()
s.projectRoomListBestEffort(ctx, snapshot)
// owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。
s.projectRoomPresenceBestEffort(ctx, snapshot)
projectionMS = elapsedMS(projectionStartedAt)
// 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, 0, true, true,
slog.Int64("save_room_meta_ms", saveRoomMetaMS),
)
return &roomv1.CreateRoomResponse{
Result: commandResult(true, snapshot.GetVersion(), now),
Room: snapshot,
}, nil
}
func logRoomCommandTiming(ctx context.Context, roomID string, commandID string, commandType string, applied bool, totalMS int64, idempotencyMS int64, leaseMS int64, saveMutationMS int64, snapshotMS int64, projectionMS int64, walletDebitMS int64, activityMS int64, tencentIMMS int64, activityAsyncOutbox bool, tencentIMAsyncOutbox bool, extra ...slog.Attr) {
attrs := []slog.Attr{
slog.String("room_id", roomID),
slog.String("command_id", commandID),
slog.String("command_type", commandType),
slog.Bool("applied", applied),
slog.Int64("total_ms", totalMS),
slog.Int64("idempotency_ms", idempotencyMS),
slog.Int64("lease_ms", leaseMS),
slog.Int64("save_mutation_ms", saveMutationMS),
slog.Int64("snapshot_ms", snapshotMS),
slog.Int64("projection_ms", projectionMS),
slog.Int64("wallet_debit_ms", walletDebitMS),
slog.Int64("activity_ms", activityMS),
slog.Int64("tencent_im_ms", tencentIMMS),
slog.Bool("activity_async_outbox", activityAsyncOutbox),
slog.Bool("tencent_im_async_outbox", tencentIMAsyncOutbox),
}
attrs = append(attrs, extra...)
logx.Info(ctx, "room_command_timing", attrs...)
}
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
}