package service import ( "context" "log/slog" "slices" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/services/room-service/internal/room/cell" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/router" ) type loadedRoomRef struct { // AppCode 是运行态 Cell 所属租户,后台 worker 必须用它恢复 ctx。 AppCode string // RoomID 是已装载 Room Cell 的业务房间 ID。 RoomID string } func (s *Service) installCell(ctx context.Context, roomID string, roomCell *cell.RoomCell, leaseToken string) { s.mu.Lock() defer s.mu.Unlock() // 注册表只保存当前进程内已装载 Cell;leaseToken 决定它能否继续承载写路径。 s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] = &loadedCell{roomCell: roomCell, leaseToken: leaseToken} } func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell { s.mu.Lock() defer s.mu.Unlock() // 返回指针后由 RoomCell 自己的 mailbox 保证状态串行化。 loaded := s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] if loaded == nil { return nil } return loaded.roomCell } func (s *Service) loadCellForLease(ctx context.Context, roomID string, lease router.Lease) *cell.RoomCell { key := runtimeRoomKey(appcode.FromContext(ctx), roomID) s.mu.Lock() defer s.mu.Unlock() loaded := s.cells[key] if loaded == nil { return nil } if loaded.leaseToken == "" || loaded.leaseToken != lease.LeaseToken { // lease token 变化代表当前节点经历过执行权断档;旧 Cell 可能落后于新 owner 已提交的 command log,必须丢弃后走恢复。 delete(s.cells, key) return nil } return loaded.roomCell } func (s *Service) loadCellForCurrentOwner(ctx context.Context, roomID string) (*cell.RoomCell, error) { if s.directory == nil { // 测试中的极简 Service 可能没有装配 directory;生产 HealthCheck 会拒绝这种配置。 return s.loadCell(ctx, roomID), nil } lease, exists, err := s.directory.Lookup(ctx, runtimeRoomKeyFromContext(ctx, roomID)) if err != nil { return nil, err } if !exists || lease.NodeID != s.nodeID || !lease.ValidAt(s.clock.Now()) { // 只读路径不能主动接管 lease;没有当前 owner 身份时必须走持久化恢复,避免旧 Cell 放行过期状态。 return nil, nil } return s.loadCellForLease(ctx, roomID, lease), nil } func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) { if s == nil { return } app := appcode.FromContext(ctx) key := runtimeRoomKey(app, roomID) s.mu.Lock() delete(s.cells, key) s.mu.Unlock() if s.directory != nil { if _, err := s.directory.ReleaseOwner(ctx, key, s.nodeID); err != nil { // 删除事实已在 MySQL 提交,释放 lease 失败只影响其他节点接管等待 TTL,不能回滚删除结果。 logx.Warn(ctx, "room_delete_lease_release_failed", slog.String("node_id", s.nodeID), slog.String("app_code", app), slog.String("room_id", roomID), slog.String("error", err.Error()), ) } } } func (s *Service) loadedRoomRefs() []loadedRoomRef { s.mu.Lock() defer s.mu.Unlock() // 复制运行态房间引用后释放锁,避免后台清理期间阻塞其他房间装载。 roomRefs := make([]loadedRoomRef, 0, len(s.cells)) for key := range s.cells { roomRefs = append(roomRefs, runtimeRoomRef(key)) } return roomRefs } func runtimeRoomKey(appCode string, roomID string) string { appCode = appcode.Normalize(appCode) // 使用 NUL 分隔 app_code 和 room_id,避免普通字符串拼接出现歧义。 return appCode + "\x00" + roomID } // RuntimeRoomKey 返回 Redis owner lease 使用的运行时路由 key。 // transport 层 owner 转发必须和领域层 EnsureOwner 使用同一个 key,否则会出现同一房间多 owner。 func RuntimeRoomKey(appCode string, roomID string) string { return runtimeRoomKey(appCode, roomID) } func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string { // 命令入口统一从 ctx 读取 app_code,避免 service 方法忘记把租户键传给 Redis lease。 return runtimeRoomKey(appcode.FromContext(ctx), roomID) } func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, now time.Time) (router.Lease, bool, error) { // 后台 worker 通过 EnsureOwner 续租当前 Cell,不能在没有 lease 的情况下读取旧内存态。 lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, now, s.leaseTTL) if err != nil { return router.Lease{}, false, err } if lease.NodeID != s.nodeID || !lease.ValidAt(now) { // 后台 worker 只处理自己仍然拥有或可安全续租的 Cell。 // 如果其他节点已经接管并持有有效 lease,当前节点必须跳过,避免旧内存态覆盖新 owner。 return router.Lease{}, false, nil } if roomCell := s.loadCellForLease(ctx, roomRef.RoomID, lease); roomCell == nil { // 旧 Cell 的 token 不连续时,后台 worker 不能靠重新抢到的 lease 继续写旧内存分支。 return router.Lease{}, false, nil } return lease, true, nil } func (s *Service) verifyLoadedRoomLease(ctx context.Context, roomRef loadedRoomRef, lease router.Lease, now time.Time) (bool, error) { // 使用同一个 lease token 做 fencing 校验,防止 worker 批处理期间 owner 已经切走。 return s.directory.VerifyOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, lease.LeaseToken, now) } func runtimeRoomRef(key string) loadedRoomRef { // runtime key 是内部格式;解析失败时保留 room_id,便于旧测试数据仍能被安全跳过或排查。 for index := 0; index < len(key); index++ { if key[index] == 0 { return loadedRoomRef{AppCode: appcode.Normalize(key[:index]), RoomID: key[index+1:]} } } return loadedRoomRef{AppCode: "", RoomID: key} } func firstNonZeroInt64(values ...int64) int64 { for _, value := range values { if value != 0 { return value } } return 0 } func commandResult(applied bool, roomVersion int64, now time.Time) *roomv1.CommandResult { // 所有命令响应统一带 applied、room_version 和服务端时间,便于客户端处理幂等结果。 return &roomv1.CommandResult{ Applied: applied, RoomVersion: roomVersion, ServerTimeMs: now.UnixMilli(), } } func baseFromMeta(meta *roomv1.RequestMeta) command.Base { if meta == nil { // 上层 mutateRoom 会校验关键字段,nil meta 在这里先收敛为空 Base。 return command.Base{} } // RequestMeta 是跨服务 protobuf 契约,领域层统一转换成 command.Base。 return command.Base{ AppCode: appcode.Normalize(meta.GetAppCode()), RequestID: meta.GetRequestId(), CommandID: meta.GetCommandId(), ActorID: meta.GetActorUserId(), Room: meta.GetRoomId(), GatewayNodeID: meta.GetGatewayNodeId(), SessionID: meta.GetSessionId(), SentAtMS: meta.GetSentAtMs(), } } func (s *Service) canonicalRoomCommandMeta(ctx context.Context, meta *roomv1.RequestMeta) (*roomv1.RequestMeta, error) { if meta == nil { return nil, nil } requestedRoomID := strings.TrimSpace(meta.GetRoomId()) if requestedRoomID == "" { return meta, nil } if roomMeta, exists, err := s.repository.GetRoomMeta(ctx, requestedRoomID); err != nil { return nil, err } else if exists { if roomMeta.RoomID == meta.GetRoomId() { return meta, nil } // 精确 room_id 命中时也用数据库里的规范值,避免首尾空白进入 lease key 或 command log。 return requestMetaWithRoomID(meta, roomMeta.RoomID), nil } roomMeta, exists, err := s.repository.GetRoomMetaByShortID(ctx, requestedRoomID) if err != nil || !exists { return meta, err } // 客户端可能只知道展示短房号;进入 Room Cell 前必须收敛到内部 room_id,保证 lease、command log、snapshot 分区一致。 return requestMetaWithRoomID(meta, roomMeta.RoomID), nil } func requestMetaWithRoomID(meta *roomv1.RequestMeta, roomID string) *roomv1.RequestMeta { if meta == nil { return nil } return &roomv1.RequestMeta{ RequestId: meta.GetRequestId(), CommandId: meta.GetCommandId(), ActorUserId: meta.GetActorUserId(), RoomId: roomID, GatewayNodeId: meta.GetGatewayNodeId(), SessionId: meta.GetSessionId(), SentAtMs: meta.GetSentAtMs(), AppCode: meta.GetAppCode(), } } func contextFromMeta(ctx context.Context, meta *roomv1.RequestMeta) context.Context { if meta == nil { return appcode.WithContext(ctx, "") } return appcode.WithContext(ctx, meta.GetAppCode()) } func findProtoUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser { if snapshot == nil { return nil } // protobuf 快照用户列表规模受房间在线人数限制,线性扫描足够简单。 for _, user := range snapshot.GetOnlineUsers() { if user.GetUserId() == userID { return user } } return nil } func containsID(values []int64, target int64) bool { // 快照里的集合以有序数组表达,守卫查询用线性查找即可。 return slices.Contains(values, target) } func snapshotUserBanned(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) bool { if snapshot == nil || userID <= 0 { return false } for _, ban := range snapshot.GetBanStates() { if ban.GetUserId() != userID { continue } // ban_states 是带时间边界的权威状态;过期后即使 ban_user_ids 还没刷新也不能继续拦截。 return ban.GetExpiresAtMs() == 0 || ban.GetExpiresAtMs() > nowMS } // 旧快照没有 ban_states 时只能按永久 ban 解释 ban_user_ids,避免恢复路径放宽权限。 return containsID(snapshot.GetBanUserIds(), userID) } func cloneProtoRank(items []state.RankItem) []*roomv1.RankItem { // SendGift 响应需要 protobuf 榜单项,不能直接暴露内部 state.RankItem。 result := make([]*roomv1.RankItem, 0, len(items)) for _, item := range items { result = append(result, &roomv1.RankItem{ UserId: item.UserID, Score: item.Score, GiftValue: item.GiftValue, UpdatedAtMs: item.UpdatedAtMS, }) } return result } // NewRequestMeta 是测试和 stub 组装命令元信息时的便捷函数。 func NewRequestMeta(roomID string, actorUserID int64) *roomv1.RequestMeta { // 该函数只用于测试和 stub;真实入口由 gateway 生成 request_id,并透传客户端动作级 command_id。 return &roomv1.RequestMeta{ AppCode: appcode.Default, RequestId: idgen.New("req"), CommandId: idgen.New("cmd"), ActorUserId: actorUserID, RoomId: roomID, GatewayNodeId: "gateway-local", SessionId: idgen.New("sess"), SentAtMs: time.Now().UnixMilli(), } }