222 lines
9.3 KiB
Go
222 lines
9.3 KiB
Go
// Package service 承载 room-service 的 Room Cell 管理、命令流水线、恢复、快照和 outbox 补偿。
|
||
package service
|
||
|
||
import (
|
||
"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
|
||
// RoomTreasureOpenScheduler 把倒计时开箱唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。
|
||
RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||
// RoomGiftLeaderboard 是跨房间金币榜读模型,SendGift 提交后 best-effort 写入。
|
||
RoomGiftLeaderboard RoomGiftLeaderboardStore
|
||
// 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
|
||
// roomTreasureOpenScheduler 在 countdown outbox 投递成功后安排 open_at_ms 唤醒。
|
||
roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||
// roomGiftLeaderboard 只保存房间维度金币消耗 zset,不承载 Room Cell 核心状态。
|
||
roomGiftLeaderboard RoomGiftLeaderboardStore
|
||
// rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。
|
||
rtcUserRemover integration.RTCUserRemover
|
||
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
|
||
draining atomic.Bool
|
||
|
||
// mu 保护本进程内 room_id -> RoomCell 注册表。
|
||
mu sync.Mutex
|
||
// cells 保存当前节点已经装载的单房间执行单元。
|
||
cells map[string]*cell.RoomCell
|
||
}
|
||
|
||
// mutationResult 是命令在 Room Cell 内执行后的统一结果包。
|
||
type mutationResult struct {
|
||
// applied 表示本次命令确实改变并提交了房间状态。
|
||
applied bool
|
||
// snapshot 是命令执行后对外返回的房间快照。
|
||
snapshot *roomv1.RoomSnapshot
|
||
// user 是 JoinRoom 等命令需要返回的用户投影。
|
||
user *roomv1.RoomUser
|
||
// seatNo 是麦位命令返回的目标或原麦位编号。
|
||
seatNo int32
|
||
// micSessionID 是 MicUp 创建的发流会话 ID,客户端确认发流时必须带回。
|
||
micSessionID string
|
||
// publishDeadlineMS 是 pending_publish 自动释放的截止时间。
|
||
publishDeadlineMS int64
|
||
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
|
||
billingReceiptID string
|
||
// roomHeat 是 SendGift 后的房间热度。
|
||
roomHeat int64
|
||
// giftRank 是 SendGift 后的本地礼物榜投影。
|
||
giftRank []*roomv1.RankItem
|
||
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
|
||
luckyGift *roomv1.LuckyGiftDrawResult
|
||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||
commandPayload []byte
|
||
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
|
||
syncEvent *tencentim.RoomEvent
|
||
// outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。
|
||
outboxRecords []outbox.Record
|
||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||
roomStatus string
|
||
// 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
|
||
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
|
||
roomUserGiftStats []RoomUserGiftStatIncrement
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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,
|
||
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
|
||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||
rtcUserRemover: cfg.RTCUserRemover,
|
||
cells: make(map[string]*cell.RoomCell),
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|