package wheel import ( "context" "fmt" "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" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) const defaultWheelID = "default" var wheelTierCounts = map[string]int{ "classic": 8, "luxury": 8, "advanced": 12, } 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 { WheelID string `json:"wheel_id"` Enabled bool `json:"enabled"` DrawPriceCoins int64 `json:"draw_price_coins"` TargetRTPPercent float64 `json:"target_rtp_percent"` PoolRatePercent float64 `json:"pool_rate_percent"` SettlementWindowDraws int64 `json:"settlement_window_draws"` InitialPoolCoins int64 `json:"initial_pool_coins"` PoolReserveCoins int64 `json:"pool_reserve_coins"` MaxSingleRTPPayout int64 `json:"max_single_rtp_payout"` EffectiveFromMS int64 `json:"effective_from_ms"` Tiers []tierDTO `json:"tiers"` } 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 tierDTO struct { TierID string `json:"tier_id"` DisplayName string `json:"display_name"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` RTPValueCoins int64 `json:"rtp_value_coins"` ProbabilityPercent float64 `json:"probability_percent"` Enabled bool `json:"enabled"` MetadataJSON string `json:"metadata_json"` } type drawDTO struct { DrawID string `json:"draw_id"` DrawIDs []string `json:"draw_ids"` CommandID string `json:"command_id"` WheelID string `json:"wheel_id"` RuleVersion int64 `json:"rule_version"` SelectedTierID string `json:"selected_tier_id"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` RTPValueCoins int64 `json:"rtp_value_coins"` RewardStatus string `json:"reward_status"` RTPWindowIndex int64 `json:"rtp_window_index"` ActualRTPPPM int64 `json:"actual_rtp_ppm"` CreatedAtMS int64 `json:"created_at_ms"` WalletTransactionID string `json:"wallet_transaction_id"` CoinBalanceAfter int64 `json:"coin_balance_after"` MetadataJSON string `json:"metadata_json"` } type drawSummaryDTO struct { WheelID string `json:"wheel_id"` TotalDraws int64 `json:"total_draws"` UniqueUsers int64 `json:"unique_users"` TotalSpentCoins int64 `json:"total_spent_coins"` TotalRTPValueCoins int64 `json:"total_rtp_value_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) GetConfig(c *gin.Context) { wheelID := normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")) ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.GetWheelConfig(ctx, &activityv1.GetWheelConfigRequest{Meta: h.meta(c), WheelId: wheelID}) if err != nil { if status.Code(err) == codes.NotFound { response.OK(c, defaultConfigDTO(wheelID)) return } response.ServerError(c, "获取转盘配置失败") return } response.OK(c, configFromProto(resp.GetConfig())) } func (h *Handler) UpsertConfig(c *gin.Context) { var req configRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "转盘配置参数不正确") return } if req.WheelID == "" { req.WheelID = firstQuery(c, "wheel_id", "wheelId") } if err := validateConfigRequest(req); err != nil { response.BadRequest(c, err.Error()) return } ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.UpsertWheelConfig(ctx, &activityv1.UpsertWheelConfigRequest{ 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-wheel-config", "wheel_rule_versions", item.WheelID, "success", "") response.OK(c, item) } func (h *Handler) ListDraws(c *gin.Context) { options := shared.ListOptions(c) ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.ListWheelDraws(ctx, &activityv1.ListWheelDrawsRequest{ Meta: h.meta(c), WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")), UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")), 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) GetDrawSummary(c *gin.Context) { ctx, cancel := h.activityContext(c) defer cancel() resp, err := h.activity.GetWheelDrawSummary(ctx, &activityv1.GetWheelDrawSummaryRequest{ Meta: h.meta(c), WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")), UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")), 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 validateConfigRequest(req configRequest) error { if normalizeWheelID(req.WheelID) == "" { return fmt.Errorf("wheel_id 不能为空") } expectedCount := expectedWheelTierCount(req.WheelID) // 转盘前端布局是固定格子数:classic=8、luxury=8、advanced=12。这里按池子强校验, // 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。 if len(req.Tiers) != expectedCount { return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount) } var totalProbability float64 for _, tier := range req.Tiers { rewardType := normalizeRewardType(tier.RewardType) if rewardType == "" { return fmt.Errorf("奖品类型不能为空") } totalProbability += tier.ProbabilityPercent switch rewardType { case "coin": if tier.RewardCoins <= 0 { return fmt.Errorf("金币档 reward_coins 必须大于 0") } case "gift": if strings.TrimSpace(tier.RewardID) == "" { return fmt.Errorf("礼物档 reward_id 不能为空") } case "prop", "dress": if strings.TrimSpace(tier.RewardID) == "" { return fmt.Errorf("道具或装扮档 reward_id 不能为空") } default: return fmt.Errorf("奖品类型只支持 coin、gift、prop、dress") } // 道具和装扮用于背包展示,不进入 RTP 支出;真实清零在 configToProto 里执行, // 这里保留运营输入兼容性,防止旧配置带值时直接无法打开保存。 if tier.ProbabilityPercent < 0 { return fmt.Errorf("奖品概率不能为负数") } } if math.Abs(totalProbability-100) > 0.000001 { return fmt.Errorf("奖品档位概率合计必须等于 100") } return nil } func configToProto(req configRequest) *activityv1.WheelRuleConfig { tiers := make([]*activityv1.WheelPrizeTier, 0, len(req.Tiers)) for index, tier := range req.Tiers { rewardType := normalizeRewardType(tier.RewardType) rtpValue := tier.RTPValueCoins rewardCoins := tier.RewardCoins if rewardType == "coin" && rtpValue <= 0 { rtpValue = rewardCoins } if rewardType == "prop" || rewardType == "dress" { rtpValue = 0 } tiers = append(tiers, &activityv1.WheelPrizeTier{ TierId: generatedTierID(tier.TierID, rewardType, index), DisplayName: strings.TrimSpace(tier.DisplayName), RewardType: rewardType, RewardId: strings.TrimSpace(tier.RewardID), RewardCount: tier.RewardCount, RewardCoins: rewardCoins, RtpValueCoins: rtpValue, WeightPpm: percentToPPM(tier.ProbabilityPercent), Enabled: tier.Enabled, MetadataJson: normalizeMetadataJSON(tier.MetadataJSON), }) } return &activityv1.WheelRuleConfig{ WheelId: normalizeWheelID(req.WheelID), Enabled: req.Enabled, DrawPriceCoins: req.DrawPriceCoins, TargetRtpPpm: percentToPPM(req.TargetRTPPercent), PoolRatePpm: percentToPPM(req.PoolRatePercent), SettlementWindowDraws: req.SettlementWindowDraws, InitialPoolCoins: req.InitialPoolCoins, PoolReserveCoins: req.PoolReserveCoins, MaxSingleRtpPayout: req.MaxSingleRTPPayout, EffectiveFromMs: req.EffectiveFromMS, Tiers: tiers, } } func configFromProto(config *activityv1.WheelRuleConfig) configDTO { if config == nil { return configDTO{} } tiers := make([]tierDTO, 0, len(config.GetTiers())) for _, tier := range config.GetTiers() { tiers = append(tiers, tierDTO{ TierID: tier.GetTierId(), DisplayName: tier.GetDisplayName(), RewardType: tier.GetRewardType(), RewardID: tier.GetRewardId(), RewardCount: tier.GetRewardCount(), RewardCoins: tier.GetRewardCoins(), RTPValueCoins: tier.GetRtpValueCoins(), ProbabilityPercent: ppmToPercent(tier.GetWeightPpm()), Enabled: tier.GetEnabled(), MetadataJSON: tier.GetMetadataJson(), }) } return configDTO{ AppCode: config.GetAppCode(), RuleVersion: config.GetRuleVersion(), CreatedByAdminID: config.GetCreatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), configRequest: configRequest{ WheelID: normalizeWheelID(config.GetWheelId()), Enabled: config.GetEnabled(), DrawPriceCoins: config.GetDrawPriceCoins(), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), SettlementWindowDraws: config.GetSettlementWindowDraws(), InitialPoolCoins: config.GetInitialPoolCoins(), PoolReserveCoins: config.GetPoolReserveCoins(), MaxSingleRTPPayout: config.GetMaxSingleRtpPayout(), EffectiveFromMS: config.GetEffectiveFromMs(), Tiers: tiers, }, } } func defaultConfigDTO(wheelID string) configDTO { wheelID = normalizeWheelID(wheelID) // 没有保存过配置时只返回基础参数和空档位。奖品档位必须从资源组导入, // 不能用占位奖品冒充真实配置,否则运营会误以为当前池子已经可发布。 return configDTO{configRequest: configRequest{ WheelID: wheelID, Enabled: false, DrawPriceCoins: 10_000, TargetRTPPercent: 90, PoolRatePercent: 90, SettlementWindowDraws: 1000, InitialPoolCoins: 0, PoolReserveCoins: 0, MaxSingleRTPPayout: 0, Tiers: []tierDTO{}, }} } func expectedWheelTierCount(wheelID string) int { if count, ok := wheelTierCounts[normalizeWheelID(wheelID)]; ok { return count } return wheelTierCounts["classic"] } func defaultProbabilityPercent(index int, count int) float64 { if count <= 0 { return 0 } base := math.Floor(10000/float64(count)) / 100 if index == count-1 { return 100 - base*float64(count-1) } return base } func drawFromProto(draw *activityv1.WheelDrawResult) drawDTO { if draw == nil { return drawDTO{} } return drawDTO{ DrawID: draw.GetDrawId(), DrawIDs: draw.GetDrawIds(), CommandID: draw.GetCommandId(), WheelID: draw.GetWheelId(), RuleVersion: draw.GetRuleVersion(), SelectedTierID: draw.GetSelectedTierId(), RewardType: draw.GetRewardType(), RewardID: draw.GetRewardId(), RewardCount: draw.GetRewardCount(), RewardCoins: draw.GetRewardCoins(), RTPValueCoins: draw.GetRtpValueCoins(), RewardStatus: draw.GetRewardStatus(), RTPWindowIndex: draw.GetRtpWindowIndex(), ActualRTPPPM: draw.GetActualRtpPpm(), CreatedAtMS: draw.GetCreatedAtMs(), WalletTransactionID: draw.GetWalletTransactionId(), CoinBalanceAfter: draw.GetCoinBalanceAfter(), MetadataJSON: draw.GetMetadataJson(), } } func drawSummaryFromProto(summary *activityv1.WheelDrawSummary) drawSummaryDTO { if summary == nil { return drawSummaryDTO{} } return drawSummaryDTO{ WheelID: summary.GetWheelId(), TotalDraws: summary.GetTotalDraws(), UniqueUsers: summary.GetUniqueUsers(), TotalSpentCoins: summary.GetTotalSpentCoins(), TotalRTPValueCoins: summary.GetTotalRtpValueCoins(), ActualRTPPPM: summary.GetActualRtpPpm(), PendingDraws: summary.GetPendingDraws(), GrantedDraws: summary.GetGrantedDraws(), FailedDraws: summary.GetFailedDraws(), } } func normalizeRewardType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "coin": return "coin" case "gift": return "gift" case "prop", "item", "decoration", "resource": return "prop" case "dress", "dress_up", "costume": return "dress" default: return strings.ToLower(strings.TrimSpace(value)) } } func normalizeWheelID(value string) string { value = strings.TrimSpace(value) if value == "" { return defaultWheelID } return value } func generatedTierID(raw string, rewardType string, index int) string { raw = strings.TrimSpace(raw) if raw != "" { return raw } // 同一奖池现在允许多个金币、多个礼物或多个装扮;缺省 ID 必须带序号, // 否则多档同类型会落到同一个 tier_id,抽奖结果无法稳定回显到正确格子。 return fmt.Sprintf("%s-%02d", rewardType, index+1) } func normalizeMetadataJSON(value string) string { value = strings.TrimSpace(value) if value == "" { return "{}" } return value } func percentToPPM(value float64) int64 { return int64(math.Round(value * 10_000)) } func ppmToPercent(value int64) float64 { return float64(value) / 10_000 } func firstQuery(c *gin.Context, names ...string) string { for _, name := range names { if value := strings.TrimSpace(c.Query(name)); value != "" { return value } } return "" } 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 }