498 lines
17 KiB
Go
498 lines
17 KiB
Go
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
|
||
}
|
||
}
|