484 lines
16 KiB
Go
484 lines
16 KiB
Go
package wheel
|
||
|
||
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"
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
)
|
||
|
||
const defaultWheelID = "default"
|
||
|
||
var wheelTierCounts = map[string]int{
|
||
"classic": 9,
|
||
"luxury": 8,
|
||
"advanced": 12,
|
||
}
|
||
|
||
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 {
|
||
WheelID string `json:"wheel_id"`
|
||
Enabled bool `json:"enabled"`
|
||
DrawPriceCoins int64 `json:"draw_price_coins"`
|
||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||
PoolRatePercent float64 `json:"pool_rate_percent"`
|
||
SettlementWindowDraws int64 `json:"settlement_window_draws"`
|
||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||
PoolReserveCoins int64 `json:"pool_reserve_coins"`
|
||
MaxSingleRTPPayout int64 `json:"max_single_rtp_payout"`
|
||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||
Tiers []tierDTO `json:"tiers"`
|
||
}
|
||
|
||
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 tierDTO struct {
|
||
TierID string `json:"tier_id"`
|
||
DisplayName string `json:"display_name"`
|
||
RewardType string `json:"reward_type"`
|
||
RewardID string `json:"reward_id"`
|
||
RewardCount int64 `json:"reward_count"`
|
||
RewardCoins int64 `json:"reward_coins"`
|
||
RTPValueCoins int64 `json:"rtp_value_coins"`
|
||
ProbabilityPercent float64 `json:"probability_percent"`
|
||
Enabled bool `json:"enabled"`
|
||
MetadataJSON string `json:"metadata_json"`
|
||
}
|
||
|
||
type drawDTO struct {
|
||
DrawID string `json:"draw_id"`
|
||
DrawIDs []string `json:"draw_ids"`
|
||
CommandID string `json:"command_id"`
|
||
WheelID string `json:"wheel_id"`
|
||
RuleVersion int64 `json:"rule_version"`
|
||
SelectedTierID string `json:"selected_tier_id"`
|
||
RewardType string `json:"reward_type"`
|
||
RewardID string `json:"reward_id"`
|
||
RewardCount int64 `json:"reward_count"`
|
||
RewardCoins int64 `json:"reward_coins"`
|
||
RTPValueCoins int64 `json:"rtp_value_coins"`
|
||
RewardStatus string `json:"reward_status"`
|
||
RTPWindowIndex int64 `json:"rtp_window_index"`
|
||
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
|
||
CreatedAtMS int64 `json:"created_at_ms"`
|
||
WalletTransactionID string `json:"wallet_transaction_id"`
|
||
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
||
MetadataJSON string `json:"metadata_json"`
|
||
}
|
||
|
||
type drawSummaryDTO struct {
|
||
WheelID string `json:"wheel_id"`
|
||
TotalDraws int64 `json:"total_draws"`
|
||
UniqueUsers int64 `json:"unique_users"`
|
||
TotalSpentCoins int64 `json:"total_spent_coins"`
|
||
TotalRTPValueCoins int64 `json:"total_rtp_value_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) GetConfig(c *gin.Context) {
|
||
wheelID := normalizeWheelID(firstQuery(c, "wheel_id", "wheelId"))
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.GetWheelConfig(ctx, &activityv1.GetWheelConfigRequest{Meta: h.meta(c), WheelId: wheelID})
|
||
if err != nil {
|
||
if status.Code(err) == codes.NotFound {
|
||
response.OK(c, defaultConfigDTO(wheelID))
|
||
return
|
||
}
|
||
response.ServerError(c, "获取转盘配置失败")
|
||
return
|
||
}
|
||
response.OK(c, configFromProto(resp.GetConfig()))
|
||
}
|
||
|
||
func (h *Handler) UpsertConfig(c *gin.Context) {
|
||
var req configRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "转盘配置参数不正确")
|
||
return
|
||
}
|
||
if req.WheelID == "" {
|
||
req.WheelID = firstQuery(c, "wheel_id", "wheelId")
|
||
}
|
||
if err := validateConfigRequest(req); err != nil {
|
||
response.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.UpsertWheelConfig(ctx, &activityv1.UpsertWheelConfigRequest{
|
||
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-wheel-config", "wheel_rule_versions", item.WheelID, "success", "")
|
||
response.OK(c, item)
|
||
}
|
||
|
||
func (h *Handler) ListDraws(c *gin.Context) {
|
||
options := shared.ListOptions(c)
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.ListWheelDraws(ctx, &activityv1.ListWheelDrawsRequest{
|
||
Meta: h.meta(c),
|
||
WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")),
|
||
UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")),
|
||
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) GetDrawSummary(c *gin.Context) {
|
||
ctx, cancel := h.activityContext(c)
|
||
defer cancel()
|
||
resp, err := h.activity.GetWheelDrawSummary(ctx, &activityv1.GetWheelDrawSummaryRequest{
|
||
Meta: h.meta(c),
|
||
WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")),
|
||
UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")),
|
||
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 validateConfigRequest(req configRequest) error {
|
||
if normalizeWheelID(req.WheelID) == "" {
|
||
return fmt.Errorf("wheel_id 不能为空")
|
||
}
|
||
expectedCount := expectedWheelTierCount(req.WheelID)
|
||
// 转盘前端布局是固定格子数:classic=9、luxury=8、advanced=12。这里按池子强校验,
|
||
// 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。
|
||
if len(req.Tiers) != expectedCount {
|
||
return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount)
|
||
}
|
||
var totalProbability float64
|
||
for _, tier := range req.Tiers {
|
||
rewardType := normalizeRewardType(tier.RewardType)
|
||
if rewardType == "" {
|
||
return fmt.Errorf("奖品类型不能为空")
|
||
}
|
||
totalProbability += tier.ProbabilityPercent
|
||
switch rewardType {
|
||
case "coin":
|
||
if tier.RewardCoins <= 0 {
|
||
return fmt.Errorf("金币档 reward_coins 必须大于 0")
|
||
}
|
||
case "gift":
|
||
if strings.TrimSpace(tier.RewardID) == "" {
|
||
return fmt.Errorf("礼物档 reward_id 不能为空")
|
||
}
|
||
case "prop", "dress":
|
||
if strings.TrimSpace(tier.RewardID) == "" {
|
||
return fmt.Errorf("道具或装扮档 reward_id 不能为空")
|
||
}
|
||
default:
|
||
return fmt.Errorf("奖品类型只支持 coin、gift、prop、dress")
|
||
}
|
||
// 道具和装扮用于背包展示,不进入 RTP 支出;真实清零在 configToProto 里执行,
|
||
// 这里保留运营输入兼容性,防止旧配置带值时直接无法打开保存。
|
||
if tier.ProbabilityPercent < 0 {
|
||
return fmt.Errorf("奖品概率不能为负数")
|
||
}
|
||
}
|
||
if math.Abs(totalProbability-100) > 0.000001 {
|
||
return fmt.Errorf("奖品档位概率合计必须等于 100")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func configToProto(req configRequest) *activityv1.WheelRuleConfig {
|
||
tiers := make([]*activityv1.WheelPrizeTier, 0, len(req.Tiers))
|
||
for index, tier := range req.Tiers {
|
||
rewardType := normalizeRewardType(tier.RewardType)
|
||
rtpValue := tier.RTPValueCoins
|
||
rewardCoins := tier.RewardCoins
|
||
if rewardType == "coin" && rtpValue <= 0 {
|
||
rtpValue = rewardCoins
|
||
}
|
||
if rewardType == "prop" || rewardType == "dress" {
|
||
rtpValue = 0
|
||
}
|
||
tiers = append(tiers, &activityv1.WheelPrizeTier{
|
||
TierId: generatedTierID(tier.TierID, rewardType, index),
|
||
DisplayName: strings.TrimSpace(tier.DisplayName),
|
||
RewardType: rewardType,
|
||
RewardId: strings.TrimSpace(tier.RewardID),
|
||
RewardCount: tier.RewardCount,
|
||
RewardCoins: rewardCoins,
|
||
RtpValueCoins: rtpValue,
|
||
WeightPpm: percentToPPM(tier.ProbabilityPercent),
|
||
Enabled: tier.Enabled,
|
||
MetadataJson: normalizeMetadataJSON(tier.MetadataJSON),
|
||
})
|
||
}
|
||
return &activityv1.WheelRuleConfig{
|
||
WheelId: normalizeWheelID(req.WheelID),
|
||
Enabled: req.Enabled,
|
||
DrawPriceCoins: req.DrawPriceCoins,
|
||
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
|
||
PoolRatePpm: percentToPPM(req.PoolRatePercent),
|
||
SettlementWindowDraws: req.SettlementWindowDraws,
|
||
InitialPoolCoins: req.InitialPoolCoins,
|
||
PoolReserveCoins: req.PoolReserveCoins,
|
||
MaxSingleRtpPayout: req.MaxSingleRTPPayout,
|
||
EffectiveFromMs: req.EffectiveFromMS,
|
||
Tiers: tiers,
|
||
}
|
||
}
|
||
|
||
func configFromProto(config *activityv1.WheelRuleConfig) configDTO {
|
||
if config == nil {
|
||
return configDTO{}
|
||
}
|
||
tiers := make([]tierDTO, 0, len(config.GetTiers()))
|
||
for _, tier := range config.GetTiers() {
|
||
tiers = append(tiers, tierDTO{
|
||
TierID: tier.GetTierId(),
|
||
DisplayName: tier.GetDisplayName(),
|
||
RewardType: tier.GetRewardType(),
|
||
RewardID: tier.GetRewardId(),
|
||
RewardCount: tier.GetRewardCount(),
|
||
RewardCoins: tier.GetRewardCoins(),
|
||
RTPValueCoins: tier.GetRtpValueCoins(),
|
||
ProbabilityPercent: ppmToPercent(tier.GetWeightPpm()),
|
||
Enabled: tier.GetEnabled(),
|
||
MetadataJSON: tier.GetMetadataJson(),
|
||
})
|
||
}
|
||
return configDTO{
|
||
AppCode: config.GetAppCode(),
|
||
RuleVersion: config.GetRuleVersion(),
|
||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||
CreatedAtMS: config.GetCreatedAtMs(),
|
||
configRequest: configRequest{
|
||
WheelID: normalizeWheelID(config.GetWheelId()),
|
||
Enabled: config.GetEnabled(),
|
||
DrawPriceCoins: config.GetDrawPriceCoins(),
|
||
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
|
||
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
|
||
SettlementWindowDraws: config.GetSettlementWindowDraws(),
|
||
InitialPoolCoins: config.GetInitialPoolCoins(),
|
||
PoolReserveCoins: config.GetPoolReserveCoins(),
|
||
MaxSingleRTPPayout: config.GetMaxSingleRtpPayout(),
|
||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||
Tiers: tiers,
|
||
},
|
||
}
|
||
}
|
||
|
||
func defaultConfigDTO(wheelID string) configDTO {
|
||
wheelID = normalizeWheelID(wheelID)
|
||
// 没有保存过配置时只返回基础参数和空档位。奖品档位必须从资源组导入,
|
||
// 不能用占位奖品冒充真实配置,否则运营会误以为当前池子已经可发布。
|
||
return configDTO{configRequest: configRequest{
|
||
WheelID: wheelID,
|
||
Enabled: false,
|
||
DrawPriceCoins: 10_000,
|
||
TargetRTPPercent: 90,
|
||
PoolRatePercent: 90,
|
||
SettlementWindowDraws: 1000,
|
||
InitialPoolCoins: 0,
|
||
PoolReserveCoins: 0,
|
||
MaxSingleRTPPayout: 0,
|
||
Tiers: []tierDTO{},
|
||
}}
|
||
}
|
||
|
||
func expectedWheelTierCount(wheelID string) int {
|
||
if count, ok := wheelTierCounts[normalizeWheelID(wheelID)]; ok {
|
||
return count
|
||
}
|
||
return wheelTierCounts["classic"]
|
||
}
|
||
|
||
func defaultProbabilityPercent(index int, count int) float64 {
|
||
if count <= 0 {
|
||
return 0
|
||
}
|
||
base := math.Floor(10000/float64(count)) / 100
|
||
if index == count-1 {
|
||
return 100 - base*float64(count-1)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func drawFromProto(draw *activityv1.WheelDrawResult) drawDTO {
|
||
if draw == nil {
|
||
return drawDTO{}
|
||
}
|
||
return drawDTO{
|
||
DrawID: draw.GetDrawId(),
|
||
DrawIDs: draw.GetDrawIds(),
|
||
CommandID: draw.GetCommandId(),
|
||
WheelID: draw.GetWheelId(),
|
||
RuleVersion: draw.GetRuleVersion(),
|
||
SelectedTierID: draw.GetSelectedTierId(),
|
||
RewardType: draw.GetRewardType(),
|
||
RewardID: draw.GetRewardId(),
|
||
RewardCount: draw.GetRewardCount(),
|
||
RewardCoins: draw.GetRewardCoins(),
|
||
RTPValueCoins: draw.GetRtpValueCoins(),
|
||
RewardStatus: draw.GetRewardStatus(),
|
||
RTPWindowIndex: draw.GetRtpWindowIndex(),
|
||
ActualRTPPPM: draw.GetActualRtpPpm(),
|
||
CreatedAtMS: draw.GetCreatedAtMs(),
|
||
WalletTransactionID: draw.GetWalletTransactionId(),
|
||
CoinBalanceAfter: draw.GetCoinBalanceAfter(),
|
||
MetadataJSON: draw.GetMetadataJson(),
|
||
}
|
||
}
|
||
|
||
func drawSummaryFromProto(summary *activityv1.WheelDrawSummary) drawSummaryDTO {
|
||
if summary == nil {
|
||
return drawSummaryDTO{}
|
||
}
|
||
return drawSummaryDTO{
|
||
WheelID: summary.GetWheelId(),
|
||
TotalDraws: summary.GetTotalDraws(),
|
||
UniqueUsers: summary.GetUniqueUsers(),
|
||
TotalSpentCoins: summary.GetTotalSpentCoins(),
|
||
TotalRTPValueCoins: summary.GetTotalRtpValueCoins(),
|
||
ActualRTPPPM: summary.GetActualRtpPpm(),
|
||
PendingDraws: summary.GetPendingDraws(),
|
||
GrantedDraws: summary.GetGrantedDraws(),
|
||
FailedDraws: summary.GetFailedDraws(),
|
||
}
|
||
}
|
||
|
||
func normalizeRewardType(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "coin":
|
||
return "coin"
|
||
case "gift":
|
||
return "gift"
|
||
case "prop", "item", "decoration", "resource":
|
||
return "prop"
|
||
case "dress", "dress_up", "costume":
|
||
return "dress"
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func normalizeWheelID(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return defaultWheelID
|
||
}
|
||
return value
|
||
}
|
||
|
||
func generatedTierID(raw string, rewardType string, index int) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw != "" {
|
||
return raw
|
||
}
|
||
// 同一奖池现在允许多个金币、多个礼物或多个装扮;缺省 ID 必须带序号,
|
||
// 否则多档同类型会落到同一个 tier_id,抽奖结果无法稳定回显到正确格子。
|
||
return fmt.Sprintf("%s-%02d", rewardType, index+1)
|
||
}
|
||
|
||
func normalizeMetadataJSON(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "{}"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func percentToPPM(value float64) int64 {
|
||
return int64(math.Round(value * 10_000))
|
||
}
|
||
|
||
func ppmToPercent(value int64) float64 {
|
||
return float64(value) / 10_000
|
||
}
|
||
|
||
func firstQuery(c *gin.Context, names ...string) string {
|
||
for _, name := range names {
|
||
if value := strings.TrimSpace(c.Query(name)); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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
|
||
}
|