2026-07-07 18:28:50 +08:00

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