2026-05-20 14:34:10 +08:00

423 lines
14 KiB
Go

package roomtreasure
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
)
const (
roomTreasureLevelCount = 7
defaultEnergySource = "gift_point_added"
defaultOpenDelayMS = int64(30_000)
defaultBroadcastScope = "region"
defaultRewardStackPolicy = "allow_stack"
)
var (
ErrInvalidArgument = errors.New("invalid argument")
defaultLevelEnergyThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000, 21_000, 28_000}
)
type Service struct {
db *sql.DB
}
type RoomTreasureConfig struct {
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
ConfigVersion int64 `json:"configVersion"`
EnergySource string `json:"energySource"`
OpenDelayMS int64 `json:"openDelayMs"`
BroadcastEnabled bool `json:"broadcastEnabled"`
BroadcastScope string `json:"broadcastScope"`
BroadcastDelayMS int64 `json:"broadcastDelayMs"`
RewardStackPolicy string `json:"rewardStackPolicy"`
Levels []RoomTreasureLevelConfig `json:"levels"`
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
UpdatedByAdminID int64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type RoomTreasureLevelConfig struct {
Level int32 `json:"level"`
EnergyThreshold int64 `json:"energyThreshold"`
CoverURL string `json:"coverUrl"`
AnimationURL string `json:"animationUrl"`
OpeningAnimationURL string `json:"openingAnimationUrl"`
OpenedImageURL string `json:"openedImageUrl"`
InRoomRewards []RoomTreasureRewardItem `json:"inRoomRewards"`
Top1Rewards []RoomTreasureRewardItem `json:"top1Rewards"`
IgniterRewards []RoomTreasureRewardItem `json:"igniterRewards"`
}
type RoomTreasureRewardItem struct {
RewardItemID string `json:"rewardItemId"`
ResourceGroupID int64 `json:"resourceGroupId"`
Weight int64 `json:"weight"`
DisplayName string `json:"displayName"`
IconURL string `json:"iconUrl"`
}
type GiftEnergyRuleConfig struct {
RuleID string `json:"ruleId"`
GiftID string `json:"giftId"`
GiftTypeCode string `json:"giftTypeCode"`
MultiplierPPM int64 `json:"multiplierPpm"`
FixedEnergy int64 `json:"fixedEnergy"`
Excluded bool `json:"excluded"`
}
type updateRoomTreasureConfigRequest struct {
Enabled bool `json:"enabled"`
EnergySource string `json:"energySource"`
OpenDelayMS int64 `json:"openDelayMs"`
BroadcastEnabled bool `json:"broadcastEnabled"`
BroadcastScope string `json:"broadcastScope"`
BroadcastDelayMS int64 `json:"broadcastDelayMs"`
RewardStackPolicy string `json:"rewardStackPolicy"`
Levels []RoomTreasureLevelConfig `json:"levels"`
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
// GetConfig 读取当前 App 的语音房宝箱配置;缺行时返回关闭态默认配置,便于首次进入后台页面。
func (s *Service) GetConfig(ctx context.Context) (RoomTreasureConfig, error) {
if s.db == nil {
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
}
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT app_code,
enabled,
config_version,
energy_source,
open_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_energy_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
FROM room_treasure_configs
WHERE app_code = ?
`, appCode)
var enabled int
var broadcastEnabled int
var rawLevels string
var rawGiftEnergyRules string
config := defaultConfig(appCode)
if err := row.Scan(
&config.AppCode,
&enabled,
&config.ConfigVersion,
&config.EnergySource,
&config.OpenDelayMS,
&broadcastEnabled,
&config.BroadcastScope,
&config.BroadcastDelayMS,
&config.RewardStackPolicy,
&rawLevels,
&rawGiftEnergyRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return config, nil
}
return RoomTreasureConfig{}, err
}
config.Enabled = enabled == 1
config.BroadcastEnabled = broadcastEnabled == 1
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
return RoomTreasureConfig{}, err
}
if err := json.Unmarshal([]byte(rawGiftEnergyRules), &config.GiftEnergyRules); err != nil {
return RoomTreasureConfig{}, err
}
return normalizeConfig(config)
}
// UpdateConfig 保存房间宝箱低频配置;版本号由后台递增,运行时只使用已提交版本。
func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfigRequest, operatorAdminID int64) (RoomTreasureConfig, error) {
if s.db == nil {
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
}
current, err := s.GetConfig(ctx)
if err != nil {
return RoomTreasureConfig{}, err
}
nowMS := time.Now().UTC().UnixMilli()
createdAtMS := current.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = nowMS
}
config, err := normalizeConfig(RoomTreasureConfig{
AppCode: appctx.FromContext(ctx),
Enabled: req.Enabled,
ConfigVersion: current.ConfigVersion + 1,
EnergySource: req.EnergySource,
OpenDelayMS: req.OpenDelayMS,
BroadcastEnabled: req.BroadcastEnabled,
BroadcastScope: req.BroadcastScope,
BroadcastDelayMS: req.BroadcastDelayMS,
RewardStackPolicy: req.RewardStackPolicy,
Levels: req.Levels,
GiftEnergyRules: req.GiftEnergyRules,
UpdatedByAdminID: operatorAdminID,
CreatedAtMS: createdAtMS,
UpdatedAtMS: nowMS,
})
if err != nil {
return RoomTreasureConfig{}, err
}
rawLevels, err := json.Marshal(config.Levels)
if err != nil {
return RoomTreasureConfig{}, err
}
rawGiftEnergyRules, err := json.Marshal(config.GiftEnergyRules)
if err != nil {
return RoomTreasureConfig{}, err
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_treasure_configs (
app_code,
enabled,
config_version,
energy_source,
open_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_energy_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
config_version = VALUES(config_version),
energy_source = VALUES(energy_source),
open_delay_ms = VALUES(open_delay_ms),
broadcast_enabled = VALUES(broadcast_enabled),
broadcast_scope = VALUES(broadcast_scope),
broadcast_delay_ms = VALUES(broadcast_delay_ms),
reward_stack_policy = VALUES(reward_stack_policy),
levels_json = VALUES(levels_json),
gift_energy_rules_json = VALUES(gift_energy_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)
`,
config.AppCode,
boolInt(config.Enabled),
config.ConfigVersion,
config.EnergySource,
config.OpenDelayMS,
boolInt(config.BroadcastEnabled),
config.BroadcastScope,
config.BroadcastDelayMS,
config.RewardStackPolicy,
string(rawLevels),
string(rawGiftEnergyRules),
config.UpdatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
); err != nil {
return RoomTreasureConfig{}, err
}
return config, nil
}
func defaultConfig(appCode string) RoomTreasureConfig {
levels := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
for index, threshold := range defaultLevelEnergyThresholds {
levels = append(levels, RoomTreasureLevelConfig{
Level: int32(index + 1),
EnergyThreshold: threshold,
})
}
return RoomTreasureConfig{
AppCode: appCode,
EnergySource: defaultEnergySource,
OpenDelayMS: defaultOpenDelayMS,
BroadcastEnabled: true,
BroadcastScope: defaultBroadcastScope,
RewardStackPolicy: defaultRewardStackPolicy,
Levels: levels,
GiftEnergyRules: []GiftEnergyRuleConfig{},
}
}
func normalizeConfig(config RoomTreasureConfig) (RoomTreasureConfig, error) {
config.AppCode = strings.TrimSpace(config.AppCode)
if config.AppCode == "" {
return RoomTreasureConfig{}, fmt.Errorf("%w: app_code 不能为空", ErrInvalidArgument)
}
if config.ConfigVersion < 0 {
return RoomTreasureConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument)
}
config.EnergySource = defaultString(strings.TrimSpace(config.EnergySource), defaultEnergySource)
if !stringIn(config.EnergySource, "gift_point_added", "heat_value") {
return RoomTreasureConfig{}, fmt.Errorf("%w: 能量来源不支持", ErrInvalidArgument)
}
if config.OpenDelayMS < 0 {
return RoomTreasureConfig{}, fmt.Errorf("%w: 开箱倒计时不能小于 0", ErrInvalidArgument)
}
config.BroadcastScope = defaultString(strings.TrimSpace(config.BroadcastScope), defaultBroadcastScope)
if !stringIn(config.BroadcastScope, "none", "region", "global") {
return RoomTreasureConfig{}, fmt.Errorf("%w: 广播范围不支持", ErrInvalidArgument)
}
if config.BroadcastDelayMS < 0 {
return RoomTreasureConfig{}, fmt.Errorf("%w: 广播延迟不能小于 0", ErrInvalidArgument)
}
config.RewardStackPolicy = defaultString(strings.TrimSpace(config.RewardStackPolicy), defaultRewardStackPolicy)
if !stringIn(config.RewardStackPolicy, "allow_stack", "priority_only") {
return RoomTreasureConfig{}, fmt.Errorf("%w: 奖励叠加策略不支持", ErrInvalidArgument)
}
levels, err := normalizeLevels(config.Levels)
if err != nil {
return RoomTreasureConfig{}, err
}
config.Levels = levels
giftEnergyRules, err := normalizeGiftEnergyRules(config.GiftEnergyRules)
if err != nil {
return RoomTreasureConfig{}, err
}
config.GiftEnergyRules = giftEnergyRules
return config, nil
}
func normalizeLevels(levels []RoomTreasureLevelConfig) ([]RoomTreasureLevelConfig, error) {
if len(levels) == 0 {
return defaultConfig("placeholder").Levels, nil
}
if len(levels) != roomTreasureLevelCount {
return nil, fmt.Errorf("%w: 宝箱等级必须配置 7 级", ErrInvalidArgument)
}
seen := make(map[int32]bool, roomTreasureLevelCount)
normalized := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
for _, level := range levels {
if level.Level < 1 || level.Level > roomTreasureLevelCount {
return nil, fmt.Errorf("%w: 宝箱等级只能是 1 到 7", ErrInvalidArgument)
}
if seen[level.Level] {
return nil, fmt.Errorf("%w: 宝箱等级不能重复", ErrInvalidArgument)
}
seen[level.Level] = true
if level.EnergyThreshold <= 0 {
return nil, fmt.Errorf("%w: 第 %d 级能量阈值必须大于 0", ErrInvalidArgument, level.Level)
}
level.CoverURL = strings.TrimSpace(level.CoverURL)
level.AnimationURL = strings.TrimSpace(level.AnimationURL)
level.OpeningAnimationURL = strings.TrimSpace(level.OpeningAnimationURL)
level.OpenedImageURL = strings.TrimSpace(level.OpenedImageURL)
var err error
if level.InRoomRewards, err = normalizeRewardPool(level.Level, "in_room", level.InRoomRewards); err != nil {
return nil, err
}
if level.Top1Rewards, err = normalizeRewardPool(level.Level, "top1", level.Top1Rewards); err != nil {
return nil, err
}
if level.IgniterRewards, err = normalizeRewardPool(level.Level, "igniter", level.IgniterRewards); err != nil {
return nil, err
}
normalized = append(normalized, level)
}
slices.SortFunc(normalized, func(left, right RoomTreasureLevelConfig) int {
return int(left.Level - right.Level)
})
return normalized, nil
}
func normalizeRewardPool(level int32, role string, rewards []RoomTreasureRewardItem) ([]RoomTreasureRewardItem, error) {
normalized := make([]RoomTreasureRewardItem, 0, len(rewards))
for index, reward := range rewards {
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
reward.IconURL = strings.TrimSpace(reward.IconURL)
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.DisplayName == "" && reward.IconURL == "" {
continue
}
if reward.ResourceGroupID <= 0 {
return nil, fmt.Errorf("%w: 第 %d 级奖励资源组不能为空", ErrInvalidArgument, level)
}
if reward.Weight <= 0 {
return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level)
}
if reward.RewardItemID == "" {
reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1)
}
normalized = append(normalized, reward)
}
return normalized, nil
}
func normalizeGiftEnergyRules(rules []GiftEnergyRuleConfig) ([]GiftEnergyRuleConfig, error) {
normalized := make([]GiftEnergyRuleConfig, 0, len(rules))
for index, rule := range rules {
rule.RuleID = strings.TrimSpace(rule.RuleID)
rule.GiftID = strings.TrimSpace(rule.GiftID)
rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode)
if rule.RuleID == "" && rule.GiftID == "" && rule.GiftTypeCode == "" && rule.MultiplierPPM == 0 && rule.FixedEnergy == 0 && !rule.Excluded {
continue
}
if rule.GiftID == "" && rule.GiftTypeCode == "" {
return nil, fmt.Errorf("%w: 礼物能量规则必须选择礼物或礼物类型", ErrInvalidArgument)
}
if rule.MultiplierPPM < 0 || rule.FixedEnergy < 0 {
return nil, fmt.Errorf("%w: 礼物能量规则不能配置负数", ErrInvalidArgument)
}
if !rule.Excluded && rule.MultiplierPPM == 0 && rule.FixedEnergy == 0 {
return nil, fmt.Errorf("%w: 礼物能量规则必须配置倍率或固定能量", ErrInvalidArgument)
}
if rule.RuleID == "" {
rule.RuleID = fmt.Sprintf("gift_energy_rule_%d", index+1)
}
normalized = append(normalized, rule)
}
return normalized, nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func defaultString(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func stringIn(value string, candidates ...string) bool {
for _, candidate := range candidates {
if value == candidate {
return true
}
}
return false
}