2026-05-29 11:59:57 +08:00

933 lines
32 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
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 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.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
}