package luckygift import ( "context" "fmt" "math" "strings" "time" "hyapp-admin-server/internal/integration/luckygiftadmin" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" "github.com/gin-gonic/gin" ) type Handler struct { luckyGift luckygiftadmin.Client requestTimeout time.Duration audit shared.OperationLogger } func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { if requestTimeout <= 0 { requestTimeout = 3 * time.Second } return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit} } type configRequest struct { AppCode string `json:"app_code"` PoolID string `json:"pool_id"` Enabled bool `json:"enabled"` TargetRTPPercent float64 `json:"target_rtp_percent"` PoolRatePercent float64 `json:"pool_rate_percent"` SettlementWindowWager int64 `json:"settlement_window_wager"` ControlBandPercent float64 `json:"control_band_percent"` GiftPriceReference int64 `json:"gift_price_reference"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` EffectiveFromMS int64 `json:"effective_from_ms"` Stages []stageDTO `json:"stages"` } type configDTO struct { configRequest RuleVersion int64 `json:"rule_version"` CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedAtMS int64 `json:"created_at_ms"` } type stageDTO struct { Stage string `json:"stage"` Tiers []tierDTO `json:"tiers"` } type tierDTO struct { TierID string `json:"tier_id"` Multiplier float64 `json:"multiplier"` ProbabilityPercent float64 `json:"probability_percent"` HighWaterOnly bool `json:"high_water_only"` Enabled bool `json:"enabled"` } type drawDTO struct { DrawID string `json:"draw_id"` CommandID string `json:"command_id"` PoolID string `json:"pool_id"` GiftID string `json:"gift_id"` RuleVersion int64 `json:"rule_version"` ExperiencePool string `json:"experience_pool"` SelectedTierID string `json:"selected_tier_id"` MultiplierPPM int64 `json:"multiplier_ppm"` BaseRewardCoins int64 `json:"base_reward_coins"` EffectiveRewardCoins int64 `json:"effective_reward_coins"` RewardStatus string `json:"reward_status"` RTPWindowIndex int64 `json:"rtp_window_index"` GiftRTPWindowIndex int64 `json:"gift_rtp_window_index"` GlobalBaseRTPPPM int64 `json:"global_base_rtp_ppm"` GiftBaseRTPPPM int64 `json:"gift_base_rtp_ppm"` StageFeedback bool `json:"stage_feedback"` HighMultiplier bool `json:"high_multiplier"` CreatedAtMS int64 `json:"created_at_ms"` AppCode string `json:"app_code"` UserID int64 `json:"user_id"` ExternalUserID string `json:"external_user_id"` } type drawSummaryDTO struct { PoolID string `json:"pool_id"` TotalDraws int64 `json:"total_draws"` UniqueUsers int64 `json:"unique_users"` UniqueRooms int64 `json:"unique_rooms"` TotalSpentCoins int64 `json:"total_spent_coins"` TotalRewardCoins int64 `json:"total_reward_coins"` BaseRewardCoins int64 `json:"base_reward_coins"` ActualRTPPPM int64 `json:"actual_rtp_ppm"` PendingDraws int64 `json:"pending_draws"` GrantedDraws int64 `json:"granted_draws"` FailedDraws int64 `json:"failed_draws"` } type poolBalanceDTO struct { AppCode string `json:"app_code"` PoolID string `json:"pool_id"` Balance int64 `json:"balance"` ReserveFloor int64 `json:"reserve_floor"` AvailableBalance int64 `json:"available_balance"` TotalIn int64 `json:"total_in"` TotalOut int64 `json:"total_out"` Materialized bool `json:"materialized"` UpdatedAtMS int64 `json:"updated_at_ms"` } func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { if strings.TrimSpace(c.Query("app_code")) == "" { response.BadRequest(c, "app_code is required") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.GetLuckyGiftConfig(ctx, &luckygiftv1.GetLuckyGiftConfigRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), }) if err != nil { response.ServerError(c, "获取幸运礼物配置失败") return } response.OK(c, configFromProto(resp.GetConfig())) } func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { var req configRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "幸运礼物配置参数不正确") return } if req.PoolID == "" { req.PoolID = strings.TrimSpace(c.Query("pool_id")) } if req.AppCode == "" { req.AppCode = strings.TrimSpace(c.Query("app_code")) } if strings.TrimSpace(req.AppCode) == "" { response.BadRequest(c, "app_code is required") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.UpsertLuckyGiftConfig(ctx, &luckygiftv1.UpsertLuckyGiftConfigRequest{ Meta: h.metaForApp(c, req.AppCode), Config: configToProto(req), OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } item := configFromProto(resp.GetConfig()) shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rule_versions", item.PoolID, "success", "") response.OK(c, item) } func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) { if strings.TrimSpace(c.Query("app_code")) == "" { response.BadRequest(c, "app_code is required") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.ListLuckyGiftConfigs(ctx, &luckygiftv1.ListLuckyGiftConfigsRequest{ Meta: h.meta(c), }) if err != nil { response.ServerError(c, "获取幸运礼物奖池列表失败") return } items := make([]configDTO, 0, len(resp.GetConfigs())) for _, config := range resp.GetConfigs() { items = append(items, configFromProto(config)) } response.OK(c, items) } func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { if strings.TrimSpace(c.Query("app_code")) == "" { response.BadRequest(c, "app_code is required") return } options := shared.ListOptions(c) ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.ListLuckyGiftDraws(ctx, &luckygiftv1.ListLuckyGiftDrawsRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), GiftId: strings.TrimSpace(c.Query("gift_id")), UserId: parseOptionalInt64(c.Query("user_id")), RoomId: strings.TrimSpace(c.Query("room_id")), Status: strings.TrimSpace(options.Status), Page: int32(options.Page), PageSize: int32(options.PageSize), ExternalUserId: strings.TrimSpace(c.Query("external_user_id")), ExternalOnly: parseOptionalBool(c.Query("external_only")), }) if err != nil { response.ServerError(c, "获取幸运礼物抽奖记录失败") return } items := make([]drawDTO, 0, len(resp.GetDraws())) for _, draw := range resp.GetDraws() { items = append(items, drawFromProto(draw)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) { if strings.TrimSpace(c.Query("app_code")) == "" { response.BadRequest(c, "app_code is required") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.GetLuckyGiftDrawSummary(ctx, &luckygiftv1.GetLuckyGiftDrawSummaryRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), GiftId: strings.TrimSpace(c.Query("gift_id")), UserId: parseOptionalInt64(c.Query("user_id")), RoomId: strings.TrimSpace(c.Query("room_id")), Status: strings.TrimSpace(c.Query("status")), ExternalUserId: strings.TrimSpace(c.Query("external_user_id")), ExternalOnly: parseOptionalBool(c.Query("external_only")), }) if err != nil { response.ServerError(c, "获取幸运礼物抽奖汇总失败") return } response.OK(c, drawSummaryFromProto(resp.GetSummary())) } func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) { if strings.TrimSpace(c.Query("app_code")) == "" { response.BadRequest(c, "app_code is required") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), }) if err != nil { response.ServerError(c, "获取幸运礼物奖池余额失败") return } items := make([]poolBalanceDTO, 0, len(resp.GetPools())) for _, pool := range resp.GetPools() { items = append(items, poolBalanceFromProto(pool)) } response.OK(c, gin.H{"items": items, "total": len(items)}) } func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta { return h.metaForApp(c, "") } func (h *Handler) metaForApp(c *gin.Context, explicitAppCode string) *luckygiftv1.RequestMeta { appCode := strings.ToLower(strings.TrimSpace(explicitAppCode)) if appCode == "" { appCode = strings.ToLower(strings.TrimSpace(c.Query("app_code"))) } return &luckygiftv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", // ops-center 是 admin 的衍生平台,筛选 app_code 通过 query/body 传入; // 这里把它放进 Admin RPC meta,让 lucky-gift-service 继续作为 app 维度过滤和配置发布的唯一 owner。 AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli(), } } func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(c.Request.Context(), h.requestTimeout) } func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages)) for _, stage := range req.Stages { stageName := strings.TrimSpace(stage.Stage) tierIDCounts := make(map[string]int, len(stage.Tiers)) tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers)) for _, tier := range stage.Tiers { multiplierPPM := multiplierToPPM(tier.Multiplier) tiers = append(tiers, &luckygiftv1.LuckyGiftRuleTier{ Stage: stageName, // 奖档 ID 是 lucky-gift-service 持久化主键的一部分,不能让运营手工维护;admin-server 按阶段和倍率生成稳定 ID, // 同一阶段同倍率出现多行时追加序号,保证进入 lucky-gift-service 前已经满足唯一性。 TierId: generatedLuckyGiftTierID(stageName, multiplierPPM, tierIDCounts), MultiplierPpm: multiplierPPM, BaseWeightPpm: percentToPPM(tier.ProbabilityPercent), HighWaterOnly: tier.HighWaterOnly, Enabled: tier.Enabled, }) } stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{ Stage: stageName, Tiers: tiers, }) } return &luckygiftv1.LuckyGiftRuleConfig{ AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), PoolId: strings.TrimSpace(req.PoolID), Enabled: req.Enabled, TargetRtpPpm: percentToPPM(req.TargetRTPPercent), PoolRatePpm: percentToPPM(req.PoolRatePercent), SettlementWindowWager: req.SettlementWindowWager, ControlBandPpm: percentToPPM(req.ControlBandPercent), GiftPriceReference: req.GiftPriceReference, NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, EffectiveFromMs: req.EffectiveFromMS, Stages: stages, } } func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { if config == nil { return configDTO{} } stages := make([]stageDTO, 0, len(config.GetStages())) for _, stage := range config.GetStages() { tiers := make([]tierDTO, 0, len(stage.GetTiers())) for _, tier := range stage.GetTiers() { tiers = append(tiers, tierDTO{ TierID: tier.GetTierId(), Multiplier: ppmToMultiplier(tier.GetMultiplierPpm()), ProbabilityPercent: ppmToPercent(tier.GetBaseWeightPpm()), HighWaterOnly: tier.GetHighWaterOnly(), Enabled: tier.GetEnabled(), }) } stages = append(stages, stageDTO{ Stage: stage.GetStage(), Tiers: tiers, }) } poolID := strings.TrimSpace(config.GetPoolId()) return configDTO{ RuleVersion: config.GetRuleVersion(), CreatedByAdminID: config.GetCreatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), configRequest: configRequest{ AppCode: config.GetAppCode(), PoolID: poolID, Enabled: config.GetEnabled(), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), SettlementWindowWager: config.GetSettlementWindowWager(), ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), GiftPriceReference: config.GetGiftPriceReference(), NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), EffectiveFromMS: config.GetEffectiveFromMs(), Stages: stages, }, } } func generatedLuckyGiftTierID(stage string, multiplierPPM int64, counts map[string]int) string { baseID := luckyGiftTierIDBase(stage, multiplierPPM) counts[baseID]++ if counts[baseID] == 1 { return baseID } return fmt.Sprintf("%s_%d", baseID, counts[baseID]) } func luckyGiftTierIDBase(stage string, multiplierPPM int64) string { stage = strings.TrimSpace(stage) if multiplierPPM <= 0 { return stage + "_none" } whole := multiplierPPM / 1_000_000 fraction := multiplierPPM % 1_000_000 if fraction == 0 { return fmt.Sprintf("%s_%dx", stage, whole) } fractionText := strings.TrimRight(fmt.Sprintf("%06d", fraction), "0") return fmt.Sprintf("%s_%d_%sx", stage, whole, fractionText) } func percentToPPM(value float64) int64 { return int64(math.Round(value * 10_000)) } func ppmToPercent(value int64) float64 { return float64(value) / 10_000 } func multiplierToPPM(value float64) int64 { return int64(math.Round(value * 1_000_000)) } func ppmToMultiplier(value int64) float64 { return float64(value) / 1_000_000 } func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO { if draw == nil { return drawDTO{} } return drawDTO{ DrawID: draw.GetDrawId(), CommandID: draw.GetCommandId(), PoolID: draw.GetPoolId(), GiftID: draw.GetGiftId(), RuleVersion: draw.GetRuleVersion(), ExperiencePool: draw.GetExperiencePool(), SelectedTierID: draw.GetSelectedTierId(), MultiplierPPM: draw.GetMultiplierPpm(), BaseRewardCoins: draw.GetBaseRewardCoins(), EffectiveRewardCoins: draw.GetEffectiveRewardCoins(), RewardStatus: draw.GetRewardStatus(), RTPWindowIndex: draw.GetRtpWindowIndex(), GiftRTPWindowIndex: draw.GetGiftRtpWindowIndex(), GlobalBaseRTPPPM: draw.GetGlobalBaseRtpPpm(), GiftBaseRTPPPM: draw.GetGiftBaseRtpPpm(), StageFeedback: draw.GetStageFeedback(), HighMultiplier: draw.GetHighMultiplier(), CreatedAtMS: draw.GetCreatedAtMs(), AppCode: draw.GetAppCode(), UserID: draw.GetUserId(), ExternalUserID: draw.GetExternalUserId(), } } func drawSummaryFromProto(summary *luckygiftv1.LuckyGiftDrawSummary) drawSummaryDTO { if summary == nil { return drawSummaryDTO{} } return drawSummaryDTO{ PoolID: summary.GetPoolId(), TotalDraws: summary.GetTotalDraws(), UniqueUsers: summary.GetUniqueUsers(), UniqueRooms: summary.GetUniqueRooms(), TotalSpentCoins: summary.GetTotalSpentCoins(), TotalRewardCoins: summary.GetTotalRewardCoins(), BaseRewardCoins: summary.GetBaseRewardCoins(), ActualRTPPPM: summary.GetActualRtpPpm(), PendingDraws: summary.GetPendingDraws(), GrantedDraws: summary.GetGrantedDraws(), FailedDraws: summary.GetFailedDraws(), } } func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO { if pool == nil { return poolBalanceDTO{} } return poolBalanceDTO{ AppCode: pool.GetAppCode(), PoolID: pool.GetPoolId(), Balance: pool.GetBalance(), ReserveFloor: pool.GetReserveFloor(), AvailableBalance: pool.GetAvailableBalance(), TotalIn: pool.GetTotalIn(), TotalOut: pool.GetTotalOut(), Materialized: pool.GetMaterialized(), UpdatedAtMS: pool.GetUpdatedAtMs(), } } func parseOptionalInt64(value string) int64 { value = strings.TrimSpace(value) if value == "" { return 0 } var result int64 for _, char := range value { if char < '0' || char > '9' { return 0 } result = result*10 + int64(char-'0') } return result } func parseOptionalBool(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "y": return true default: return false } }