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 ( roomTreasureLevelCount = 7 roomTreasureDefaultEnergySource = "gift_point_added" roomTreasureDefaultOpenDelayMS = int64(30_000) roomTreasureDefaultBroadcastScope = "region" roomTreasureDefaultRewardStackPolicy = "allow_stack" roomTreasureEnergyGiftPoint = "gift_point_added" roomTreasureEnergyHeatValue = "heat_value" roomTreasureBroadcastNone = "none" roomTreasureBroadcastRegion = "region" roomTreasureBroadcastGlobal = "global" roomTreasureRewardStackAllow = "allow_stack" roomTreasureRewardStackPriority = "priority_only" roomTreasureRewardInRoom = "in_room" roomTreasureRewardTop1 = "top1" roomTreasureRewardIgniter = "igniter" roomTreasureGrantSource = "room_treasure" roomTreasureGrantStatus = "succeeded" ) var roomTreasureDefaultThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000, 21_000, 28_000} // GetRoomTreasure 返回房间宝箱 UI 初始化数据;它只读 Room Cell,不刷新 presence。 func (s *Service) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, 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 containsUserID(snapshot.GetBanUserIds(), viewerUserID) || findProtoUser(snapshot, viewerUserID) == nil { // 宝箱状态属于房间内活动信息,只允许仍在业务 presence 内的用户读取。 return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room") } config, err := s.roomTreasureConfig(ctx) if err != nil { return nil, err } now := s.clock.Now().UTC() info := roomTreasureInfoFromConfig(config, snapshot.GetTreasure(), now) return &roomv1.GetRoomTreasureResponse{ Treasure: info, ServerTimeMs: now.UnixMilli(), }, nil } func (s *Service) roomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error) { config, exists, err := s.repository.GetRoomTreasureConfig(ctx) if err != nil { return RoomTreasureConfig{}, err } if !exists { config = defaultRoomTreasureConfig(appcode.FromContext(ctx)) } return normalizeRoomTreasureConfig(config) } func defaultRoomTreasureConfig(appCode string) RoomTreasureConfig { levels := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount) for index, threshold := range roomTreasureDefaultThresholds { levels = append(levels, RoomTreasureLevelConfig{ Level: int32(index + 1), EnergyThreshold: threshold, }) } return RoomTreasureConfig{ AppCode: appcode.Normalize(appCode), EnergySource: roomTreasureDefaultEnergySource, OpenDelayMS: roomTreasureDefaultOpenDelayMS, BroadcastEnabled: true, BroadcastScope: roomTreasureDefaultBroadcastScope, RewardStackPolicy: roomTreasureDefaultRewardStackPolicy, Levels: levels, } } func normalizeRoomTreasureConfig(config RoomTreasureConfig) (RoomTreasureConfig, error) { config.AppCode = appcode.Normalize(config.AppCode) if config.AppCode == "" { config.AppCode = appcode.Default } if config.ConfigVersion < 0 { return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure config_version is invalid") } config.EnergySource = defaultRoomTreasureString(config.EnergySource, roomTreasureDefaultEnergySource) if config.EnergySource != roomTreasureEnergyGiftPoint && config.EnergySource != roomTreasureEnergyHeatValue { return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure energy_source is invalid") } if config.OpenDelayMS < 0 || config.BroadcastDelayMS < 0 { return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure delay is invalid") } config.BroadcastScope = defaultRoomTreasureString(config.BroadcastScope, roomTreasureDefaultBroadcastScope) if config.BroadcastScope != roomTreasureBroadcastNone && config.BroadcastScope != roomTreasureBroadcastRegion && config.BroadcastScope != roomTreasureBroadcastGlobal { return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure broadcast_scope is invalid") } if !config.BroadcastEnabled { config.BroadcastScope = roomTreasureBroadcastNone } config.RewardStackPolicy = defaultRoomTreasureString(config.RewardStackPolicy, roomTreasureDefaultRewardStackPolicy) if config.RewardStackPolicy != roomTreasureRewardStackAllow && config.RewardStackPolicy != roomTreasureRewardStackPriority { return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure reward_stack_policy is invalid") } levels, err := normalizeRoomTreasureLevels(config.Levels) if err != nil { return RoomTreasureConfig{}, err } config.Levels = levels config.GiftEnergyRules = normalizeRoomTreasureEnergyRules(config.GiftEnergyRules) return config, nil } func normalizeRoomTreasureLevels(levels []RoomTreasureLevelConfig) ([]RoomTreasureLevelConfig, error) { if len(levels) == 0 { return defaultRoomTreasureConfig(appcode.Default).Levels, nil } if len(levels) != roomTreasureLevelCount { return nil, xerr.New(xerr.InvalidArgument, "room treasure levels must be 7") } seen := make(map[int32]bool, roomTreasureLevelCount) normalized := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount) for _, level := range levels { if level.Level < 1 || level.Level > roomTreasureLevelCount || seen[level.Level] { return nil, xerr.New(xerr.InvalidArgument, "room treasure level is invalid") } if level.EnergyThreshold <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room treasure energy_threshold is invalid") } seen[level.Level] = true level.CoverURL = strings.TrimSpace(level.CoverURL) level.AnimationURL = strings.TrimSpace(level.AnimationURL) level.OpeningAnimationURL = strings.TrimSpace(level.OpeningAnimationURL) level.OpenedImageURL = strings.TrimSpace(level.OpenedImageURL) level.InRoomRewards = normalizeRoomTreasureRewardPool(level.InRoomRewards) level.Top1Rewards = normalizeRoomTreasureRewardPool(level.Top1Rewards) level.IgniterRewards = normalizeRoomTreasureRewardPool(level.IgniterRewards) normalized = append(normalized, level) } slices.SortFunc(normalized, func(left, right RoomTreasureLevelConfig) int { return int(left.Level - right.Level) }) return normalized, nil } func normalizeRoomTreasureRewardPool(input []RoomTreasureRewardItem) []RoomTreasureRewardItem { out := make([]RoomTreasureRewardItem, 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 normalizeRoomTreasureEnergyRules(input []GiftEnergyRuleConfig) []GiftEnergyRuleConfig { out := make([]GiftEnergyRuleConfig, 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.FixedEnergy < 0 { continue } out = append(out, rule) } return out } func defaultRoomTreasureString(value string, fallback string) string { value = strings.TrimSpace(value) if value == "" { return fallback } return value } func roomTreasureInfoFromConfig(config RoomTreasureConfig, current *roomv1.RoomTreasureState, now time.Time) *roomv1.RoomTreasureInfo { stateView := roomTreasureProtoStateForView(current, config, now) return &roomv1.RoomTreasureInfo{ Enabled: config.Enabled, Levels: roomTreasureLevelsToProto(config.Levels), State: stateView, ServerTimeMs: now.UnixMilli(), BroadcastScope: config.BroadcastScope, OpenDelayMs: config.OpenDelayMS, BroadcastDelayMs: config.BroadcastDelayMS, RewardStackPolicy: config.RewardStackPolicy, } } func roomTreasureProtoStateForView(current *roomv1.RoomTreasureState, config RoomTreasureConfig, now time.Time) *roomv1.RoomTreasureState { internal := state.TreasureState{} if current != nil { internal = state.TreasureState{ CurrentLevel: current.GetCurrentLevel(), CurrentProgress: current.GetCurrentProgress(), EnergyThreshold: current.GetEnergyThreshold(), Status: current.GetStatus(), CountdownStartedAtMS: current.GetCountdownStartedAtMs(), OpenAtMS: current.GetOpenAtMs(), OpenedAtMS: current.GetOpenedAtMs(), ResetAtMS: current.GetResetAtMs(), Top1UserID: current.GetTop1UserId(), IgniterUserID: current.GetIgniterUserId(), BoxID: current.GetBoxId(), ConfigVersion: current.GetConfigVersion(), LastRewards: stateRewardsFromProto(current.GetLastRewards()), } } normalized := treasureStateForNow(&internal, config, now, false) return treasureStateToRoomProto(normalized) } func stateRewardsFromProto(input []*roomv1.RoomTreasureRewardGrant) []state.TreasureRewardGrant { out := make([]state.TreasureRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, state.TreasureRewardGrant{ 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 roomTreasureLevelsToProto(levels []RoomTreasureLevelConfig) []*roomv1.RoomTreasureLevel { out := make([]*roomv1.RoomTreasureLevel, 0, len(levels)) for _, level := range levels { out = append(out, &roomv1.RoomTreasureLevel{ Level: level.Level, EnergyThreshold: level.EnergyThreshold, CoverUrl: level.CoverURL, AnimationUrl: level.AnimationURL, OpeningAnimationUrl: level.OpeningAnimationURL, OpenedImageUrl: level.OpenedImageURL, InRoomRewards: roomTreasureRewardsToProto(level.InRoomRewards), Top1Rewards: roomTreasureRewardsToProto(level.Top1Rewards), IgniterRewards: roomTreasureRewardsToProto(level.IgniterRewards), }) } return out } func roomTreasureRewardsToProto(input []RoomTreasureRewardItem) []*roomv1.RoomTreasureRewardItem { out := make([]*roomv1.RoomTreasureRewardItem, 0, len(input)) for _, reward := range input { out = append(out, &roomv1.RoomTreasureRewardItem{ RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, Weight: reward.Weight, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, }) } return out } type treasureGiftApplyResult struct { touched bool progressEvent *roomeventsv1.RoomTreasureProgressChanged countdown *roomeventsv1.RoomTreasureCountdownStarted } func (s *Service) applyRoomTreasureGift(now time.Time, current *state.RoomState, cfg RoomTreasureConfig, cmd command.SendGift, settled *command.SendGift, billing *walletv1.DebitGiftResponse, roomMeta RoomMeta) (treasureGiftApplyResult, error) { if !cfg.Enabled { return treasureGiftApplyResult{}, nil } now = now.UTC() nowMS := now.UnixMilli() treasure := treasureStateForNow(current.Treasure, cfg, now, true) result := treasureGiftApplyResult{touched: current.Treasure == nil || treasure.ResetAtMS != current.Treasure.ResetAtMS} addedEnergy := roomTreasureEnergy(cfg, cmd.GiftID, billing.GetGiftTypeCode(), billing.GetGiftPointAdded(), billing.GetHeatValue()) effectiveEnergy := int64(0) overflowEnergy := int64(0) if treasure.Status == state.TreasureStatusCountdown { // 倒计时期间送礼只走普通礼物表现,宝箱能量按产品规则整笔无效。 overflowEnergy = addedEnergy } else if addedEnergy > 0 { remaining := treasure.EnergyThreshold - treasure.CurrentProgress if remaining < 0 { remaining = 0 } effectiveEnergy = minInt64(addedEnergy, remaining) overflowEnergy = addedEnergy - effectiveEnergy if effectiveEnergy > 0 { treasure.CurrentProgress += effectiveEnergy result.touched = true } if treasure.CurrentProgress >= treasure.EnergyThreshold { treasure.CurrentProgress = treasure.EnergyThreshold treasure.Status = state.TreasureStatusCountdown treasure.CountdownStartedAtMS = nowMS treasure.OpenAtMS = nowMS + cfg.OpenDelayMS treasure.Top1UserID = firstRankUserID(current.GiftRank) treasure.IgniterUserID = cmd.ActorUserID() treasure.ConfigVersion = cfg.ConfigVersion result.countdown = &roomeventsv1.RoomTreasureCountdownStarted{ BoxId: treasure.BoxID, Level: treasure.CurrentLevel, CurrentProgress: treasure.CurrentProgress, EnergyThreshold: treasure.EnergyThreshold, CountdownStartedAtMs: treasure.CountdownStartedAtMS, OpenAtMs: treasure.OpenAtMS, BroadcastScope: cfg.BroadcastScope, VisibleRegionId: roomMeta.VisibleRegionID, Top1UserId: treasure.Top1UserID, IgniterUserId: treasure.IgniterUserID, CommandId: cmd.ID(), } } } if result.touched { current.Treasure = treasure } if effectiveEnergy > 0 { result.progressEvent = &roomeventsv1.RoomTreasureProgressChanged{ BoxId: treasure.BoxID, Level: treasure.CurrentLevel, AddedEnergy: addedEnergy, EffectiveAddedEnergy: effectiveEnergy, OverflowEnergy: overflowEnergy, CurrentProgress: treasure.CurrentProgress, EnergyThreshold: treasure.EnergyThreshold, Status: treasure.Status, ResetAtMs: treasure.ResetAtMS, SenderUserId: cmd.ActorUserID(), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, CommandId: cmd.ID(), VisibleRegionId: roomMeta.VisibleRegionID, } } if result.touched { settled.GiftTypeCode = billing.GetGiftTypeCode() settled.TreasureTouched = true settled.TreasureAddedEnergy = addedEnergy settled.TreasureEffectiveEnergy = effectiveEnergy settled.TreasureOverflowEnergy = overflowEnergy settled.TreasureLevel = treasure.CurrentLevel settled.TreasureProgress = treasure.CurrentProgress settled.TreasureStatus = treasure.Status settled.TreasureEnergyThreshold = treasure.EnergyThreshold settled.TreasureCountdownStartedAtMS = treasure.CountdownStartedAtMS settled.TreasureOpenAtMS = treasure.OpenAtMS settled.TreasureResetAtMS = treasure.ResetAtMS settled.TreasureTop1UserID = treasure.Top1UserID settled.TreasureIgniterUserID = treasure.IgniterUserID settled.TreasureBoxID = treasure.BoxID settled.TreasureConfigVersion = treasure.ConfigVersion } return result, nil } func treasureStateForNow(input *state.TreasureState, cfg RoomTreasureConfig, now time.Time, allocateBoxID bool) *state.TreasureState { now = now.UTC() nowMS := now.UnixMilli() resetAtMS := nextUTCResetMS(now) level := int32(1) if input != nil && input.CurrentLevel >= 1 && input.CurrentLevel <= roomTreasureLevelCount { level = input.CurrentLevel } if input == nil || input.ResetAtMS <= 0 || nowMS >= input.ResetAtMS { return newTreasureState(levelForUTCReset(), roomTreasureLevelByNumber(cfg, levelForUTCReset()), resetAtMS, cfg.ConfigVersion, allocateBoxID) } treasure := input.Clone() levelConfig := roomTreasureLevelByNumber(cfg, level) treasure.CurrentLevel = level treasure.EnergyThreshold = levelConfig.EnergyThreshold treasure.ResetAtMS = input.ResetAtMS if treasure.Status == "" { treasure.Status = state.TreasureStatusIdle } if treasure.Status != state.TreasureStatusCountdown { treasure.Status = state.TreasureStatusIdle treasure.OpenAtMS = 0 treasure.CountdownStartedAtMS = 0 } if treasure.CurrentProgress < 0 { treasure.CurrentProgress = 0 } if treasure.CurrentProgress > treasure.EnergyThreshold { treasure.CurrentProgress = treasure.EnergyThreshold } if treasure.BoxID == "" && allocateBoxID { treasure.BoxID = idgen.New("room_treasure_box") } return treasure } func newTreasureState(level int32, levelConfig RoomTreasureLevelConfig, resetAtMS int64, configVersion int64, allocateBoxID bool) *state.TreasureState { boxID := "" if allocateBoxID { boxID = idgen.New("room_treasure_box") } return &state.TreasureState{ CurrentLevel: level, EnergyThreshold: levelConfig.EnergyThreshold, Status: state.TreasureStatusIdle, ResetAtMS: resetAtMS, BoxID: boxID, 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 roomTreasureLevelByNumber(cfg RoomTreasureConfig, level int32) RoomTreasureLevelConfig { for _, item := range cfg.Levels { if item.Level == level { return item } } return defaultRoomTreasureConfig(cfg.AppCode).Levels[0] } func roomTreasureEnergy(cfg RoomTreasureConfig, giftID string, giftTypeCode string, giftPointAdded int64, heatValue int64) int64 { base := giftPointAdded if cfg.EnergySource == roomTreasureEnergyHeatValue { base = heatValue } if base < 0 { base = 0 } for _, rule := range cfg.GiftEnergyRules { if rule.GiftID != "" && rule.GiftID == giftID { return roomTreasureEnergyByRule(base, rule) } } for _, rule := range cfg.GiftEnergyRules { if rule.GiftID == "" && rule.GiftTypeCode != "" && rule.GiftTypeCode == giftTypeCode { return roomTreasureEnergyByRule(base, rule) } } return base } func roomTreasureEnergyByRule(base int64, rule GiftEnergyRuleConfig) int64 { if rule.Excluded { return 0 } energy := int64(0) if rule.MultiplierPPM > 0 { energy += base * rule.MultiplierPPM / 1_000_000 } energy += rule.FixedEnergy if energy < 0 { return 0 } return energy } 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 treasureStateToRoomProto(input *state.TreasureState) *roomv1.RoomTreasureState { if input == nil { return nil } rewards := make([]*roomv1.RoomTreasureRewardGrant, 0, len(input.LastRewards)) for _, reward := range input.LastRewards { rewards = append(rewards, &roomv1.RoomTreasureRewardGrant{ 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 &roomv1.RoomTreasureState{ CurrentLevel: input.CurrentLevel, CurrentProgress: input.CurrentProgress, EnergyThreshold: input.EnergyThreshold, Status: input.Status, CountdownStartedAtMs: input.CountdownStartedAtMS, OpenAtMs: input.OpenAtMS, OpenedAtMs: input.OpenedAtMS, ResetAtMs: input.ResetAtMS, Top1UserId: input.Top1UserID, IgniterUserId: input.IgniterUserID, BoxId: input.BoxID, ConfigVersion: input.ConfigVersion, LastRewards: rewards, } } func treasureStateToEventRewards(input []state.TreasureRewardGrant) []*roomeventsv1.RoomTreasureRewardGrant { out := make([]*roomeventsv1.RoomTreasureRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, &roomeventsv1.RoomTreasureRewardGrant{ 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 } // RunRoomTreasureOpenWorker 周期性扫描已装载房间,把到点的倒计时宝箱走命令链路打开。 func (s *Service) RunRoomTreasureOpenWorker(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.SweepRoomTreasureOpenings(ctx) } } } // SweepRoomTreasureOpenings 只处理本节点仍持有 lease 的已装载 Room Cell。 func (s *Service) SweepRoomTreasureOpenings(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.Treasure == nil { continue } treasure := currentState.Treasure if treasure.ResetAtMS > 0 && nowMS >= treasure.ResetAtMS { // UTC 切日优先级高于延迟开箱;跨日倒计时宝箱不再结算,下一次查询或送礼会按新 UTC 日投影/持久化一级新进度。 continue } if treasure.Status != state.TreasureStatusCountdown || treasure.OpenAtMS <= 0 || treasure.OpenAtMS > nowMS { continue } cmd := command.OpenRoomTreasure{ Base: command.Base{ AppCode: roomRef.AppCode, RequestID: idgen.New("req_room_treasure_open"), CommandID: roomTreasureOpenCommandID(treasure.BoxID), ActorID: roomTreasureSystemActor(currentState), Room: roomRef.RoomID, GatewayNodeID: s.nodeID, SessionID: "room-treasure-open", SentAtMS: nowMS, }, BoxID: treasure.BoxID, Level: treasure.CurrentLevel, } if _, err := s.openRoomTreasure(roomCtx, cmd); err != nil { return err } } return nil } func roomTreasureOpenCommandID(boxID string) string { if strings.TrimSpace(boxID) == "" { return idgen.New("cmd_room_treasure_open") } return "cmd_room_treasure_open_" + boxID } func roomTreasureSystemActor(current *state.RoomState) int64 { if current == nil { return 0 } if current.OwnerUserID > 0 { return current.OwnerUserID } if current.Treasure != nil && current.Treasure.IgniterUserID > 0 { return current.Treasure.IgniterUserID } return 0 } func (s *Service) openRoomTreasure(ctx context.Context, cmd command.OpenRoomTreasure) (*roomv1.RoomTreasureState, 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.Treasure == nil || current.Treasure.Status != state.TreasureStatusCountdown { return mutationResult{snapshot: current.ToProto()}, nil, nil } if current.Treasure.BoxID != cmd.BoxID || current.Treasure.CurrentLevel != cmd.Level { return mutationResult{snapshot: current.ToProto()}, nil, nil } if current.Treasure.ResetAtMS > 0 && nowMS >= current.Treasure.ResetAtMS { // 手动或延迟到达的开箱命令也不能越过 UTC 日界结算昨天的宝箱。 return mutationResult{snapshot: current.ToProto()}, nil, nil } if current.Treasure.OpenAtMS <= 0 || current.Treasure.OpenAtMS > nowMS { return mutationResult{snapshot: current.ToProto()}, nil, nil } cfg, err := s.roomTreasureConfig(ctx) if err != nil { return mutationResult{}, nil, err } levelCfg := roomTreasureLevelByNumber(cfg, current.Treasure.CurrentLevel) inRoomUserIDs := sortedTreasureOnlineUserIDs(current.OnlineUsers) rewards, err := s.settleRoomTreasureRewards(ctx, now, current, cfg, levelCfg, inRoomUserIDs) if err != nil { return mutationResult{}, nil, err } nextLevel := current.Treasure.CurrentLevel + 1 if nextLevel > roomTreasureLevelCount { // 七级是当天最高等级;打开七级后继续留在七级,UTC 切日再回到一级。 nextLevel = roomTreasureLevelCount } nextLevelCfg := roomTreasureLevelByNumber(cfg, nextLevel) settledCommand := cmd settledCommand.NextLevel = nextLevel settledCommand.NextEnergyThreshold = nextLevelCfg.EnergyThreshold settledCommand.ConfigVersion = cfg.ConfigVersion settledCommand.ResetAtMS = current.Treasure.ResetAtMS settledCommand.Top1UserID = current.Treasure.Top1UserID settledCommand.IgniterUserID = current.Treasure.IgniterUserID settledCommand.InRoomUserIDs = inRoomUserIDs settledCommand.Rewards = treasureRewardsToCommand(rewards) commandPayload, err := command.Serialize(settledCommand) if err != nil { return mutationResult{}, nil, err } openedBoxID := current.Treasure.BoxID openedLevel := current.Treasure.CurrentLevel top1UserID := current.Treasure.Top1UserID igniterUserID := current.Treasure.IgniterUserID resetAtMS := current.Treasure.ResetAtMS current.Treasure = &state.TreasureState{ CurrentLevel: nextLevel, EnergyThreshold: nextLevelCfg.EnergyThreshold, Status: state.TreasureStatusIdle, OpenedAtMS: nowMS, ResetAtMS: resetAtMS, ConfigVersion: cfg.ConfigVersion, LastRewards: rewards, } current.Version++ openedEvent, err := outbox.Build(current.RoomID, "RoomTreasureOpened", current.Version, now, &roomeventsv1.RoomTreasureOpened{ BoxId: openedBoxID, Level: openedLevel, NextLevel: nextLevel, OpenedAtMs: nowMS, ResetAtMs: resetAtMS, Top1UserId: top1UserID, IgniterUserId: igniterUserID, InRoomUserIds: inRoomUserIDs, Rewards: treasureStateToEventRewards(rewards), }) if err != nil { return mutationResult{}, nil, err } records := []outbox.Record{openedEvent} if len(rewards) > 0 { rewardEvent, err := outbox.Build(current.RoomID, "RoomTreasureRewardGranted", current.Version, now, &roomeventsv1.RoomTreasureRewardGranted{ BoxId: openedBoxID, Level: openedLevel, Rewards: treasureStateToEventRewards(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.GetTreasure(), nil } func sortedTreasureOnlineUserIDs(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) settleRoomTreasureRewards(ctx context.Context, now time.Time, current *state.RoomState, cfg RoomTreasureConfig, levelCfg RoomTreasureLevelConfig, inRoomUserIDs []int64) ([]state.TreasureRewardGrant, error) { if current == nil || current.Treasure == nil { return nil, nil } rewards := make([]state.TreasureRewardGrant, 0, len(inRoomUserIDs)+2) claimed := make(map[int64]bool, len(inRoomUserIDs)+2) addReward := func(role string, userID int64, pool []RoomTreasureRewardItem) error { if userID <= 0 { return nil } if cfg.RewardStackPolicy == roomTreasureRewardStackPriority && claimed[userID] { return nil } item, ok := selectRoomTreasureReward(current.Treasure.BoxID, role, userID, pool) if !ok { return nil } grantID, err := s.grantRoomTreasureReward(ctx, now, current, current.Treasure.BoxID, role, userID, item) if err != nil { return err } rewards = append(rewards, state.TreasureRewardGrant{ RewardRole: role, UserID: userID, RewardItemID: item.RewardItemID, ResourceGroupID: item.ResourceGroupID, DisplayName: item.DisplayName, IconURL: item.IconURL, GrantID: grantID, Status: roomTreasureGrantStatus, }) claimed[userID] = true return nil } if cfg.RewardStackPolicy == roomTreasureRewardStackPriority { // 去重策略下先发特殊身份奖励,再发普通在房奖励,避免 top1/点火人被普通池占位。 if err := addReward(roomTreasureRewardIgniter, current.Treasure.IgniterUserID, levelCfg.IgniterRewards); err != nil { return nil, err } if err := addReward(roomTreasureRewardTop1, current.Treasure.Top1UserID, levelCfg.Top1Rewards); err != nil { return nil, err } for _, userID := range inRoomUserIDs { if err := addReward(roomTreasureRewardInRoom, userID, levelCfg.InRoomRewards); err != nil { return nil, err } } return rewards, nil } for _, userID := range inRoomUserIDs { if err := addReward(roomTreasureRewardInRoom, userID, levelCfg.InRoomRewards); err != nil { return nil, err } } if err := addReward(roomTreasureRewardTop1, current.Treasure.Top1UserID, levelCfg.Top1Rewards); err != nil { return nil, err } if err := addReward(roomTreasureRewardIgniter, current.Treasure.IgniterUserID, levelCfg.IgniterRewards); err != nil { return nil, err } return rewards, nil } func (s *Service) grantRoomTreasureReward(ctx context.Context, _ time.Time, current *state.RoomState, boxID string, role string, userID int64, item RoomTreasureRewardItem) (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: roomTreasureGrantCommandID(boxID, role, userID, item.RewardItemID), AppCode: appcode.FromContext(ctx), TargetUserId: userID, GroupId: item.ResourceGroupID, Reason: fmt.Sprintf("room treasure %s reward", role), OperatorUserId: operatorUserID, GrantSource: roomTreasureGrantSource, }) 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 selectRoomTreasureReward(boxID string, role string, userID int64, pool []RoomTreasureRewardItem) (RoomTreasureRewardItem, bool) { total := int64(0) for _, item := range pool { if item.Weight > 0 && item.ResourceGroupID > 0 { total += item.Weight } } if total <= 0 { return RoomTreasureRewardItem{}, false } hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", boxID, 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 roomTreasureGrantCommandID(boxID string, role string, userID int64, rewardItemID string) string { hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", boxID, role, userID, rewardItemID))) return fmt.Sprintf("cmd_room_treasure_grant_%x", hash[:8]) } func treasureRewardsToCommand(input []state.TreasureRewardGrant) []command.TreasureRewardGrant { out := make([]command.TreasureRewardGrant, 0, len(input)) for _, reward := range input { out = append(out, command.TreasureRewardGrant{ 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 }