package service import ( "context" "crypto/sha256" "encoding/binary" "fmt" "slices" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/roomid" "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" ) const ( roomRocketLevelCount = 5 roomRocketDefaultFuelSource = "heat_value" roomRocketDefaultLaunchDelayMS = int64(30_000) roomRocketDefaultBroadcastScope = "region" roomRocketDefaultRewardStackPolicy = "allow_stack" roomRocketFuelHeatValue = "heat_value" roomRocketBroadcastNone = "none" roomRocketBroadcastRegion = "region" roomRocketBroadcastGlobal = "global" roomRocketRewardStackAllow = "allow_stack" roomRocketRewardStackPriority = "priority_only" roomRocketRewardInRoom = "in_room" roomRocketRewardTop1 = "top1" roomRocketRewardIgniter = "igniter" roomRocketGrantSource = "room_rocket" roomRocketGrantStatus = "succeeded" ) var roomRocketDefaultThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000} // GetRoomRocket 返回房间火箭 UI 初始化数据;它只读 Room Cell,不刷新 presence。 func (s *Service) GetRoomRocket(ctx context.Context, req *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) roomID := strings.TrimSpace(req.GetRoomId()) viewerUserID := req.GetViewerUserId() if !roomid.ValidStringID(roomID) { return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid") } if viewerUserID <= 0 { return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required") } snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return nil, err } if snapshot == nil || snapshot.GetRoomId() == "" { return nil, xerr.New(xerr.NotFound, "room not found") } if snapshot.GetStatus() != state.RoomStatusActive { return nil, xerr.New(xerr.RoomClosed, "room closed") } if snapshotUserBanned(snapshot, viewerUserID, s.clock.Now().UnixMilli()) || findProtoUser(snapshot, viewerUserID) == nil { // 火箭状态属于房间内活动信息,只允许仍在业务 presence 内的用户读取。 return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room") } config, err := s.roomRocketConfig(ctx) if err != nil { return nil, err } now := s.clock.Now().UTC() info := roomRocketInfoFromConfig(config, snapshot.GetRocket(), now) return &roomv1.GetRoomRocketResponse{ Rocket: info, ServerTimeMs: now.UnixMilli(), }, nil } func (s *Service) roomRocketConfig(ctx context.Context) (RoomRocketConfig, error) { config, exists, err := s.repository.GetRoomRocketConfig(ctx) if err != nil { return RoomRocketConfig{}, err } if !exists { config = defaultRoomRocketConfig(appcode.FromContext(ctx)) } return normalizeRoomRocketConfig(config) } func defaultRoomRocketConfig(appCode string) RoomRocketConfig { levels := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount) for index, threshold := range roomRocketDefaultThresholds { levels = append(levels, RoomRocketLevelConfig{ Level: int32(index + 1), FuelThreshold: threshold, }) } return RoomRocketConfig{ AppCode: appcode.Normalize(appCode), FuelSource: roomRocketDefaultFuelSource, LaunchDelayMS: roomRocketDefaultLaunchDelayMS, BroadcastEnabled: true, BroadcastScope: roomRocketDefaultBroadcastScope, RewardStackPolicy: roomRocketDefaultRewardStackPolicy, Levels: levels, } } func normalizeRoomRocketConfig(config RoomRocketConfig) (RoomRocketConfig, error) { config.AppCode = appcode.Normalize(config.AppCode) if config.AppCode == "" { config.AppCode = appcode.Default } if config.ConfigVersion < 0 { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid") } config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource) if config.FuelSource != roomRocketFuelHeatValue { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket fuel_source is invalid") } if config.LaunchDelayMS < 0 || config.BroadcastDelayMS < 0 { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket delay is invalid") } config.BroadcastScope = defaultRoomRocketString(config.BroadcastScope, roomRocketDefaultBroadcastScope) if config.BroadcastScope != roomRocketBroadcastNone && config.BroadcastScope != roomRocketBroadcastRegion && config.BroadcastScope != roomRocketBroadcastGlobal { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket broadcast_scope is invalid") } if !config.BroadcastEnabled { config.BroadcastScope = roomRocketBroadcastNone } config.RewardStackPolicy = defaultRoomRocketString(config.RewardStackPolicy, roomRocketDefaultRewardStackPolicy) if config.RewardStackPolicy != roomRocketRewardStackAllow && config.RewardStackPolicy != roomRocketRewardStackPriority { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket reward_stack_policy is invalid") } levels, err := normalizeRoomRocketLevels(config.Levels) if err != nil { return RoomRocketConfig{}, err } config.Levels = levels config.GiftFuelRules = normalizeRoomRocketFuelRules(config.GiftFuelRules) return config, nil } func normalizeRoomRocketLevels(levels []RoomRocketLevelConfig) ([]RoomRocketLevelConfig, error) { if len(levels) == 0 { return defaultRoomRocketConfig(appcode.Default).Levels, nil } if len(levels) != roomRocketLevelCount { return nil, xerr.New(xerr.InvalidArgument, "room rocket levels must be 5") } seen := make(map[int32]bool, roomRocketLevelCount) normalized := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount) for _, level := range levels { if level.Level < 1 || level.Level > roomRocketLevelCount || seen[level.Level] { return nil, xerr.New(xerr.InvalidArgument, "room rocket level is invalid") } if level.FuelThreshold <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room rocket fuel_threshold is invalid") } seen[level.Level] = true level.CoverURL = strings.TrimSpace(level.CoverURL) level.AnimationURL = strings.TrimSpace(level.AnimationURL) level.LaunchAnimationURL = strings.TrimSpace(level.LaunchAnimationURL) level.LaunchedImageURL = strings.TrimSpace(level.LaunchedImageURL) level.InRoomRewards = normalizeRoomRocketRewardPool(level.InRoomRewards) level.Top1Rewards = normalizeRoomRocketRewardPool(level.Top1Rewards) level.IgniterRewards = normalizeRoomRocketRewardPool(level.IgniterRewards) normalized = append(normalized, level) } slices.SortFunc(normalized, func(left, right RoomRocketLevelConfig) int { return int(left.Level - right.Level) }) return normalized, nil } func normalizeRoomRocketRewardPool(input []RoomRocketRewardItem) []RoomRocketRewardItem { out := make([]RoomRocketRewardItem, 0, len(input)) for _, reward := range input { reward.RewardItemID = strings.TrimSpace(reward.RewardItemID) reward.DisplayName = strings.TrimSpace(reward.DisplayName) reward.IconURL = strings.TrimSpace(reward.IconURL) if reward.ResourceGroupID <= 0 || reward.Weight <= 0 { // 后台保存层会拒绝非法奖励;运行时跳过历史脏项,避免一个奖励池配置污染送礼主链路。 continue } out = append(out, reward) } return out } func normalizeRoomRocketFuelRules(input []GiftFuelRuleConfig) []GiftFuelRuleConfig { out := make([]GiftFuelRuleConfig, 0, len(input)) for _, rule := range input { rule.RuleID = strings.TrimSpace(rule.RuleID) rule.GiftID = strings.TrimSpace(rule.GiftID) rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode) if rule.GiftID == "" && rule.GiftTypeCode == "" { continue } if rule.MultiplierPPM < 0 || rule.FixedFuel < 0 { continue } out = append(out, rule) } return out } func defaultRoomRocketString(value string, fallback string) string { value = strings.TrimSpace(value) if value == "" { return fallback } return value } func roomRocketInfoFromConfig(config RoomRocketConfig, current *roomv1.RoomRocketState, now time.Time) *roomv1.RoomRocketInfo { stateView := roomRocketProtoStateForView(current, config, now) return &roomv1.RoomRocketInfo{ Enabled: config.Enabled, Levels: roomRocketLevelsToProto(config.Levels), State: stateView, ServerTimeMs: now.UnixMilli(), BroadcastScope: config.BroadcastScope, LaunchDelayMs: config.LaunchDelayMS, BroadcastDelayMs: config.BroadcastDelayMS, RewardStackPolicy: config.RewardStackPolicy, } } func roomRocketProtoStateForView(current *roomv1.RoomRocketState, config RoomRocketConfig, now time.Time) *roomv1.RoomRocketState { internal := state.RocketState{} if current != nil { internal = state.RocketState{ CurrentLevel: current.GetCurrentLevel(), CurrentFuel: current.GetCurrentFuel(), FuelThreshold: current.GetFuelThreshold(), Status: current.GetStatus(), IgnitedAtMS: current.GetIgnitedAtMs(), LaunchAtMS: current.GetLaunchAtMs(), LaunchedAtMS: current.GetLaunchedAtMs(), ResetAtMS: current.GetResetAtMs(), Top1UserID: current.GetTop1UserId(), IgniterUserID: current.GetIgniterUserId(), RocketID: current.GetRocketId(), ConfigVersion: current.GetConfigVersion(), PendingLaunches: statePendingLaunchesFromProto(current.GetPendingLaunches()), LastRewards: stateRewardsFromProto(current.GetLastRewards()), } } normalized := rocketStateForNow(&internal, config, now, false) return rocketStateToRoomProto(normalized) } func stateRewardsFromProto(input []*roomv1.RoomRocketRewardGrant) []state.RocketRewardGrant { out := make([]state.RocketRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, state.RocketRewardGrant{ RewardRole: reward.GetRewardRole(), UserID: reward.GetUserId(), RewardItemID: reward.GetRewardItemId(), ResourceGroupID: reward.GetResourceGroupId(), DisplayName: reward.GetDisplayName(), IconURL: reward.GetIconUrl(), GrantID: reward.GetGrantId(), Status: reward.GetStatus(), }) } return out } func statePendingLaunchesFromProto(input []*roomv1.RoomRocketPendingLaunch) []state.RocketPendingLaunch { out := make([]state.RocketPendingLaunch, 0, len(input)) for _, launch := range input { out = append(out, state.RocketPendingLaunch{ RocketID: launch.GetRocketId(), Level: launch.GetLevel(), FuelThreshold: launch.GetFuelThreshold(), IgnitedAtMS: launch.GetIgnitedAtMs(), LaunchAtMS: launch.GetLaunchAtMs(), ResetAtMS: launch.GetResetAtMs(), Top1UserID: launch.GetTop1UserId(), IgniterUserID: launch.GetIgniterUserId(), ConfigVersion: launch.GetConfigVersion(), CoverURL: launch.GetCoverUrl(), }) } return out } func roomRocketLevelsToProto(levels []RoomRocketLevelConfig) []*roomv1.RoomRocketLevel { out := make([]*roomv1.RoomRocketLevel, 0, len(levels)) for _, level := range levels { out = append(out, &roomv1.RoomRocketLevel{ Level: level.Level, FuelThreshold: level.FuelThreshold, CoverUrl: level.CoverURL, AnimationUrl: level.AnimationURL, LaunchAnimationUrl: level.LaunchAnimationURL, LaunchedImageUrl: level.LaunchedImageURL, InRoomRewards: roomRocketRewardsToProto(level.InRoomRewards), Top1Rewards: roomRocketRewardsToProto(level.Top1Rewards), IgniterRewards: roomRocketRewardsToProto(level.IgniterRewards), }) } return out } func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocketRewardItem { out := make([]*roomv1.RoomRocketRewardItem, 0, len(input)) for _, reward := range input { out = append(out, &roomv1.RoomRocketRewardItem{ RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, Weight: reward.Weight, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, }) } return out } type rocketGiftApplyResult struct { touched bool progressEvent *roomeventsv1.RoomRocketFuelChanged ignited *roomeventsv1.RoomRocketIgnited } func (s *Service) applyRoomRocketGift(now time.Time, current *state.RoomState, cfg RoomRocketConfig, cmd command.SendGift, settled *command.SendGift, billing *walletv1.DebitGiftResponse, roomMeta RoomMeta) (rocketGiftApplyResult, error) { if !cfg.Enabled { return rocketGiftApplyResult{}, nil } now = now.UTC() nowMS := now.UnixMilli() rocket := rocketStateForNow(current.Rocket, cfg, now, true) result := rocketGiftApplyResult{touched: current.Rocket == nil || rocket.ResetAtMS != current.Rocket.ResetAtMS} addedFuel := roomRocketFuel(cfg, cmd.GiftID, billing.GetGiftTypeCode(), billing.GetHeatValue()) effectiveFuel := int64(0) overflowFuel := int64(0) progressRocketID := rocket.RocketID progressLevel := rocket.CurrentLevel progressFuel := rocket.CurrentFuel progressThreshold := rocket.FuelThreshold progressStatus := rocket.Status if rocket.Status == state.RocketStatusCompleted { // 当天最高 5 级已满后,继续送礼不再产生新火箭;整笔燃料按产品规则直接作废。 overflowFuel = addedFuel } else if addedFuel > 0 { remaining := rocket.FuelThreshold - rocket.CurrentFuel if remaining < 0 { remaining = 0 } effectiveFuel = minInt64(addedFuel, remaining) overflowFuel = addedFuel - effectiveFuel if effectiveFuel > 0 { rocket.CurrentFuel += effectiveFuel progressFuel = rocket.CurrentFuel result.touched = true } if rocket.CurrentFuel >= rocket.FuelThreshold { levelCfg := roomRocketLevelByNumber(cfg, rocket.CurrentLevel) pending := state.RocketPendingLaunch{ RocketID: rocket.RocketID, Level: rocket.CurrentLevel, FuelThreshold: rocket.FuelThreshold, IgnitedAtMS: nowMS, LaunchAtMS: nowMS + cfg.LaunchDelayMS, ResetAtMS: rocket.ResetAtMS, Top1UserID: firstRankUserID(current.GiftRank), IgniterUserID: cmd.ActorUserID(), ConfigVersion: cfg.ConfigVersion, CoverURL: levelCfg.CoverURL, } rocket.CurrentFuel = rocket.FuelThreshold rocket.IgnitedAtMS = pending.IgnitedAtMS rocket.LaunchAtMS = pending.LaunchAtMS rocket.Top1UserID = pending.Top1UserID rocket.IgniterUserID = pending.IgniterUserID rocket.ConfigVersion = cfg.ConfigVersion rocket.PendingLaunches = append(rocket.PendingLaunches, pending) progressRocketID = pending.RocketID progressLevel = pending.Level progressFuel = pending.FuelThreshold progressThreshold = pending.FuelThreshold progressStatus = state.RocketStatusIgnited result.ignited = &roomeventsv1.RoomRocketIgnited{ RocketId: pending.RocketID, Level: pending.Level, CurrentFuel: pending.FuelThreshold, FuelThreshold: pending.FuelThreshold, IgnitedAtMs: pending.IgnitedAtMS, LaunchAtMs: pending.LaunchAtMS, BroadcastScope: cfg.BroadcastScope, VisibleRegionId: roomMeta.VisibleRegionID, Top1UserId: pending.Top1UserID, IgniterUserId: pending.IgniterUserID, CommandId: cmd.ID(), RoomShortId: roomMeta.RoomShortID, RocketCoverUrl: pending.CoverURL, ResetAtMs: pending.ResetAtMS, } if rocket.CurrentLevel < roomRocketLevelCount { nextLevel := rocket.CurrentLevel + 1 nextLevelCfg := roomRocketLevelByNumber(cfg, nextLevel) rocket.CurrentLevel = nextLevel rocket.CurrentFuel = 0 rocket.FuelThreshold = nextLevelCfg.FuelThreshold rocket.Status = state.RocketStatusCharging rocket.RocketID = idgen.New("room_rocket") } else { // 5 级是当天最高等级;点火后不再生成第 6 级,UTC 日界统一重置回 1 级。 rocket.Status = state.RocketStatusCompleted rocket.RocketID = "" } } } if result.touched { current.Rocket = rocket } if effectiveFuel > 0 { result.progressEvent = &roomeventsv1.RoomRocketFuelChanged{ RocketId: progressRocketID, Level: progressLevel, AddedFuel: addedFuel, EffectiveAddedFuel: effectiveFuel, OverflowFuel: overflowFuel, CurrentFuel: progressFuel, FuelThreshold: progressThreshold, Status: progressStatus, ResetAtMs: rocket.ResetAtMS, SenderUserId: cmd.ActorUserID(), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, CommandId: cmd.ID(), VisibleRegionId: roomMeta.VisibleRegionID, } } if result.touched { settled.GiftTypeCode = billing.GetGiftTypeCode() settled.RocketTouched = true settled.RocketAddedFuel = addedFuel settled.RocketEffectiveFuel = effectiveFuel settled.RocketOverflowFuel = overflowFuel settled.RocketLevel = rocket.CurrentLevel settled.RocketFuel = rocket.CurrentFuel settled.RocketStatus = rocket.Status settled.RocketFuelThreshold = rocket.FuelThreshold settled.RocketIgnitedAtMS = rocket.IgnitedAtMS settled.RocketLaunchAtMS = rocket.LaunchAtMS settled.RocketResetAtMS = rocket.ResetAtMS settled.RocketTop1UserID = rocket.Top1UserID settled.RocketIgniterUserID = rocket.IgniterUserID settled.RocketID = rocket.RocketID settled.RocketConfigVersion = rocket.ConfigVersion settled.RocketPendingLaunches = rocketPendingLaunchesToCommand(rocket.PendingLaunches) } return result, nil } func rocketStateForNow(input *state.RocketState, cfg RoomRocketConfig, now time.Time, allocateRocketID bool) *state.RocketState { now = now.UTC() nowMS := now.UnixMilli() resetAtMS := nextUTCResetMS(now) level := int32(1) if input != nil && input.CurrentLevel >= 1 && input.CurrentLevel <= roomRocketLevelCount { level = input.CurrentLevel } if input == nil || input.ResetAtMS <= 0 || nowMS >= input.ResetAtMS { return newRocketState(levelForUTCReset(), roomRocketLevelByNumber(cfg, levelForUTCReset()), resetAtMS, cfg.ConfigVersion, allocateRocketID) } rocket := input.Clone() levelConfig := roomRocketLevelByNumber(cfg, level) rocket.CurrentLevel = level rocket.FuelThreshold = levelConfig.FuelThreshold rocket.ResetAtMS = input.ResetAtMS if rocket.CurrentFuel < 0 { rocket.CurrentFuel = 0 } if rocket.CurrentFuel > rocket.FuelThreshold { rocket.CurrentFuel = rocket.FuelThreshold } rocket.PendingLaunches = validRocketPendingLaunches(rocket.PendingLaunches, rocket.ResetAtMS) if rocket.Status == state.RocketStatusCompleted { rocket.CurrentLevel = roomRocketLevelCount rocket.FuelThreshold = roomRocketLevelByNumber(cfg, roomRocketLevelCount).FuelThreshold rocket.CurrentFuel = rocket.FuelThreshold rocket.RocketID = "" } else { rocket.Status = state.RocketStatusCharging } if rocket.RocketID == "" && allocateRocketID && rocket.Status != state.RocketStatusCompleted { rocket.RocketID = idgen.New("room_rocket") } return rocket } func validRocketPendingLaunches(input []state.RocketPendingLaunch, resetAtMS int64) []state.RocketPendingLaunch { out := make([]state.RocketPendingLaunch, 0, len(input)) seen := make(map[string]bool, len(input)) for _, launch := range input { if launch.RocketID == "" || launch.Level <= 0 || launch.LaunchAtMS <= 0 { continue } if resetAtMS > 0 && launch.ResetAtMS > 0 && launch.ResetAtMS != resetAtMS { continue } key := fmt.Sprintf("%s:%d", launch.RocketID, launch.Level) if seen[key] { continue } seen[key] = true out = append(out, launch) } slices.SortFunc(out, func(left, right state.RocketPendingLaunch) int { if left.LaunchAtMS == right.LaunchAtMS { return int(left.Level - right.Level) } if left.LaunchAtMS < right.LaunchAtMS { return -1 } return 1 }) return out } func newRocketState(level int32, levelConfig RoomRocketLevelConfig, resetAtMS int64, configVersion int64, allocateRocketID bool) *state.RocketState { rocketID := "" if allocateRocketID { rocketID = idgen.New("room_rocket") } return &state.RocketState{ CurrentLevel: level, FuelThreshold: levelConfig.FuelThreshold, Status: state.RocketStatusCharging, ResetAtMS: resetAtMS, RocketID: rocketID, ConfigVersion: configVersion, } } func levelForUTCReset() int32 { return 1 } func nextUTCResetMS(now time.Time) int64 { utc := now.UTC() next := time.Date(utc.Year(), utc.Month(), utc.Day()+1, 0, 0, 0, 0, time.UTC) return next.UnixMilli() } func roomRocketLevelByNumber(cfg RoomRocketConfig, level int32) RoomRocketLevelConfig { for _, item := range cfg.Levels { if item.Level == level { return item } } return defaultRoomRocketConfig(cfg.AppCode).Levels[0] } func roomRocketFuel(cfg RoomRocketConfig, giftID string, giftTypeCode string, heatValue int64) int64 { // 火箭燃料只读取 wallet-service 按真实扣费和贡献比例计算出的 heat_value,避免历史 GIFT_POINT 字段污染活动进度。 base := heatValue if base < 0 { base = 0 } for _, rule := range cfg.GiftFuelRules { if rule.GiftID != "" && rule.GiftID == giftID { return roomRocketFuelByRule(base, rule) } } for _, rule := range cfg.GiftFuelRules { if rule.GiftID == "" && rule.GiftTypeCode != "" && rule.GiftTypeCode == giftTypeCode { return roomRocketFuelByRule(base, rule) } } return base } func roomRocketFuelByRule(base int64, rule GiftFuelRuleConfig) int64 { if rule.Excluded { return 0 } fuel := int64(0) if rule.MultiplierPPM > 0 { fuel += base * rule.MultiplierPPM / 1_000_000 } fuel += rule.FixedFuel if fuel < 0 { return 0 } return fuel } func firstRankUserID(items []state.RankItem) int64 { if len(items) == 0 { return 0 } return items[0].UserID } func minInt64(left int64, right int64) int64 { if left < right { return left } return right } func rocketStateToRoomProto(input *state.RocketState) *roomv1.RoomRocketState { if input == nil { return nil } rewards := make([]*roomv1.RoomRocketRewardGrant, 0, len(input.LastRewards)) for _, reward := range input.LastRewards { rewards = append(rewards, &roomv1.RoomRocketRewardGrant{ RewardRole: reward.RewardRole, UserId: reward.UserID, RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, GrantId: reward.GrantID, Status: reward.Status, }) } pending := make([]*roomv1.RoomRocketPendingLaunch, 0, len(input.PendingLaunches)) for _, launch := range input.PendingLaunches { pending = append(pending, &roomv1.RoomRocketPendingLaunch{ RocketId: launch.RocketID, Level: launch.Level, FuelThreshold: launch.FuelThreshold, IgnitedAtMs: launch.IgnitedAtMS, LaunchAtMs: launch.LaunchAtMS, ResetAtMs: launch.ResetAtMS, Top1UserId: launch.Top1UserID, IgniterUserId: launch.IgniterUserID, ConfigVersion: launch.ConfigVersion, CoverUrl: launch.CoverURL, }) } return &roomv1.RoomRocketState{ CurrentLevel: input.CurrentLevel, CurrentFuel: input.CurrentFuel, FuelThreshold: input.FuelThreshold, Status: input.Status, IgnitedAtMs: input.IgnitedAtMS, LaunchAtMs: input.LaunchAtMS, LaunchedAtMs: input.LaunchedAtMS, ResetAtMs: input.ResetAtMS, Top1UserId: input.Top1UserID, IgniterUserId: input.IgniterUserID, RocketId: input.RocketID, ConfigVersion: input.ConfigVersion, LastRewards: rewards, PendingLaunches: pending, } } func rocketStateToEventRewards(input []state.RocketRewardGrant) []*roomeventsv1.RoomRocketRewardGrant { out := make([]*roomeventsv1.RoomRocketRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, &roomeventsv1.RoomRocketRewardGrant{ RewardRole: reward.RewardRole, UserId: reward.UserID, RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, GrantId: reward.GrantID, Status: reward.Status, }) } return out } // RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的待发射火箭走命令链路发射。 func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) { if interval <= 0 { return } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: _ = s.SweepRoomRocketLaunchings(ctx) } } } // SweepRoomRocketLaunchings 只处理本节点仍持有 lease 的已装载 Room Cell。 func (s *Service) SweepRoomRocketLaunchings(ctx context.Context) error { now := s.clock.Now().UTC() nowMS := now.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 || currentState == nil || currentState.Rocket == nil { continue } rocket := currentState.Rocket if rocket.ResetAtMS > 0 && nowMS >= rocket.ResetAtMS { // UTC 切日优先级高于延迟发射;跨日倒计时火箭不再结算,下一次查询或送礼会按新 UTC 日投影/持久化一级新进度。 continue } for _, pending := range append([]state.RocketPendingLaunch(nil), rocket.PendingLaunches...) { if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS { continue } actorID := pending.IgniterUserID if actorID <= 0 { actorID = roomRocketSystemActor(currentState) } cmd := command.LaunchRoomRocket{ Base: command.Base{ AppCode: roomRef.AppCode, RequestID: idgen.New("req_room_rocket_launch"), CommandID: roomRocketLaunchCommandID(pending.RocketID), ActorID: actorID, Room: roomRef.RoomID, GatewayNodeID: s.nodeID, SessionID: "room-rocket-launch", SentAtMS: nowMS, }, RocketID: pending.RocketID, Level: pending.Level, } if _, err := s.launchRoomRocket(roomCtx, cmd); err != nil { return err } } } return nil } func roomRocketLaunchCommandID(rocketID string) string { if strings.TrimSpace(rocketID) == "" { return idgen.New("cmd_room_rocket_launch") } return "cmd_room_rocket_launch_" + rocketID } func roomRocketSystemActor(current *state.RoomState) int64 { if current == nil { return 0 } if current.OwnerUserID > 0 { return current.OwnerUserID } if current.Rocket != nil && current.Rocket.IgniterUserID > 0 { return current.Rocket.IgniterUserID } return 0 } func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRocket) (*roomv1.RoomRocketState, error) { result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { now = now.UTC() nowMS := now.UnixMilli() if current.Rocket == nil { return mutationResult{snapshot: current.ToProto()}, nil, nil } pending, pendingIndex, ok := findRocketPendingLaunch(current.Rocket.PendingLaunches, cmd.RocketID, cmd.Level) if !ok { return mutationResult{snapshot: current.ToProto()}, nil, nil } if pending.ResetAtMS > 0 && nowMS >= pending.ResetAtMS { // 手动或延迟到达的发射命令也不能越过 UTC 日界结算昨天的火箭。 return mutationResult{snapshot: current.ToProto()}, nil, nil } if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS { return mutationResult{snapshot: current.ToProto()}, nil, nil } cfg, err := s.roomRocketConfig(ctx) if err != nil { return mutationResult{}, nil, err } levelCfg := roomRocketLevelByNumber(cfg, pending.Level) inRoomUserIDs := sortedRocketOnlineUserIDs(current.OnlineUsers) rewards, err := s.settleRoomRocketRewards(ctx, now, current, pending, cfg, levelCfg, inRoomUserIDs) if err != nil { return mutationResult{}, nil, err } pendingAfter := removeRocketPendingLaunch(current.Rocket.PendingLaunches, pendingIndex) current.Rocket.PendingLaunches = pendingAfter current.Rocket.LaunchedAtMS = nowMS current.Rocket.LastRewards = rewards settledCommand := cmd settledCommand.CurrentLevel = current.Rocket.CurrentLevel settledCommand.CurrentFuel = current.Rocket.CurrentFuel settledCommand.CurrentFuelThreshold = current.Rocket.FuelThreshold settledCommand.CurrentStatus = current.Rocket.Status settledCommand.CurrentRocketID = current.Rocket.RocketID settledCommand.ConfigVersion = current.Rocket.ConfigVersion settledCommand.ResetAtMS = current.Rocket.ResetAtMS settledCommand.Top1UserID = pending.Top1UserID settledCommand.IgniterUserID = pending.IgniterUserID settledCommand.LaunchedAtMS = nowMS settledCommand.InRoomUserIDs = inRoomUserIDs settledCommand.Rewards = rocketRewardsToCommand(rewards) settledCommand.PendingLaunches = rocketPendingLaunchesToCommand(pendingAfter) commandPayload, err := command.Serialize(settledCommand) if err != nil { return mutationResult{}, nil, err } current.Version++ launchedEvent, err := outbox.Build(current.RoomID, "RoomRocketLaunched", current.Version, now, &roomeventsv1.RoomRocketLaunched{ RocketId: pending.RocketID, Level: pending.Level, NextLevel: current.Rocket.CurrentLevel, LaunchedAtMs: nowMS, ResetAtMs: pending.ResetAtMS, Top1UserId: pending.Top1UserID, IgniterUserId: pending.IgniterUserID, InRoomUserIds: inRoomUserIDs, Rewards: rocketStateToEventRewards(rewards), }) if err != nil { return mutationResult{}, nil, err } records := []outbox.Record{launchedEvent} if len(rewards) > 0 { rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{ RocketId: pending.RocketID, Level: pending.Level, Rewards: rocketStateToEventRewards(rewards), }) if err != nil { return mutationResult{}, nil, err } records = append(records, rewardEvent) } return mutationResult{ snapshot: current.ToProto(), commandPayload: commandPayload, }, records, nil }) if err != nil { return nil, err } return result.snapshot.GetRocket(), nil } func findRocketPendingLaunch(input []state.RocketPendingLaunch, rocketID string, level int32) (state.RocketPendingLaunch, int, bool) { for index, launch := range input { if launch.RocketID == rocketID && launch.Level == level { return launch, index, true } } return state.RocketPendingLaunch{}, -1, false } func removeRocketPendingLaunch(input []state.RocketPendingLaunch, index int) []state.RocketPendingLaunch { if index < 0 || index >= len(input) { return append([]state.RocketPendingLaunch(nil), input...) } out := make([]state.RocketPendingLaunch, 0, len(input)-1) out = append(out, input[:index]...) out = append(out, input[index+1:]...) return out } func sortedRocketOnlineUserIDs(users map[int64]*state.RoomUserState) []int64 { userIDs := make([]int64, 0, len(users)) for userID := range users { if userID > 0 { userIDs = append(userIDs, userID) } } slices.Sort(userIDs) return userIDs } func (s *Service) settleRoomRocketRewards(ctx context.Context, now time.Time, current *state.RoomState, launch state.RocketPendingLaunch, cfg RoomRocketConfig, levelCfg RoomRocketLevelConfig, inRoomUserIDs []int64) ([]state.RocketRewardGrant, error) { if current == nil { return nil, nil } rewards := make([]state.RocketRewardGrant, 0, len(inRoomUserIDs)+2) claimed := make(map[int64]bool, len(inRoomUserIDs)+2) addReward := func(role string, userID int64, pool []RoomRocketRewardItem) error { if userID <= 0 { return nil } if cfg.RewardStackPolicy == roomRocketRewardStackPriority && claimed[userID] { return nil } item, ok := selectRoomRocketReward(launch.RocketID, role, userID, pool) if !ok { return nil } grantID, err := s.grantRoomRocketReward(ctx, now, current, launch.RocketID, role, userID, item) if err != nil { return err } rewards = append(rewards, state.RocketRewardGrant{ RewardRole: role, UserID: userID, RewardItemID: item.RewardItemID, ResourceGroupID: item.ResourceGroupID, DisplayName: item.DisplayName, IconURL: item.IconURL, GrantID: grantID, Status: roomRocketGrantStatus, }) claimed[userID] = true return nil } if cfg.RewardStackPolicy == roomRocketRewardStackPriority { // 去重策略下先发特殊身份奖励,再发普通在房奖励,避免 top1/点火人被普通池占位。 if err := addReward(roomRocketRewardIgniter, launch.IgniterUserID, levelCfg.IgniterRewards); err != nil { return nil, err } if err := addReward(roomRocketRewardTop1, launch.Top1UserID, levelCfg.Top1Rewards); err != nil { return nil, err } for _, userID := range inRoomUserIDs { if err := addReward(roomRocketRewardInRoom, userID, levelCfg.InRoomRewards); err != nil { return nil, err } } return rewards, nil } for _, userID := range inRoomUserIDs { if err := addReward(roomRocketRewardInRoom, userID, levelCfg.InRoomRewards); err != nil { return nil, err } } if err := addReward(roomRocketRewardTop1, launch.Top1UserID, levelCfg.Top1Rewards); err != nil { return nil, err } if err := addReward(roomRocketRewardIgniter, launch.IgniterUserID, levelCfg.IgniterRewards); err != nil { return nil, err } return rewards, nil } func (s *Service) grantRoomRocketReward(ctx context.Context, _ time.Time, current *state.RoomState, rocketID string, role string, userID int64, item RoomRocketRewardItem) (string, error) { if s.wallet == nil { return "", xerr.New(xerr.Unavailable, "wallet client is not configured") } operatorUserID := current.OwnerUserID if operatorUserID <= 0 { operatorUserID = userID } resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{ CommandId: roomRocketGrantCommandID(rocketID, role, userID, item.RewardItemID), AppCode: appcode.FromContext(ctx), TargetUserId: userID, GroupId: item.ResourceGroupID, Reason: fmt.Sprintf("room rocket %s reward", role), OperatorUserId: operatorUserID, GrantSource: roomRocketGrantSource, }) if err != nil { return "", err } if resp == nil || resp.GetGrant() == nil { return "", xerr.New(xerr.Unavailable, "wallet grant response is empty") } return resp.GetGrant().GetGrantId(), nil } func selectRoomRocketReward(rocketID string, role string, userID int64, pool []RoomRocketRewardItem) (RoomRocketRewardItem, bool) { total := int64(0) for _, item := range pool { if item.Weight > 0 && item.ResourceGroupID > 0 { total += item.Weight } } if total <= 0 { return RoomRocketRewardItem{}, false } hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", rocketID, role, userID))) pick := int64(binary.BigEndian.Uint64(hash[:8]) % uint64(total)) cursor := int64(0) for _, item := range pool { if item.Weight <= 0 || item.ResourceGroupID <= 0 { continue } cursor += item.Weight if pick < cursor { return item, true } } return pool[len(pool)-1], true } func roomRocketGrantCommandID(rocketID string, role string, userID int64, rewardItemID string) string { hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", rocketID, role, userID, rewardItemID))) return fmt.Sprintf("cmd_room_rocket_grant_%x", hash[:8]) } func rocketRewardsToCommand(input []state.RocketRewardGrant) []command.RocketRewardGrant { out := make([]command.RocketRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, command.RocketRewardGrant{ RewardRole: reward.RewardRole, UserID: reward.UserID, RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, DisplayName: reward.DisplayName, IconURL: reward.IconURL, GrantID: reward.GrantID, Status: reward.Status, }) } return out } func rocketPendingLaunchesToCommand(input []state.RocketPendingLaunch) []command.RocketPendingLaunch { out := make([]command.RocketPendingLaunch, 0, len(input)) for _, launch := range input { out = append(out, command.RocketPendingLaunch{ RocketID: launch.RocketID, Level: launch.Level, FuelThreshold: launch.FuelThreshold, IgnitedAtMS: launch.IgnitedAtMS, LaunchAtMS: launch.LaunchAtMS, ResetAtMS: launch.ResetAtMS, Top1UserID: launch.Top1UserID, IgniterUserID: launch.IgniterUserID, ConfigVersion: launch.ConfigVersion, CoverURL: launch.CoverURL, }) } return out }