622 lines
28 KiB
Go
622 lines
28 KiB
Go
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)
|
||
}
|
||
}
|