// Package service 承载 room-service 的 Room Cell 管理、命令流水线、恢复、快照和 outbox 补偿。 package service import ( "context" "errors" "sync" "sync/atomic" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/clock" "hyapp/pkg/tencentim" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/cell" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/router" ) // Config 收敛 room-service 领域服务的运行参数。 type Config struct { // NodeID 是当前 room-service 实例标识,Redis lease 以它作为房间 owner。 NodeID string // LeaseTTL 是房间 owner 租约有效期,过期后其他节点可以接管并恢复房间。 LeaseTTL time.Duration // RankLimit 是房间本地礼物榜保留长度。 RankLimit int // SnapshotEveryN 表示每 N 个房间版本保存一次快照。 SnapshotEveryN int64 // PresenceStaleAfter 控制业务 presence 多久未刷新后由后台 worker 清理。 PresenceStaleAfter time.Duration // MicPublishTimeout 控制 MicUp 后等待 RTC 发流确认的最长时间。 MicPublishTimeout time.Duration // RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。 RTCUserRemover integration.RTCUserRemover // RoomRocketLaunchScheduler 把倒计时发射唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。 RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler // RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。 RobotDisplayPublisher integration.OutboxPublisher // RoomDisplayPublisher 在 Room Cell 提交后直接投递腾讯 IM 展示事件;失败不回滚已提交房间命令。 RoomDisplayPublisher integration.OutboxPublisher // HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。 HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider // RoomGiftLeaderboard 是跨房间贡献榜读模型,SendGift 提交后 best-effort 写入。 RoomGiftLeaderboard RoomGiftLeaderboardStore // RoomListCache 是公共发现页 Redis 展示读模型;未配置或未就绪时 ListRooms 直接读 MySQL。 RoomListCache RoomListCacheStore // LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。 LuckyGiftSendLocker LuckyGiftSendLocker // LuckyGiftSendLockTTL 是幸运礼物短锁过期时间。 LuckyGiftSendLockTTL time.Duration // Clock 允许测试注入稳定时间,生产默认使用系统时钟。 Clock clock.Clock } // Service 承载 room-service 的核心房间领域逻辑。 type Service struct { // nodeID 是当前实例 ID,必须和 Redis lease 中的 owner 对齐。 nodeID string // leaseTTL 控制当前节点持有房间执行权的时间窗口。 leaseTTL time.Duration // rankLimit 控制每个 Room Cell 的 LocalRank top N。 rankLimit int // snapshotEveryN 控制快照频率,值越小恢复越快但写放大越高。 snapshotEveryN int64 // presenceStaleAfter 是断线或无心跳用户从 room-service presence 中移除的阈值。 presenceStaleAfter time.Duration // micPublishTimeout 是 pending_publish 自动下麦的确认窗口。 micPublishTimeout time.Duration // clock 是房间命令、事件和快照的统一时间来源。 clock clock.Clock // directory 负责 room_id -> node_id lease,生产路径是 Redis。 directory router.Directory // repository 是 MySQL 持久化边界,保存 meta、snapshot、command log 和 outbox。 repository Repository // wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。 wallet integration.WalletClient // luckyGift 是幸运礼物同步检查和抽奖依赖;未配置时幸运礼物按未启用处理。 luckyGift integration.LuckyGiftClient // syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。 syncPublisher integration.RoomEventPublisher // outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。 outboxPublisher integration.OutboxPublisher // robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。 robotDisplayPublisher integration.OutboxPublisher // roomDisplayPublisher 只承载客户端可由快照修复的直接 IM 展示事件,不作为业务事实源。 roomDisplayPublisher integration.OutboxPublisher // outboxWake 提交成功后唤醒主 outbox worker;它只是低延迟提示,事实源仍然是 MySQL room_outbox。 outboxWake chan struct{} // humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。 humanRobotPoolProvider integration.HumanRoomRobotPoolProvider // roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。 roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler // roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox,降低 room_outbox/MQ/IM 写放大。 roomRocketProgressCoalescer *roomRocketProgressCoalescer // robotDisplaySampler 把同房间同展示类型在短窗口内收敛成一条直发 IM,房间命令和状态仍完整执行。 robotDisplaySampler *robotDisplaySampler // roomGiftLeaderboard 只保存房间维度贡献值 zset,不承载 Room Cell 核心状态。 roomGiftLeaderboard RoomGiftLeaderboardStore // roomListCache 只承载发现页展示顺序;MySQL 仍然是房间列表和置顶事实 owner。 roomListCache RoomListCacheStore // luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。 luckyGiftSendLocker LuckyGiftSendLocker // luckyGiftSendLockTTL 控制 Redis 短锁自动释放窗口。 luckyGiftSendLockTTL time.Duration // rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。 rtcUserRemover integration.RTCUserRemover // draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。 draining atomic.Bool // mu 保护本进程内 room_id -> RoomCell 注册表。 mu sync.Mutex // cells 保存当前节点已经装载的单房间执行单元。 cells map[string]*cell.RoomCell // robotRuntimeMu 保护后台机器人房间运行循环;配置变更时要取消旧循环后重建。 robotRuntimeMu sync.Mutex // robotRuntimes 保存 room_id -> runtime,避免同一房间被重复启动多个送礼循环。 robotRuntimes map[string]robotRoomRuntime // humanRobotRuntimeMu 保护真人房间机器人运行循环,和机器人房间 runtime 分开避免互相取消。 humanRobotRuntimeMu sync.Mutex // humanRobotRuntimes 保存 room_id -> runtime,避免同一真人房间被重复补机器人。 humanRobotRuntimes map[string]robotRoomRuntime } type robotRoomRuntime struct { // cancel 停止当前房间的所有机器人送礼 goroutine。 cancel context.CancelFunc // token 区分同一房间的前后两次启动,旧 goroutine 退出时不能误删新 runtime。 token string // countryCode 记录真人房间机器人 runtime 所属国家,用于后台国家限额变更后精确清理。 countryCode string // configUpdatedAtMS 记录启动 runtime 时采用的配置版本;后台改配置后扫描器据此重启真人房间机器人 runtime。 configUpdatedAtMS int64 } // mutationResult 是命令在 Room Cell 内执行后的统一结果包。 type mutationResult struct { // applied 表示本次命令确实改变并提交了房间状态。 applied bool // snapshot 是命令执行后对外返回的房间快照。 snapshot *roomv1.RoomSnapshot // forceSnapshot 表示本命令必须立即保存快照,适用于低频资料 backfill,避免等待 snapshot_every_n。 forceSnapshot bool // user 是 JoinRoom 等命令需要返回的用户投影。 user *roomv1.RoomUser // seatNo 是麦位命令返回的目标或原麦位编号。 seatNo int32 // micSessionID 是 MicUp 创建的发流会话 ID,客户端确认发流时必须带回。 micSessionID string // publishDeadlineMS 是 pending_publish 自动释放的截止时间。 publishDeadlineMS int64 // micHeartbeatAtMS 是服务端接受当前麦上心跳的时间,只属于麦位活跃探测,不改变时长口径。 micHeartbeatAtMS int64 // billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。 billingReceiptID string // coinBalanceAfter 是送礼人可展示的最终 COIN 余额;幸运礼物返奖成功时优先使用返奖后的余额。 coinBalanceAfter int64 // giftIncomeBalanceAfter 是收礼人返币后的 COIN 余额;单目标返币可同步返回给发起方确认。 giftIncomeBalanceAfter int64 // roomHeat 是 SendGift 后的房间热度。 roomHeat int64 // giftRank 是 SendGift 后的本地礼物榜投影。 giftRank []*roomv1.RankItem // luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。 luckyGift *roomv1.LuckyGiftDrawResult // luckyGifts 是多目标幸运礼物的逐接收方抽奖结果;单目标时只包含 luckyGift。 luckyGifts []*roomv1.LuckyGiftDrawResult // batchDisplay 是 batch-send 的发送方本地展示快照;它不替代逐目标 RoomGiftSent 事实。 batchDisplay *roomv1.SendGiftBatchDisplay // suppressDirectIMEventTypes 只影响提交后的低延迟展示 lane,不能改变 durable outbox 事实。 suppressDirectIMEventTypes map[string]bool // commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。 commandPayload []byte // syncEvent 标记该命令存在腾讯云 IM 房间消息形态;保留旧字段兼容命令结果,room UI 现在由提交后 direct IM 发送。 syncEvent *tencentim.RoomEvent // outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。 outboxRecords []outbox.Record // robotDisplayRecords 是机器人房间展示事实,提交成功后 best-effort 直发 Tencent IM,不占主 room_outbox。 robotDisplayRecords []outbox.Record // directIMRecords 是提交成功后直接发腾讯 IM 的展示事件;失败不回滚已提交房间命令。 directIMRecords []outbox.Record // roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。 roomRocketProgress *roomRocketProgressPending // roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。 roomStatus string // deleteRoom 表示本命令成功后房间应从持久恢复来源、列表投影和当前节点 Cell 注册表中移除。 deleteRoom bool // roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。 roomSeatCount *int32 // roomPasswordHash 非 nil 时代表命令提交时需要同步更新 rooms 当前密码哈希。 roomPasswordHash *string roomMode *string visibleRegionID *int64 closeInfo *RoomCloseInfo // walletDebitMS 记录 SendGift 同步扣费耗时;非钱包命令保持 0。 walletDebitMS int64 // roomGiftLeaderboard 是 SendGift 成功后写入跨房间贡献榜的轻量增量。 roomGiftLeaderboard *RoomGiftLeaderboardIncrement // roomUserContributionPeriodStats 是当前房间用户周期贡献统计,和命令日志同事务提交。 roomUserContributionPeriodStats []RoomUserContributionPeriodStatIncrement // roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。 roomUserGiftStats []RoomUserGiftStatIncrement // roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。 roomWeeklyContribution *RoomWeeklyContributionIncrement } // New 初始化 room-service 领域服务。 func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher, luckyGift ...integration.LuckyGiftClient) *Service { usedClock := cfg.Clock if usedClock == nil { // 生产默认系统时钟,测试可注入固定时钟保证断言稳定。 usedClock = clock.System{} } snapshotEveryN := cfg.SnapshotEveryN if snapshotEveryN <= 0 { // 默认每次状态变更都保存快照,优先保证首版恢复简单。 snapshotEveryN = 1 } rankLimit := cfg.RankLimit if rankLimit <= 0 { // 默认只保留房间内前 20 名礼物榜,避免快照和广播载荷过大。 rankLimit = 20 } presenceStaleAfter := cfg.PresenceStaleAfter if presenceStaleAfter <= 0 { // 默认 2 分钟清理无刷新 presence,避免重连窗口过短导致误踢。 presenceStaleAfter = 2 * time.Minute } micPublishTimeout := cfg.MicPublishTimeout if micPublishTimeout <= 0 { // MicUp 只是业务占麦,客户端必须在短窗口内确认 RTC 发流成功。 micPublishTimeout = 15 * time.Second } luckyGiftSendLockTTL := cfg.LuckyGiftSendLockTTL if luckyGiftSendLockTTL <= 0 { luckyGiftSendLockTTL = 5 * time.Second } var luckyGiftClient integration.LuckyGiftClient if len(luckyGift) > 0 { luckyGiftClient = luckyGift[0] } return &Service{ nodeID: cfg.NodeID, leaseTTL: cfg.LeaseTTL, rankLimit: rankLimit, snapshotEveryN: snapshotEveryN, presenceStaleAfter: presenceStaleAfter, micPublishTimeout: micPublishTimeout, clock: usedClock, directory: directory, repository: repository, wallet: wallet, luckyGift: luckyGiftClient, syncPublisher: syncPublisher, outboxPublisher: outboxPublisher, robotDisplayPublisher: cfg.RobotDisplayPublisher, roomDisplayPublisher: cfg.RoomDisplayPublisher, outboxWake: make(chan struct{}, 1024), humanRobotPoolProvider: cfg.HumanRobotPoolProvider, roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler, roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher), robotDisplaySampler: newRobotDisplaySampler(), roomGiftLeaderboard: cfg.RoomGiftLeaderboard, roomListCache: cfg.RoomListCache, luckyGiftSendLocker: cfg.LuckyGiftSendLocker, luckyGiftSendLockTTL: luckyGiftSendLockTTL, rtcUserRemover: cfg.RTCUserRemover, cells: make(map[string]*cell.RoomCell), robotRuntimes: make(map[string]robotRoomRuntime), humanRobotRuntimes: make(map[string]robotRoomRuntime), } } // HealthCheck 校验 room-service 核心状态 owner 和持久化依赖已经完成装配。 func (s *Service) HealthCheck() error { if s == nil { // nil service 说明 App 装配失败,live/ready 都不能放行。 return errors.New("room service is not initialized") } if s.directory == nil { // 没有 directory 就无法保证单房间单活 owner。 return errors.New("room directory is not initialized") } if s.repository == nil { // 没有 repository 就无法保证 command log、snapshot 和 outbox 持久化。 return errors.New("room repository is not initialized") } if s.cells == nil { // cells 注册表缺失会导致已恢复房间无法复用同一个 Room Cell。 return errors.New("room cell registry is not initialized") } return nil } // MarkDraining 让领域服务停止接收新的 Room Cell 命令和新 lease 接管。 func (s *Service) MarkDraining() { if s == nil { return } s.draining.Store(true) } func (s *Service) isDraining() bool { return s != nil && s.draining.Load() }