package luckygift import ( "context" "math" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" activityv1 "hyapp.local/api/proto/activity/v1" "github.com/gin-gonic/gin" ) type Handler struct { activity activityclient.Client requestTimeout time.Duration audit shared.OperationLogger } func New(activity activityclient.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { if requestTimeout <= 0 { requestTimeout = 3 * time.Second } return &Handler{activity: activity, requestTimeout: requestTimeout, audit: audit} } type configRequest struct { 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"` MaxSinglePayout int64 `json:"max_single_payout"` UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"` UserDailyPayoutCap int64 `json:"user_daily_payout_cap"` DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"` RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"` AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"` EffectiveFromMS int64 `json:"effective_from_ms"` Stages []stageDTO `json:"stages"` } type configDTO struct { configRequest AppCode string `json:"app_code"` 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"` RewardSource string `json:"reward_source"` HighWaterOnly bool `json:"high_water_only"` BroadcastLevel string `json:"broadcast_level"` 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"` RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` EffectiveRewardCoins int64 `json:"effective_reward_coins"` BudgetSourcesJSON string `json:"budget_sources_json"` 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"` } 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"` RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` ActualRTPPPM int64 `json:"actual_rtp_ppm"` PendingDraws int64 `json:"pending_draws"` GrantedDraws int64 `json:"granted_draws"` FailedDraws int64 `json:"failed_draws"` } func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.GetLuckyGiftConfig(ctx, &activityv1.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")) } ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.UpsertLuckyGiftConfig(ctx, &activityv1.UpsertLuckyGiftConfigRequest{ Meta: h.meta(c), 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) { ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.ListLuckyGiftConfigs(ctx, &activityv1.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) { options := shared.ListOptions(c) ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.ListLuckyGiftDraws(ctx, &activityv1.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), }) 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) { ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.GetLuckyGiftDrawSummary(ctx, &activityv1.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")), }) if err != nil { response.ServerError(c, "获取幸运礼物抽奖汇总失败") return } response.OK(c, drawSummaryFromProto(resp.GetSummary())) } func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", AppCode: appctx.FromContext(c.Request.Context()), SentAtMs: time.Now().UTC().UnixMilli(), } } func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(c.Request.Context(), h.requestTimeout) } func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig { stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages)) for _, stage := range req.Stages { tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers)) for _, tier := range stage.Tiers { tiers = append(tiers, &activityv1.LuckyGiftRuleTier{ Stage: strings.TrimSpace(stage.Stage), TierId: strings.TrimSpace(tier.TierID), MultiplierPpm: multiplierToPPM(tier.Multiplier), BaseWeightPpm: percentToPPM(tier.ProbabilityPercent), RewardSource: strings.TrimSpace(tier.RewardSource), HighWaterOnly: tier.HighWaterOnly, BroadcastLevel: strings.TrimSpace(tier.BroadcastLevel), Enabled: tier.Enabled, }) } stages = append(stages, &activityv1.LuckyGiftRuleStage{ Stage: strings.TrimSpace(stage.Stage), Tiers: tiers, }) } return &activityv1.LuckyGiftRuleConfig{ 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, MaxSinglePayout: req.MaxSinglePayout, UserHourlyPayoutCap: req.UserHourlyPayoutCap, UserDailyPayoutCap: req.UserDailyPayoutCap, DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, EffectiveFromMs: req.EffectiveFromMS, Stages: stages, } } func configFromProto(config *activityv1.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()), RewardSource: tier.GetRewardSource(), HighWaterOnly: tier.GetHighWaterOnly(), BroadcastLevel: tier.GetBroadcastLevel(), Enabled: tier.GetEnabled(), }) } stages = append(stages, stageDTO{ Stage: stage.GetStage(), Tiers: tiers, }) } poolID := strings.TrimSpace(config.GetPoolId()) return configDTO{ AppCode: config.GetAppCode(), RuleVersion: config.GetRuleVersion(), CreatedByAdminID: config.GetCreatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), configRequest: configRequest{ 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(), MaxSinglePayout: config.GetMaxSinglePayout(), UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), UserDailyPayoutCap: config.GetUserDailyPayoutCap(), DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), EffectiveFromMS: config.GetEffectiveFromMs(), Stages: stages, }, } } 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 *activityv1.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(), RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(), EffectiveRewardCoins: draw.GetEffectiveRewardCoins(), BudgetSourcesJSON: draw.GetBudgetSourcesJson(), RewardStatus: draw.GetRewardStatus(), RTPWindowIndex: draw.GetRtpWindowIndex(), GiftRTPWindowIndex: draw.GetGiftRtpWindowIndex(), GlobalBaseRTPPPM: draw.GetGlobalBaseRtpPpm(), GiftBaseRTPPPM: draw.GetGiftBaseRtpPpm(), StageFeedback: draw.GetStageFeedback(), HighMultiplier: draw.GetHighMultiplier(), CreatedAtMS: draw.GetCreatedAtMs(), } } func drawSummaryFromProto(summary *activityv1.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(), RoomAtmosphereRewardCoins: summary.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: summary.GetActivitySubsidyCoins(), ActualRTPPPM: summary.GetActualRtpPpm(), PendingDraws: summary.GetPendingDraws(), GrantedDraws: summary.GetGrantedDraws(), FailedDraws: summary.GetFailedDraws(), } } 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 }