314 lines
16 KiB
Go
314 lines
16 KiB
Go
// 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()
|
||
}
|