429 lines
16 KiB
Go
429 lines
16 KiB
Go
package luckygift
|
||
|
||
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"
|
||
)
|
||
|
||
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 {
|
||
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"`
|
||
MaxSinglePayout int64 `json:"max_single_payout"`
|
||
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
|
||
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
|
||
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
|
||
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
|
||
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
|
||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||
Stages []stageDTO `json:"stages"`
|
||
}
|
||
|
||
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 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"`
|
||
RewardSource string `json:"reward_source"`
|
||
HighWaterOnly bool `json:"high_water_only"`
|
||
BroadcastLevel string `json:"broadcast_level"`
|
||
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"`
|
||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
|
||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||
BudgetSourcesJSON string `json:"budget_sources_json"`
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||
ActivitySubsidyCoins int64 `json:"activity_subsidy_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) GetLuckyGiftConfig(c *gin.Context) {
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.GetLuckyGiftConfig(ctx, &activityv1.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"))
|
||
}
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.UpsertLuckyGiftConfig(ctx, &activityv1.UpsertLuckyGiftConfigRequest{
|
||
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-lucky-gift-config", "lucky_gift_rule_versions", item.PoolID, "success", "")
|
||
response.OK(c, item)
|
||
}
|
||
|
||
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.ListLuckyGiftConfigs(ctx, &activityv1.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) {
|
||
options := shared.ListOptions(c)
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.ListLuckyGiftDraws(ctx, &activityv1.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),
|
||
})
|
||
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) {
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.GetLuckyGiftDrawSummary(ctx, &activityv1.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")),
|
||
})
|
||
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 configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
|
||
stages := make([]*activityv1.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([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
|
||
for _, tier := range stage.Tiers {
|
||
multiplierPPM := multiplierToPPM(tier.Multiplier)
|
||
tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
|
||
Stage: stageName,
|
||
// 奖档 ID 是 activity-service 持久化主键的一部分,不能让运营手工维护;admin-server 按阶段和倍率生成稳定 ID,
|
||
// 同一阶段同倍率出现多行时追加序号,保证进入 activity-service 前已经满足唯一性。
|
||
TierId: generatedLuckyGiftTierID(stageName, multiplierPPM, tierIDCounts),
|
||
MultiplierPpm: multiplierPPM,
|
||
BaseWeightPpm: percentToPPM(tier.ProbabilityPercent),
|
||
RewardSource: strings.TrimSpace(tier.RewardSource),
|
||
HighWaterOnly: tier.HighWaterOnly,
|
||
BroadcastLevel: strings.TrimSpace(tier.BroadcastLevel),
|
||
Enabled: tier.Enabled,
|
||
})
|
||
}
|
||
stages = append(stages, &activityv1.LuckyGiftRuleStage{
|
||
Stage: stageName,
|
||
Tiers: tiers,
|
||
})
|
||
}
|
||
return &activityv1.LuckyGiftRuleConfig{
|
||
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,
|
||
MaxSinglePayout: req.MaxSinglePayout,
|
||
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
|
||
UserDailyPayoutCap: req.UserDailyPayoutCap,
|
||
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
|
||
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
|
||
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
|
||
EffectiveFromMs: req.EffectiveFromMS,
|
||
Stages: stages,
|
||
}
|
||
}
|
||
|
||
func configFromProto(config *activityv1.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()),
|
||
RewardSource: tier.GetRewardSource(),
|
||
HighWaterOnly: tier.GetHighWaterOnly(),
|
||
BroadcastLevel: tier.GetBroadcastLevel(),
|
||
Enabled: tier.GetEnabled(),
|
||
})
|
||
}
|
||
stages = append(stages, stageDTO{
|
||
Stage: stage.GetStage(),
|
||
Tiers: tiers,
|
||
})
|
||
}
|
||
poolID := strings.TrimSpace(config.GetPoolId())
|
||
return configDTO{
|
||
AppCode: config.GetAppCode(),
|
||
RuleVersion: config.GetRuleVersion(),
|
||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||
CreatedAtMS: config.GetCreatedAtMs(),
|
||
configRequest: configRequest{
|
||
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(),
|
||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
|
||
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
|
||
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
|
||
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
|
||
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
|
||
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 *activityv1.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(),
|
||
RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(),
|
||
ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(),
|
||
EffectiveRewardCoins: draw.GetEffectiveRewardCoins(),
|
||
BudgetSourcesJSON: draw.GetBudgetSourcesJson(),
|
||
RewardStatus: draw.GetRewardStatus(),
|
||
RTPWindowIndex: draw.GetRtpWindowIndex(),
|
||
GiftRTPWindowIndex: draw.GetGiftRtpWindowIndex(),
|
||
GlobalBaseRTPPPM: draw.GetGlobalBaseRtpPpm(),
|
||
GiftBaseRTPPPM: draw.GetGiftBaseRtpPpm(),
|
||
StageFeedback: draw.GetStageFeedback(),
|
||
HighMultiplier: draw.GetHighMultiplier(),
|
||
CreatedAtMS: draw.GetCreatedAtMs(),
|
||
}
|
||
}
|
||
|
||
func drawSummaryFromProto(summary *activityv1.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(),
|
||
RoomAtmosphereRewardCoins: summary.GetRoomAtmosphereRewardCoins(),
|
||
ActivitySubsidyCoins: summary.GetActivitySubsidyCoins(),
|
||
ActualRTPPPM: summary.GetActualRtpPpm(),
|
||
PendingDraws: summary.GetPendingDraws(),
|
||
GrantedDraws: summary.GetGrantedDraws(),
|
||
FailedDraws: summary.GetFailedDraws(),
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|