2026-06-18 17:13:56 +08:00

484 lines
16 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 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
}