package service import ( "context" "log/slog" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "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" ) // JoinRoom 把用户业务态接入房间。 func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) password := strings.TrimSpace(req.GetPassword()) // JoinRoom 只维护 room-service presence,不创建任何本地长连接订阅。 cmd := command.JoinRoom{ Base: baseFromMeta(req.GetMeta()), Role: req.GetRole(), ActorCountryID: req.GetActorCountryId(), ActorRegionID: req.GetActorRegionId(), // entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。 EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()), } if cmd.Role == "" { // 客户端不传角色时按普通观众进入房间。 cmd.Role = "audience" } if err := s.requireRoomOpenForEntry(ctx, cmd.RoomID()); err != nil { return nil, err } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists { if ban.ActiveAt(now.UnixMilli()) { // 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。 return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned") } // 限时 ban 过期后由下一次进房命令清理,避免定时任务介入 Room Cell owner 状态。 delete(current.BanUsers, cmd.ActorUserID()) } if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) { // 锁房校验必须覆盖新进房和已有 presence 的重连;否则房主改密后,悬浮保活或 stale 未清理用户会绕过最新密码。 return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid") } if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists { // 重复 JoinRoom 表示重连或刷新业务 presence,只更新 last_seen,不重复发进房系统消息。 existing.LastSeenAtMS = now.UnixMilli() current.Version++ snapshot := current.ToProto() return mutationResult{ snapshot: snapshot, user: findProtoUser(snapshot, cmd.ActorUserID()), }, nil, nil } current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{ UserID: cmd.ActorUserID(), Role: cmd.Role, JoinedAtMS: now.UnixMilli(), LastSeenAtMS: now.UnixMilli(), } current.Version++ // JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。 joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{ UserId: cmd.ActorUserID(), Role: cmd.Role, VisibleRegionId: current.VisibleRegionID, CountryId: cmd.ActorCountryID, RegionId: firstNonZeroInt64(cmd.ActorRegionID, current.VisibleRegionID), // outbox 事件必须带座驾快照;否则异步补偿投递 IM 时会丢失入场座驾。 EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle), }) if err != nil { return mutationResult{}, nil, err } snapshot := current.ToProto() return mutationResult{ snapshot: snapshot, user: findProtoUser(snapshot, cmd.ActorUserID()), syncEvent: &tencentim.RoomEvent{ EventID: joinedEvent.EventID, RoomID: current.RoomID, EventType: "room_user_joined", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.ActorUserID(), RoomVersion: current.Version, // syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。 EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle), }, }, []outbox.Record{joinedEvent}, nil }) if err != nil { return nil, err } s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID()) return &roomv1.JoinRoomResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), User: result.user, Room: result.snapshot, }, nil } func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot { if item == nil || item.GetResourceId() <= 0 { return command.EntryVehicleSnapshot{} } // 只裁剪字符串,不做有效期二次判断;wallet batch RPC 已经负责过期佩戴过滤。 return command.EntryVehicleSnapshot{ ResourceID: item.GetResourceId(), ResourceCode: strings.TrimSpace(item.GetResourceCode()), Name: strings.TrimSpace(item.GetName()), AssetURL: strings.TrimSpace(item.GetAssetUrl()), PreviewURL: strings.TrimSpace(item.GetPreviewUrl()), AnimationURL: strings.TrimSpace(item.GetAnimationUrl()), MetadataJSON: strings.TrimSpace(item.GetMetadataJson()), EntitlementID: strings.TrimSpace(item.GetEntitlementId()), ExpiresAtMS: item.GetExpiresAtMs(), } } func eventEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *roomeventsv1.RoomEntryVehicleSnapshot { if item.ResourceID <= 0 { return nil } // 事件 proto 是补偿投递、统计消费的稳定事实,字段不依赖后续资源表变更。 return &roomeventsv1.RoomEntryVehicleSnapshot{ ResourceId: item.ResourceID, ResourceCode: item.ResourceCode, Name: item.Name, AssetUrl: item.AssetURL, PreviewUrl: item.PreviewURL, AnimationUrl: item.AnimationURL, MetadataJson: item.MetadataJSON, EntitlementId: item.EntitlementID, ExpiresAtMs: item.ExpiresAtMS, } } func imEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot { if item.ResourceID <= 0 { return nil } // Tencent IM payload 使用客户端展示字段,保持和 /appearance 的资源字段命名一致。 return &tencentim.RoomEntryVehicleSnapshot{ ResourceID: item.ResourceID, ResourceCode: item.ResourceCode, Name: item.Name, AssetURL: item.AssetURL, PreviewURL: item.PreviewURL, AnimationURL: item.AnimationURL, MetadataJSON: item.MetadataJSON, EntitlementID: item.EntitlementID, ExpiresAtMS: item.ExpiresAtMS, } } // projectRoomVisitBestEffort 记录 Mine/Visited tab 的房间访问索引。 // 这是列表读模型,不参与 Room Cell 提交;失败只记录日志,不能影响已经成功的 JoinRoom。 func (s *Service) projectRoomVisitBestEffort(ctx context.Context, userID int64, roomID string) { if userID <= 0 || roomID == "" { return } nowMS := s.clock.Now().UnixMilli() if err := s.repository.UpsertRoomUserFeedEntry(ctx, RoomUserFeedEntry{ AppCode: appcode.FromContext(ctx), UserID: userID, FeedType: roomListTabVisited, RoomID: roomID, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, }); err != nil { logx.Error(ctx, "room_visit_feed_upsert_failed", err, slog.String("component", "room_user_feed_projector"), slog.String("room_id", roomID), slog.Int64("user_id", userID), ) } } // RoomHeartbeat 显式刷新已在房间内用户的业务 presence。 func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.RoomHeartbeat{ Base: baseFromMeta(req.GetMeta()), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists { if ban.ActiveAt(now.UnixMilli()) { // ban 用户不能通过心跳续住业务 presence;KickUser 已经清理 presence,这里是防御脏状态。 return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned") } // 过期 ban 不再阻断心跳;保守清理后继续按 presence 是否存在判断。 delete(current.BanUsers, cmd.ActorUserID()) } existing, exists := current.OnlineUsers[cmd.ActorUserID()] if !exists { // 心跳只刷新已存在 presence,不能把已离房用户重新加入房间。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room") } existing.LastSeenAtMS = now.UnixMilli() current.Version++ snapshot := current.ToProto() return mutationResult{ snapshot: snapshot, user: findProtoUser(snapshot, cmd.ActorUserID()), }, nil, nil }) if err != nil { return nil, err } return &roomv1.RoomHeartbeatResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), User: result.user, Room: result.snapshot, }, nil } // LeaveRoom 把用户从房间业务态移出。 func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // LeaveRoom 只移除 room-service 业务 presence,真实 IM 连接态由腾讯云 IM SDK 处理。 cmd := command.LeaveRoom{ Base: baseFromMeta(req.GetMeta()), } result, err := s.leaveRoom(ctx, cmd, "") if err != nil { return nil, err } return &roomv1.LeaveRoomResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil } func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason string) (mutationResult, error) { return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { _, inRoom := current.OnlineUsers[cmd.ActorUserID()] seat, onSeat := current.SeatByUser(cmd.ActorUserID()) if !inRoom && !onSeat { // 离房幂等:没有业务 presence 且不占麦时不写命令日志和 outbox。 return mutationResult{snapshot: current.ToProto()}, nil, nil } delete(current.OnlineUsers, cmd.ActorUserID()) var micSessionID string if onSeat { // 离房必须同步释放麦位;同时写 RoomMicChanged/down,给时长聚合一个稳定闭合事件。 index := current.SeatIndex(seat.SeatNo) if index >= 0 { micSessionID = current.MicSeats[index].MicSessionID current.ClearMicSession(index) } } current.Version++ records := make([]outbox.Record, 0, 2) if onSeat && micSessionID != "" { micReason := reason if micReason == "" { micReason = "leave" } micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason) if err != nil { return mutationResult{}, nil, err } records = append(records, micEvent) } // 离房事件进入 outbox;stale 清理会通过 attributes 标记原因,腾讯云 IM 由 worker 异步投递。 leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{ UserId: cmd.ActorUserID(), }) if err != nil { return mutationResult{}, nil, err } records = append(records, leftEvent) attributes := map[string]string{} if reason != "" { attributes["reason"] = reason } return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: leftEvent.EventID, RoomID: current.RoomID, EventType: "room_user_left", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.ActorUserID(), RoomVersion: current.Version, Attributes: attributes, }, }, records, nil }) } // RunPresenceStaleWorker 周期性清理本节点已装载房间中的过期业务 presence。 // worker 只调用 room-service 命令链路,不触碰腾讯云 IM 连接态,客户端重连后仍需重新 JoinRoom。 func (s *Service) RunPresenceStaleWorker(ctx context.Context, interval time.Duration) { if interval <= 0 || s.presenceStaleAfter <= 0 { // 非正数表示显式关闭 worker,测试或特殊环境可用。 return } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: _ = s.SweepStalePresence(ctx) } } } // SweepStalePresence 扫描并清理已经超过 last_seen 阈值的业务 presence。 // 该方法暴露给测试和后台 worker 复用,所有清理都复用 LeaveRoom 的持久化、快照和 outbox 语义。 func (s *Service) SweepStalePresence(ctx context.Context) error { if s.presenceStaleAfter <= 0 { return nil } now := s.clock.Now() cutoffMs := now.Add(-s.presenceStaleAfter).UnixMilli() for _, roomRef := range s.loadedRoomRefs() { roomCtx := appcode.WithContext(ctx, roomRef.AppCode) lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now) if err != nil { return err } if !owned { continue } roomCell := s.loadCell(roomCtx, roomRef.RoomID) if roomCell == nil { continue } currentState, _, err := roomCell.Snapshot(roomCtx) if err != nil { return err } owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now()) if err != nil { return err } if !owned { continue } for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) { owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now()) if err != nil { return err } if !owned { break } cmd := command.LeaveRoom{ Base: command.Base{ RequestID: idgen.New("req_presence_stale"), CommandID: idgen.New("cmd_presence_stale"), ActorID: userID, Room: roomRef.RoomID, GatewayNodeID: s.nodeID, SessionID: "presence-stale", SentAtMS: now.UnixMilli(), }, } if _, err := s.leaveRoom(roomCtx, cmd, "stale_timeout"); err != nil { return err } } } return nil } func stalePresenceUserIDs(current *state.RoomState, cutoffMs int64) []int64 { if current == nil { return nil } userIDs := make([]int64, 0) for userID, userState := range current.OnlineUsers { if userState != nil && userState.LastSeenAtMS <= cutoffMs { userIDs = append(userIDs, userID) } } return userIDs }