package service import ( "context" "slices" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "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) { s.mu.Lock() defer s.mu.Unlock() // 注册表只保存当前进程内已装载 Cell,不代表 Redis lease 的全局 owner。 s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] = roomCell } func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell { s.mu.Lock() defer s.mu.Unlock() // 返回指针后由 RoomCell 自己的 mailbox 保证状态串行化。 return s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] } 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 } 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 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(), } }