2026-07-06 12:43:40 +08:00

314 lines
16 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 承载 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()
}