package luckygift import ( "context" "fmt" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" "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" "google.golang.org/grpc/codes" grpcstatus "google.golang.org/grpc/status" ) type luckyGiftInternalUserDTO struct { // UserID 使用字符串返回,避免浏览器把 64 位长号转成 JavaScript number 后丢失末位精度。 UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` DefaultDisplayID string `json:"default_display_user_id"` PrettyID string `json:"pretty_id"` PrettyDisplayUserID string `json:"pretty_display_user_id"` Name string `json:"name"` Avatar string `json:"avatar"` Status string `json:"status"` } type luckyGiftUserProfileWindowDTO struct { Draws int64 `json:"draws"` WagerCoins int64 `json:"wager_coins"` PayoutCoins int64 `json:"payout_coins"` NetCoins int64 `json:"net_coins"` RTPPPM int64 `json:"rtp_ppm"` HasRTP bool `json:"has_rtp"` } type luckyGiftUserProfileDTO struct { AppCode string `json:"app_code"` PoolID string `json:"pool_id"` IdentityType string `json:"identity_type"` InternalUserID string `json:"internal_user_id,omitempty"` ExternalUserID string `json:"external_user_id,omitempty"` InternalUser *luckyGiftInternalUserDTO `json:"internal_user,omitempty"` RuleVersion int64 `json:"rule_version"` StrategyVersion string `json:"strategy_version"` Stage string `json:"stage"` PaidDraws int64 `json:"paid_draws"` EquivalentDraws int64 `json:"equivalent_draws"` LossStreak int64 `json:"loss_streak"` PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"` GuaranteeDrawsRemaining int64 `json:"guarantee_draws_remaining"` DownweightActive bool `json:"downweight_active"` DownweightFactorPPM int64 `json:"downweight_factor_ppm"` User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` CurrentRTPPPM int64 `json:"current_rtp_ppm"` HasCurrentRTP bool `json:"has_current_rtp"` OneHour luckyGiftUserProfileWindowDTO `json:"one_hour"` TwentyFourHours luckyGiftUserProfileWindowDTO `json:"twenty_four_hours"` FortyEightHours luckyGiftUserProfileWindowDTO `json:"forty_eight_hours"` Lifetime luckyGiftUserProfileWindowDTO `json:"lifetime"` BaseRewardCoins int64 `json:"base_reward_coins"` OrdinaryWinCount int64 `json:"ordinary_win_count"` HighMultiplierOrdinaryWinCount int64 `json:"high_multiplier_ordinary_win_count"` RTPCompensationJackpotCount int64 `json:"rtp_compensation_jackpot_count"` CumulativeSpendJackpotCount int64 `json:"cumulative_spend_jackpot_count"` JackpotWinCount int64 `json:"jackpot_win_count"` GuaranteeHitCount int64 `json:"guarantee_hit_count"` ZeroDrawCount int64 `json:"zero_draw_count"` GrantedDrawCount int64 `json:"granted_draw_count"` PendingDrawCount int64 `json:"pending_draw_count"` FailedDrawCount int64 `json:"failed_draw_count"` MaxMultiplierPPM int64 `json:"max_multiplier_ppm"` MaxRewardCoins int64 `json:"max_reward_coins"` FirstDrawAtMS int64 `json:"first_draw_at_ms"` LastDrawAtMS int64 `json:"last_draw_at_ms"` AggregatedAtMS int64 `json:"aggregated_at_ms"` DataLagMS int64 `json:"data_lag_ms"` } type luckyGiftUserProfilePageDTO struct { Items []luckyGiftUserProfileDTO `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` SnapshotAtMS int64 `json:"snapshot_at_ms"` NextCursor string `json:"next_cursor,omitempty"` HasMore bool `json:"has_more"` } type luckyGiftUserMultiplierStatDTO struct { HitType string `json:"hit_type"` HitTypeLabel string `json:"hit_type_label"` MultiplierPPM int64 `json:"multiplier_ppm"` DrawCount int64 `json:"draw_count"` WagerCoins int64 `json:"wager_coins"` PayoutCoins int64 `json:"payout_coins"` } type luckyGiftUserJackpotConditionDTO struct { Name string `json:"name"` Label string `json:"label"` Actual string `json:"actual"` Threshold string `json:"threshold"` Operator string `json:"operator"` Numerator int64 `json:"numerator"` Denominator int64 `json:"denominator"` RatioPPM int64 `json:"ratio_ppm"` LimitPPM int64 `json:"limit_ppm"` FactorPPM int64 `json:"factor_ppm"` Factor string `json:"factor"` Passed bool `json:"passed"` Reason string `json:"reason"` } type luckyGiftUserJackpotHitDTO struct { EventKey string `json:"event_key"` DrawID string `json:"draw_id"` RequestID string `json:"request_id"` RuleVersion int64 `json:"rule_version"` Stage string `json:"stage"` Mechanism string `json:"mechanism"` MechanismLabel string `json:"mechanism_label"` ReasonCode string `json:"reason_code"` ReasonSummary string `json:"reason_summary"` TierID string `json:"tier_id"` MultiplierPPM int64 `json:"multiplier_ppm"` WagerCoins int64 `json:"wager_coins"` PayoutCoins int64 `json:"payout_coins"` OccurredAtMS int64 `json:"occurred_at_ms"` Conditions []luckyGiftUserJackpotConditionDTO `json:"conditions"` } type luckyGiftUserProfileDetailDTO struct { Profile luckyGiftUserProfileDTO `json:"profile"` MultiplierDistribution []luckyGiftUserMultiplierStatDTO `json:"multiplier_distribution"` JackpotHits []luckyGiftUserJackpotHitDTO `json:"jackpot_hits"` JackpotHitTotal int64 `json:"jackpot_hit_total"` JackpotPage int32 `json:"jackpot_page"` JackpotPageSize int32 `json:"jackpot_page_size"` NextJackpotCursor string `json:"next_jackpot_cursor,omitempty"` JackpotHasMore bool `json:"jackpot_has_more"` SnapshotAtMS int64 `json:"snapshot_at_ms"` } // ListLuckyGiftUserProfiles 查询已经由 lucky-gift-service 增量物化的用户画像。列表只按 app+pool // 读取聚合表;内部账号的短号/靓号解析和资料补全统一走 user-service,不在 HTTP 请求里扫描开奖事实表。 func (h *Handler) ListLuckyGiftUserProfiles(c *gin.Context) { appCode, poolID, ok := requiredLuckyGiftProfileScope(c) if !ok { return } identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source"))) if !ok { response.BadRequest(c, "identity_type must be all, internal or external") return } options := shared.ListOptions(c) if options.PageSize > 100 { options.PageSize = 100 } ctx, cancel := h.luckyGiftContext(c) defer cancel() ctx = appctx.WithContext(ctx, appCode) internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id"))) externalUserID := strings.TrimSpace(c.Query("external_user_id")) keyword := strings.TrimSpace(c.Query("user_keyword")) if keyword != "" { resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c)) if err != nil { response.ServerError(c, "搜索用户失败") return } internalUserID, externalUserID = resolvedInternalID, resolvedExternalID if identityType == "internal" && !internalMatched { // 未解析的内部短号/长号/靓号必须返回空页,不能把“查无此人”退化为无条件全量列表。 response.OK(c, luckyGiftUserProfilePageDTO{Items: []luckyGiftUserProfileDTO{}, Page: options.Page, PageSize: options.PageSize}) return } } resp, err := h.luckyGift.ListLuckyGiftUserProfiles(ctx, &luckygiftv1.ListLuckyGiftUserProfilesRequest{ Meta: h.metaForApp(c, appCode), PoolId: poolID, IdentityType: identityType, InternalUserId: internalUserID, ExternalUserId: externalUserID, Stage: strings.TrimSpace(c.Query("stage")), Status: strings.TrimSpace(c.Query("status")), SortBy: strings.TrimSpace(c.Query("sort_by")), SortDirection: strings.TrimSpace(c.Query("sort_direction")), Page: int32(options.Page), PageSize: int32(options.PageSize), Cursor: strings.TrimSpace(c.Query("cursor")), }) if err != nil { h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像失败") return } users, err := h.loadLuckyGiftInternalUsers(ctx, resp.GetProfiles(), middleware.CurrentRequestID(c)) if err != nil { response.ServerError(c, "获取用户资料失败") return } items := make([]luckyGiftUserProfileDTO, 0, len(resp.GetProfiles())) for _, profile := range resp.GetProfiles() { items = append(items, luckyGiftUserProfileFromProto(profile, users[profile.GetInternalUserId()])) } response.OK(c, luckyGiftUserProfilePageDTO{ Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal(), SnapshotAtMS: resp.GetSnapshotAtMs(), NextCursor: resp.GetNextCursor(), HasMore: resp.GetHasMore(), }) } // GetLuckyGiftUserProfile 返回单个用户的倍率分布和大奖解释。大奖明细只在展开行时请求, // 避免列表页同时解码所有用户的条件 trace,保证高密度页面仍保持稳定响应时间。 func (h *Handler) GetLuckyGiftUserProfile(c *gin.Context) { appCode, poolID, ok := requiredLuckyGiftProfileScope(c) if !ok { return } identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source"))) if !ok || identityType == "" { response.BadRequest(c, "identity_type must be internal or external") return } ctx, cancel := h.luckyGiftContext(c) defer cancel() ctx = appctx.WithContext(ctx, appCode) internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id"))) externalUserID := strings.TrimSpace(c.Query("external_user_id")) if keyword := strings.TrimSpace(c.Query("user_keyword")); keyword != "" { resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c)) if err != nil { response.ServerError(c, "搜索用户失败") return } internalUserID, externalUserID = resolvedInternalID, resolvedExternalID if identityType == "internal" && !internalMatched { response.NotFound(c, "未找到该用户的幸运礼物画像") return } } if (identityType == "internal" && internalUserID <= 0) || (identityType == "external" && externalUserID == "") { response.BadRequest(c, "internal_user_id or external_user_id is required") return } jackpotPage := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page"), 1, 1_000_000) jackpotPageSize := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page_size"), 20, 50) resp, err := h.luckyGift.GetLuckyGiftUserProfile(ctx, &luckygiftv1.GetLuckyGiftUserProfileRequest{ Meta: h.metaForApp(c, appCode), PoolId: poolID, IdentityType: identityType, InternalUserId: internalUserID, ExternalUserId: externalUserID, JackpotPage: int32(jackpotPage), JackpotPageSize: int32(jackpotPageSize), JackpotCursor: strings.TrimSpace(c.Query("jackpot_cursor")), }) if err != nil { h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像详情失败") return } users, err := h.loadLuckyGiftInternalUsers(ctx, []*luckygiftv1.LuckyGiftUserProfile{resp.GetProfile()}, middleware.CurrentRequestID(c)) if err != nil { response.ServerError(c, "获取用户资料失败") return } detail := luckyGiftUserProfileDetailDTO{ Profile: luckyGiftUserProfileFromProto(resp.GetProfile(), users[resp.GetProfile().GetInternalUserId()]), MultiplierDistribution: make([]luckyGiftUserMultiplierStatDTO, 0, len(resp.GetMultiplierDistribution())), JackpotHits: make([]luckyGiftUserJackpotHitDTO, 0, len(resp.GetJackpotHits())), JackpotHitTotal: resp.GetJackpotHitTotal(), JackpotPage: resp.GetJackpotPage(), JackpotPageSize: resp.GetJackpotPageSize(), NextJackpotCursor: resp.GetNextJackpotCursor(), JackpotHasMore: resp.GetJackpotHasMore(), SnapshotAtMS: resp.GetSnapshotAtMs(), } for _, stat := range resp.GetMultiplierDistribution() { detail.MultiplierDistribution = append(detail.MultiplierDistribution, luckyGiftUserMultiplierStatFromProto(stat)) } for _, hit := range resp.GetJackpotHits() { detail.JackpotHits = append(detail.JackpotHits, luckyGiftUserJackpotHitFromProto(hit)) } response.OK(c, detail) } func parseLuckyGiftProfilePositiveInt(value string, fallback, maximum int) int { parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || parsed <= 0 { return fallback } if parsed > maximum { return maximum } return parsed } func requiredLuckyGiftProfileScope(c *gin.Context) (string, string, bool) { appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) poolID := strings.TrimSpace(c.Query("pool_id")) if appCode == "" || poolID == "" { response.BadRequest(c, "app_code and pool_id are required") return "", "", false } return appCode, poolID, true } func normalizeLuckyGiftProfileIdentity(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": // owner 服务用空值表达“内部与外部”,避免引入第三种持久身份类型。 return "", true case "internal", "external": return strings.ToLower(strings.TrimSpace(value)), true default: return "", false } } func (h *Handler) resolveLuckyGiftProfileKeyword(ctx context.Context, appCode, identityType, keyword, requestID string) (int64, string, bool, error) { var externalUserID string if identityType == "" || identityType == "external" { // 外部接入没有 HyApp 用户主数据,原样按 external_user_id 精确匹配;稳定策略哈希永不出 owner 服务。 externalUserID = keyword } if identityType == "external" { return 0, externalUserID, false, nil } if h.users == nil { return 0, externalUserID, false, fmt.Errorf("lucky gift user profile resolver is not configured") } identity, err := h.users.ResolveDisplayUserID(appctx.WithContext(ctx, appCode), userclient.ResolveDisplayUserIDRequest{ RequestID: requestID, Caller: "admin-server", DisplayUserID: keyword, }) if err == nil && identity != nil && identity.UserID > 0 { return identity.UserID, externalUserID, true, nil } if err != nil { switch grpcstatus.Code(err) { case codes.NotFound: // 继续尝试把纯数字解释为系统长 user_id。 case codes.InvalidArgument: // all 模式下 keyword 也可能是任意 external_user_id;内部展示号格式不合法只代表内部未命中,不能阻断外部精确搜索。 return 0, externalUserID, false, nil default: return 0, externalUserID, false, err } } // 纯数字先按展示号解析;只有展示号确实不存在时才回退为 64 位长 user_id,避免短号与长号碰撞时查错人。 longUserID, parseErr := strconv.ParseInt(keyword, 10, 64) if parseErr != nil || longUserID <= 0 { return 0, externalUserID, false, nil } user, getErr := h.users.GetUser(appctx.WithContext(ctx, appCode), userclient.GetUserRequest{ RequestID: requestID, Caller: "admin-server", UserID: longUserID, }) if getErr == nil && user != nil { return user.UserID, externalUserID, true, nil } if getErr != nil && grpcstatus.Code(getErr) != codes.NotFound { return 0, externalUserID, false, getErr } return 0, externalUserID, false, nil } func (h *Handler) loadLuckyGiftInternalUsers(ctx context.Context, profiles []*luckygiftv1.LuckyGiftUserProfile, requestID string) (map[int64]*userclient.User, error) { ids := make([]int64, 0, len(profiles)) seen := make(map[int64]struct{}, len(profiles)) for _, profile := range profiles { if profile == nil || profile.GetIdentityType() != "internal" || profile.GetInternalUserId() <= 0 { continue } if _, exists := seen[profile.GetInternalUserId()]; exists { continue } seen[profile.GetInternalUserId()] = struct{}{} ids = append(ids, profile.GetInternalUserId()) } if len(ids) == 0 { return map[int64]*userclient.User{}, nil } if h.users == nil { return nil, fmt.Errorf("lucky gift user profile user client is not configured") } return h.users.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{RequestID: requestID, Caller: "admin-server", UserIDs: ids}) } func luckyGiftUserProfileFromProto(profile *luckygiftv1.LuckyGiftUserProfile, user *userclient.User) luckyGiftUserProfileDTO { if profile == nil { return luckyGiftUserProfileDTO{} } jackpotCount := profile.GetRtpCompensationJackpotCount() + profile.GetCumulativeSpendJackpotCount() dto := luckyGiftUserProfileDTO{ AppCode: profile.GetAppCode(), PoolID: profile.GetPoolId(), IdentityType: profile.GetIdentityType(), RuleVersion: profile.GetRuleVersion(), StrategyVersion: profile.GetStrategyVersion(), Stage: profile.GetStage(), PaidDraws: profile.GetPaidDraws(), EquivalentDraws: profile.GetEquivalentDraws(), LossStreak: profile.GetLossStreak(), PendingSpendJackpotTokens: profile.GetPendingSpendJackpotTokens(), GuaranteeDrawsRemaining: profile.GetGuaranteeDrawsRemaining(), DownweightActive: profile.GetDownweightActive(), DownweightFactorPPM: profile.GetDownweightFactorPpm(), User24HourRTPThresholdPPM: profile.GetUser_24HRtpThresholdPpm(), OneHour: luckyGiftUserProfileWindowFromProto(profile.GetOneHour()), TwentyFourHours: luckyGiftUserProfileWindowFromProto(profile.GetTwentyFourHours()), FortyEightHours: luckyGiftUserProfileWindowFromProto(profile.GetFortyEightHours()), Lifetime: luckyGiftUserProfileWindowFromProto(profile.GetLifetime()), BaseRewardCoins: profile.GetBaseRewardCoins(), OrdinaryWinCount: profile.GetOrdinaryWinCount(), HighMultiplierOrdinaryWinCount: profile.GetHighMultiplierOrdinaryWinCount(), RTPCompensationJackpotCount: profile.GetRtpCompensationJackpotCount(), CumulativeSpendJackpotCount: profile.GetCumulativeSpendJackpotCount(), JackpotWinCount: jackpotCount, GuaranteeHitCount: profile.GetGuaranteeHitCount(), ZeroDrawCount: profile.GetZeroDrawCount(), GrantedDrawCount: profile.GetGrantedDrawCount(), PendingDrawCount: profile.GetPendingDrawCount(), FailedDrawCount: profile.GetFailedDrawCount(), MaxMultiplierPPM: profile.GetMaxMultiplierPpm(), MaxRewardCoins: profile.GetMaxRewardCoins(), FirstDrawAtMS: profile.GetFirstDrawAtMs(), LastDrawAtMS: profile.GetLastDrawAtMs(), AggregatedAtMS: profile.GetAggregatedAtMs(), DataLagMS: profile.GetDataLagMs(), CurrentRTPPPM: profile.GetTwentyFourHours().GetRtpPpm(), HasCurrentRTP: profile.GetTwentyFourHours().GetHasRtp(), } if profile.GetIdentityType() == "internal" && profile.GetInternalUserId() > 0 { // 外部画像即使 owner 发生协议回归误填了 internal_user_id,也不得在 admin HTTP 边界透出策略哈希。 dto.InternalUserID = strconv.FormatInt(profile.GetInternalUserId(), 10) dto.InternalUser = luckyGiftInternalUserFromClient(profile.GetInternalUserId(), user) } else if profile.GetIdentityType() == "external" { dto.ExternalUserID = profile.GetExternalUserId() } return dto } func luckyGiftInternalUserFromClient(userID int64, user *userclient.User) *luckyGiftInternalUserDTO { dto := &luckyGiftInternalUserDTO{UserID: strconv.FormatInt(userID, 10)} if user == nil { return dto } dto.DisplayUserID = user.DisplayUserID dto.DefaultDisplayID = user.DefaultDisplayUserID dto.PrettyID = user.PrettyID dto.PrettyDisplayUserID = user.PrettyDisplayUserID dto.Name = user.Username dto.Avatar = user.Avatar dto.Status = user.Status return dto } func luckyGiftUserProfileWindowFromProto(window *luckygiftv1.LuckyGiftUserProfileWindow) luckyGiftUserProfileWindowDTO { if window == nil { return luckyGiftUserProfileWindowDTO{} } return luckyGiftUserProfileWindowDTO{ Draws: window.GetDraws(), WagerCoins: window.GetWagerCoins(), PayoutCoins: window.GetPayoutCoins(), NetCoins: window.GetNetCoins(), RTPPPM: window.GetRtpPpm(), HasRTP: window.GetHasRtp(), } } func luckyGiftUserMultiplierStatFromProto(stat *luckygiftv1.LuckyGiftUserMultiplierStat) luckyGiftUserMultiplierStatDTO { if stat == nil { return luckyGiftUserMultiplierStatDTO{} } return luckyGiftUserMultiplierStatDTO{ HitType: stat.GetHitType(), HitTypeLabel: luckyGiftHitTypeLabel(stat.GetHitType()), MultiplierPPM: stat.GetMultiplierPpm(), DrawCount: stat.GetDrawCount(), WagerCoins: stat.GetWagerCoins(), PayoutCoins: stat.GetPayoutCoins(), } } func luckyGiftUserJackpotHitFromProto(hit *luckygiftv1.LuckyGiftUserJackpotHit) luckyGiftUserJackpotHitDTO { if hit == nil { return luckyGiftUserJackpotHitDTO{} } conditions := make([]luckyGiftUserJackpotConditionDTO, 0, len(hit.GetConditions())) for _, condition := range hit.GetConditions() { conditions = append(conditions, luckyGiftUserJackpotConditionFromProto(condition)) } return luckyGiftUserJackpotHitDTO{ EventKey: hit.GetEventKey(), DrawID: hit.GetDrawId(), RequestID: hit.GetRequestId(), RuleVersion: hit.GetRuleVersion(), Stage: hit.GetStage(), Mechanism: hit.GetMechanism(), MechanismLabel: luckyGiftJackpotMechanismLabel(hit.GetMechanism()), ReasonCode: hit.GetReasonCode(), ReasonSummary: luckyGiftJackpotReasonSummary(hit), TierID: hit.GetTierId(), MultiplierPPM: hit.GetMultiplierPpm(), WagerCoins: hit.GetWagerCoins(), PayoutCoins: hit.GetPayoutCoins(), OccurredAtMS: hit.GetOccurredAtMs(), Conditions: conditions, } } func luckyGiftUserJackpotConditionFromProto(condition *luckygiftv1.LuckyGiftUserJackpotCondition) luckyGiftUserJackpotConditionDTO { if condition == nil { return luckyGiftUserJackpotConditionDTO{} } actual, threshold, operator := luckyGiftJackpotConditionValues(condition) return luckyGiftUserJackpotConditionDTO{ Name: condition.GetName(), Label: luckyGiftJackpotConditionLabel(condition.GetName()), Actual: actual, Threshold: threshold, Operator: operator, Numerator: condition.GetNumerator(), Denominator: condition.GetDenominator(), RatioPPM: condition.GetRatioPpm(), LimitPPM: condition.GetLimitPpm(), FactorPPM: condition.GetFactorPpm(), Factor: luckyGiftPercent(condition.GetFactorPpm()), Passed: condition.GetPassed(), Reason: condition.GetReason(), } } func luckyGiftJackpotConditionValues(condition *luckygiftv1.LuckyGiftUserJackpotCondition) (string, string, string) { name := condition.GetName() switch name { case "global_rtp", "spend_global_rtp", "user_round_rtp", "user_48h_rtp", "user_day_rtp", "user_72h_rtp": return fmt.Sprintf("%s(返奖 %d / 消耗 %d)", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), "≤" case "user_24h_rtp_downweight": return fmt.Sprintf("%s(返奖 %d / 消耗 %d)", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), ">" case "user_registered_48h": return luckyGiftDurationHours(condition.GetNumerator()), luckyGiftDurationHours(condition.GetDenominator()), "≥" case "daily_jackpot_limit", "spend_daily_jackpot_limit": return fmt.Sprintf("%d 次", condition.GetNumerator()), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<" case "settled_round_qualification", "payable_jackpot_candidate": if condition.GetPassed() { return "已满足", "必须满足", "=" } return "未满足", "必须满足", "=" default: if condition.GetLimitPpm() != 0 || condition.GetRatioPpm() != 0 { return luckyGiftPercent(condition.GetRatioPpm()), luckyGiftPercent(condition.GetLimitPpm()), "≤" } if condition.GetPassed() { return "已满足", "必须满足", "=" } return "未满足", "必须满足", "=" } } func luckyGiftJackpotConditionLabel(name string) string { labels := map[string]string{ "global_rtp": "全奖池当前返奖率", "spend_global_rtp": "累计消费大奖检查时的全奖池返奖率", "settled_round_qualification": "上一结算轮次是否达到补偿条件", "user_round_rtp": "用户当前结算轮次返奖率", "user_48h_rtp": "用户近 48 小时返奖率", "user_registered_48h": "用户注册是否已满 48 小时", "user_24h_rtp_downweight": "用户近 24 小时普通中奖降权", "user_day_rtp": "用户当天返奖率", "user_72h_rtp": "用户近 72 小时返奖率", "daily_jackpot_limit": "用户当天已中大奖次数", "spend_daily_jackpot_limit": "累计消费大奖的当天次数限制", "payable_jackpot_candidate": "奖池余额是否足够支付所选大奖", } if label := labels[name]; label != "" { return label } return strings.ReplaceAll(strings.TrimSpace(name), "_", " ") } func luckyGiftJackpotMechanismLabel(mechanism string) string { switch mechanism { case "rtp_compensation", "jackpot_mechanism_1": return "RTP 补偿大奖" case "spend_milestone", "jackpot_mechanism_2": return "累计消费大奖" default: return "大奖" } } func luckyGiftJackpotReasonSummary(hit *luckygiftv1.LuckyGiftUserJackpotHit) string { switch hit.GetReasonCode() { case "rtp_compensation_jackpot": return "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。" case "milestone_token_jackpot": return "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。" } if summary := strings.TrimSpace(hit.GetReasonSummary()); summary != "" { return summary } return "本次开奖满足下列全部条件,因此命中该大奖。" } func luckyGiftHitTypeLabel(hitType string) string { switch hitType { case "zero": return "未中奖" case "ordinary": return "普通奖" case "rtp_compensation": return "RTP 补偿大奖" case "spend_milestone": return "累计消费大奖" default: return hitType } } func luckyGiftPercent(ppm int64) string { value := strconv.FormatFloat(float64(ppm)/10_000, 'f', 2, 64) value = strings.TrimRight(strings.TrimRight(value, "0"), ".") if value == "" { value = "0" } return value + "%" } func luckyGiftDurationHours(milliseconds int64) string { value := strconv.FormatFloat(float64(milliseconds)/3_600_000, 'f', 1, 64) value = strings.TrimRight(strings.TrimRight(value, "0"), ".") return value + " 小时" } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func (h *Handler) writeLuckyGiftUserProfileError(c *gin.Context, err error, fallback string) { status := grpcstatus.Convert(err) switch status.Code() { case codes.InvalidArgument: response.BadRequest(c, status.Message()) case codes.NotFound: response.NotFound(c, "未找到该用户的幸运礼物画像") default: response.ServerError(c, fallback) } }