498 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}