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