完善抽奖池
This commit is contained in:
parent
6195544950
commit
886bc8998b
23
internal/model/activity_idempotency.go
Normal file
23
internal/model/activity_idempotency.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ActivityIdempotency stores client-side idempotency keys for paid activity draw
|
||||||
|
// requests. The same activity/sysOrigin/user/key may only map to one request hash.
|
||||||
|
type ActivityIdempotency struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
|
Activity string `gorm:"column:activity;size:64;uniqueIndex:uk_activity_idempotency_key,priority:1"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_activity_idempotency_key,priority:2"`
|
||||||
|
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_activity_idempotency_key,priority:3"`
|
||||||
|
IdempotencyKey string `gorm:"column:idempotency_key;size:128;uniqueIndex:uk_activity_idempotency_key,priority:4"`
|
||||||
|
RequestHash string `gorm:"column:request_hash;size:64"`
|
||||||
|
BusinessNo string `gorm:"column:business_no;size:64;index:idx_activity_idempotency_business"`
|
||||||
|
Status string `gorm:"column:status;size:32;index:idx_activity_idempotency_status"`
|
||||||
|
ResponseJSON string `gorm:"column:response_json;type:longtext"`
|
||||||
|
ErrorMessage string `gorm:"column:error_message;size:1024"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the activity idempotency table name.
|
||||||
|
func (ActivityIdempotency) TableName() string { return "activity_idempotency" }
|
||||||
@ -49,6 +49,9 @@ type WheelRewardConfig struct {
|
|||||||
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
|
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
|
||||||
GoldAmount int64 `gorm:"column:gold_amount"`
|
GoldAmount int64 `gorm:"column:gold_amount"`
|
||||||
Probability int `gorm:"column:probability"`
|
Probability int `gorm:"column:probability"`
|
||||||
|
TotalLimit int64 `gorm:"column:total_limit"`
|
||||||
|
UserLimit int64 `gorm:"column:user_limit"`
|
||||||
|
IssuedCount int64 `gorm:"column:issued_count"`
|
||||||
Sort int `gorm:"column:sort"`
|
Sort int `gorm:"column:sort"`
|
||||||
Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"`
|
Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"`
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
@ -60,28 +63,31 @@ func (WheelRewardConfig) TableName() string { return "wheel_reward_config" }
|
|||||||
|
|
||||||
// WheelDrawRecord stores each reward item produced by a wheel draw.
|
// WheelDrawRecord stores each reward item produced by a wheel draw.
|
||||||
type WheelDrawRecord struct {
|
type WheelDrawRecord struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
DrawNo string `gorm:"column:draw_no;size:64;index:idx_wheel_draw_record_draw_no"`
|
DrawNo string `gorm:"column:draw_no;size:64;index:idx_wheel_draw_record_draw_no"`
|
||||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_wheel_draw_record_event"`
|
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_wheel_draw_record_event"`
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_draw_record_user_time,priority:1"`
|
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_draw_record_user_time,priority:1"`
|
||||||
Category string `gorm:"column:category;size:32;index:idx_wheel_draw_record_category"`
|
Category string `gorm:"column:category;size:32;index:idx_wheel_draw_record_category"`
|
||||||
UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"`
|
UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"`
|
||||||
DrawTimes int `gorm:"column:draw_times"`
|
DrawTimes int `gorm:"column:draw_times"`
|
||||||
PaidGold int64 `gorm:"column:paid_gold"`
|
PaidGold int64 `gorm:"column:paid_gold"`
|
||||||
RewardType string `gorm:"column:reward_type;size:32"`
|
RewardConfigID int64 `gorm:"column:reward_config_id;index:idx_wheel_draw_reward_user,priority:1"`
|
||||||
ResourceID *int64 `gorm:"column:resource_id"`
|
RewardType string `gorm:"column:reward_type;size:32"`
|
||||||
ResourceType string `gorm:"column:resource_type;size:64"`
|
ResourceID *int64 `gorm:"column:resource_id"`
|
||||||
ResourceName string `gorm:"column:resource_name;size:255"`
|
ResourceType string `gorm:"column:resource_type;size:64"`
|
||||||
ResourceURL string `gorm:"column:resource_url;size:512"`
|
ResourceName string `gorm:"column:resource_name;size:255"`
|
||||||
CoverURL string `gorm:"column:cover_url;size:512"`
|
ResourceURL string `gorm:"column:resource_url;size:512"`
|
||||||
DurationDays int `gorm:"column:duration_days"`
|
CoverURL string `gorm:"column:cover_url;size:512"`
|
||||||
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
|
DurationDays int `gorm:"column:duration_days"`
|
||||||
GoldAmount int64 `gorm:"column:gold_amount"`
|
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
|
||||||
Probability int `gorm:"column:probability"`
|
GoldAmount int64 `gorm:"column:gold_amount"`
|
||||||
Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"`
|
Probability int `gorm:"column:probability"`
|
||||||
ErrorMessage string `gorm:"column:error_message;size:1024"`
|
Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"`
|
||||||
CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"`
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
ErrorMessage string `gorm:"column:error_message;size:1024"`
|
||||||
|
GrantTime *time.Time `gorm:"column:grant_time"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the wheel draw record table name.
|
// TableName returns the wheel draw record table name.
|
||||||
|
|||||||
@ -88,7 +88,7 @@ func NewRouter(
|
|||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
|
||||||
registerVipRoutes(engine, javaClient, services.VIP)
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
registerWheelRoutes(engine, javaClient, services.Wheel)
|
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
||||||
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
|
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
|
||||||
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
||||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ func registerSmashEggRoutes(engine *gin.Engine, javaClient authGateway, service
|
|||||||
writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
|
||||||
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
|
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
@ -157,7 +158,7 @@ func handleLegacySmashEggAction(c *gin.Context, javaClient authGateway, service
|
|||||||
if times <= 0 {
|
if times <= 0 {
|
||||||
times = 1
|
times = 1
|
||||||
}
|
}
|
||||||
resp, err = service.LegacyStartHunt(c.Request.Context(), user, times)
|
resp, err = service.LegacyStartHunt(c.Request.Context(), user, times, requestIdempotencyKey(c, ""))
|
||||||
case "Action/TreasureHunt.getMyHuntRecord":
|
case "Action/TreasureHunt.getMyHuntRecord":
|
||||||
start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start")))
|
start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start")))
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num")))
|
limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num")))
|
||||||
@ -219,6 +220,25 @@ func firstNonEmptyQuery(c *gin.Context, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestIdempotencyKey(c *gin.Context, fallback string) string {
|
||||||
|
for _, value := range []string{
|
||||||
|
fallback,
|
||||||
|
c.GetHeader("Idempotency-Key"),
|
||||||
|
c.GetHeader("X-Idempotency-Key"),
|
||||||
|
firstNonEmptyQuery(c, "idempotencyKey", "idempotency_key", "requestId", "request_id", "nonce"),
|
||||||
|
strings.TrimSpace(c.PostForm("idempotencyKey")),
|
||||||
|
strings.TrimSpace(c.PostForm("idempotency_key")),
|
||||||
|
strings.TrimSpace(c.PostForm("requestId")),
|
||||||
|
strings.TrimSpace(c.PostForm("request_id")),
|
||||||
|
strings.TrimSpace(c.PostForm("nonce")),
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func writeLegacyOK(c *gin.Context, data any) {
|
func writeLegacyOK(c *gin.Context, data any) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response_data": data,
|
"response_data": data,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
"chatapp3-golang/internal/service/wheel"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -13,7 +14,7 @@ import (
|
|||||||
const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png"
|
const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png"
|
||||||
|
|
||||||
// registerWheelRoutes registers app, admin, and copied H5-compatible wheel APIs.
|
// registerWheelRoutes registers app, admin, and copied H5-compatible wheel APIs.
|
||||||
func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) {
|
func registerWheelRoutes(engine *gin.Engine, cfg config.Config, javaClient authGateway, wheelService *wheel.Service) {
|
||||||
if wheelService == nil {
|
if wheelService == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -34,6 +35,7 @@ func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelServic
|
|||||||
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
|
||||||
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
|
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
@ -114,6 +116,32 @@ func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelServic
|
|||||||
}
|
}
|
||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
|
residentGroup.POST("/record/retry", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wheelService.RetryDrawRecord(c.Request.Context(), req.ID); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"success": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
internalGroup := engine.Group("/internal/wheel")
|
||||||
|
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||||
|
internalGroup.POST("/records/retry-failed", func(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
|
||||||
|
processed, err := wheelService.ProcessFailedRecords(c.Request.Context(), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, gin.H{"processed": processed})
|
||||||
|
})
|
||||||
|
|
||||||
registerWheelLegacyH5Routes(engine, javaClient, wheelService)
|
registerWheelLegacyH5Routes(engine, javaClient, wheelService)
|
||||||
}
|
}
|
||||||
@ -206,6 +234,7 @@ func legacyDrawHandler(wheelService *wheel.Service, category string) gin.Handler
|
|||||||
var req wheel.DrawRequest
|
var req wheel.DrawRequest
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
req.Category = category
|
req.Category = category
|
||||||
|
req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey)
|
||||||
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
|
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
legacyError(c, err)
|
legacyError(c, err)
|
||||||
|
|||||||
157
internal/service/idempotency/store.go
Normal file
157
internal/service/idempotency/store.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package idempotency
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusProcessing = "PROCESSING"
|
||||||
|
StatusSucceeded = "SUCCEEDED"
|
||||||
|
StatusFailed = "FAILED"
|
||||||
|
|
||||||
|
MaxKeyLength = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmptyKey = errors.New("idempotency: key is empty")
|
||||||
|
ErrKeyTooLong = errors.New("idempotency: key is too long")
|
||||||
|
ErrRequestMismatch = errors.New("idempotency: request hash mismatch")
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB interface {
|
||||||
|
WithContext(ctx context.Context) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type BeginRequest struct {
|
||||||
|
Activity string
|
||||||
|
SysOrigin string
|
||||||
|
UserID int64
|
||||||
|
IdempotencyKey string
|
||||||
|
RequestHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BeginResult struct {
|
||||||
|
Record model.ActivityIdempotency
|
||||||
|
Created bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db DB) Store {
|
||||||
|
return Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeKey(key string) (string, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return "", ErrEmptyKey
|
||||||
|
}
|
||||||
|
if len(key) > MaxKeyLength {
|
||||||
|
return "", ErrKeyTooLong
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Hash(parts ...any) string {
|
||||||
|
payload, _ := json.Marshal(parts)
|
||||||
|
sum := sha256.Sum256(payload)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) Begin(ctx context.Context, req BeginRequest) (BeginResult, error) {
|
||||||
|
key, err := NormalizeKey(req.IdempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
row := model.ActivityIdempotency{
|
||||||
|
ID: id,
|
||||||
|
Activity: strings.ToUpper(strings.TrimSpace(req.Activity)),
|
||||||
|
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
|
||||||
|
UserID: req.UserID,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
RequestHash: req.RequestHash,
|
||||||
|
Status: StatusProcessing,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||||
|
if result.Error != nil {
|
||||||
|
return BeginResult{}, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 1 {
|
||||||
|
return BeginResult{Record: row, Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing model.ActivityIdempotency
|
||||||
|
err = s.db.WithContext(ctx).
|
||||||
|
Where("activity = ? AND sys_origin = ? AND user_id = ? AND idempotency_key = ?", row.Activity, row.SysOrigin, row.UserID, row.IdempotencyKey).
|
||||||
|
First(&existing).Error
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
if existing.RequestHash != req.RequestHash {
|
||||||
|
return BeginResult{}, ErrRequestMismatch
|
||||||
|
}
|
||||||
|
return BeginResult{Record: existing, Created: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) MarkSucceeded(ctx context.Context, id int64, businessNo string, response any) error {
|
||||||
|
responseJSON, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"business_no": strings.TrimSpace(businessNo),
|
||||||
|
"status": StatusSucceeded,
|
||||||
|
"response_json": string(responseJSON),
|
||||||
|
"error_message": "",
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) MarkFailed(ctx context.Context, id int64, businessNo string, err error) error {
|
||||||
|
message := ""
|
||||||
|
if err != nil {
|
||||||
|
message = err.Error()
|
||||||
|
}
|
||||||
|
if len(message) > 1024 {
|
||||||
|
message = message[:1024]
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"business_no": strings.TrimSpace(businessNo),
|
||||||
|
"status": StatusFailed,
|
||||||
|
"error_message": message,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeResponse(record model.ActivityIdempotency, out any) error {
|
||||||
|
if strings.TrimSpace(record.ResponseJSON) == "" {
|
||||||
|
return fmt.Errorf("idempotency response is empty")
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(record.ResponseJSON), out)
|
||||||
|
}
|
||||||
141
internal/service/prizepool/pool.go
Normal file
141
internal/service/prizepool/pool.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
package prizepool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoAvailableItem means every configured item was filtered out by switch,
|
||||||
|
// weight, total limit, or user limit.
|
||||||
|
ErrNoAvailableItem = errors.New("prizepool: no available item")
|
||||||
|
|
||||||
|
// ErrUserIssueCounterRequired means at least one item has a user limit, but no
|
||||||
|
// counter was provided to check how many times the user has already received it.
|
||||||
|
ErrUserIssueCounterRequired = errors.New("prizepool: user issue counter required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item is the activity-neutral shape used by the prize pool picker.
|
||||||
|
// TotalLimit/UserLimit use 0 to mean unlimited.
|
||||||
|
type Item struct {
|
||||||
|
ID int64
|
||||||
|
Enabled bool
|
||||||
|
Weight int
|
||||||
|
TotalLimit int64
|
||||||
|
UserLimit int64
|
||||||
|
IssuedCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickResult returns the selected item and its original index in the input slice.
|
||||||
|
type PickResult struct {
|
||||||
|
Item Item
|
||||||
|
Index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIssueCounter returns how many cap-occupying records one user already has
|
||||||
|
// for a prize item.
|
||||||
|
type UserIssueCounter interface {
|
||||||
|
CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIssueCounterFunc adapts a function to UserIssueCounter.
|
||||||
|
type UserIssueCounterFunc func(ctx context.Context, itemID int64, userID int64) (int64, error)
|
||||||
|
|
||||||
|
func (f UserIssueCounterFunc) CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error) {
|
||||||
|
return f(ctx, itemID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomIntn returns a number in [0, max).
|
||||||
|
type RandomIntn func(max int) (int, error)
|
||||||
|
|
||||||
|
// Picker owns the randomness used by the prize pool. A zero-value Picker uses
|
||||||
|
// crypto/rand so production callers do not need to seed a global RNG.
|
||||||
|
type Picker struct {
|
||||||
|
RandomIntn RandomIntn
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickAvailable filters unavailable items and selects one by Weight.
|
||||||
|
func (p Picker) PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
|
||||||
|
available := make([]PickResult, 0, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
if !item.Enabled || item.Weight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.TotalLimit > 0 && item.IssuedCount >= item.TotalLimit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.UserLimit > 0 {
|
||||||
|
if counter == nil {
|
||||||
|
return PickResult{}, ErrUserIssueCounterRequired
|
||||||
|
}
|
||||||
|
used, err := counter.CountUserIssues(ctx, item.ID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return PickResult{}, err
|
||||||
|
}
|
||||||
|
if used >= item.UserLimit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
available = append(available, PickResult{Item: item, Index: index})
|
||||||
|
}
|
||||||
|
if len(available) == 0 {
|
||||||
|
return PickResult{}, ErrNoAvailableItem
|
||||||
|
}
|
||||||
|
return p.PickWeighted(available)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickWeighted selects one already-filtered item by Weight.
|
||||||
|
func (p Picker) PickWeighted(items []PickResult) (PickResult, error) {
|
||||||
|
total := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Item.Enabled && item.Item.Weight > 0 {
|
||||||
|
total += item.Item.Weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return PickResult{}, ErrNoAvailableItem
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := p.randomIntn(total)
|
||||||
|
if err != nil {
|
||||||
|
return PickResult{}, err
|
||||||
|
}
|
||||||
|
target := n + 1
|
||||||
|
accumulated := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Item.Enabled || item.Item.Weight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accumulated += item.Item.Weight
|
||||||
|
if target <= accumulated {
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PickResult{}, ErrNoAvailableItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Picker) randomIntn(max int) (int, error) {
|
||||||
|
if max <= 0 {
|
||||||
|
return 0, ErrNoAvailableItem
|
||||||
|
}
|
||||||
|
if p.RandomIntn != nil {
|
||||||
|
return p.RandomIntn(max)
|
||||||
|
}
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickAvailable uses a zero-value Picker.
|
||||||
|
func PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
|
||||||
|
return Picker{}.PickAvailable(ctx, items, userID, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickWeighted uses a zero-value Picker.
|
||||||
|
func PickWeighted(items []PickResult) (PickResult, error) {
|
||||||
|
return Picker{}.PickWeighted(items)
|
||||||
|
}
|
||||||
74
internal/service/prizepool/pool_test.go
Normal file
74
internal/service/prizepool/pool_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package prizepool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPickAvailableSkipsUnavailableItems(t *testing.T) {
|
||||||
|
counter := UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
|
||||||
|
if itemID == 4 && userID == 1001 {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
})
|
||||||
|
picker := Picker{RandomIntn: func(max int) (int, error) {
|
||||||
|
if max != 7 {
|
||||||
|
t.Fatalf("max = %d, want 7", max)
|
||||||
|
}
|
||||||
|
return 6, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
picked, err := picker.PickAvailable(context.Background(), []Item{
|
||||||
|
{ID: 1, Enabled: false, Weight: 100},
|
||||||
|
{ID: 2, Enabled: true, Weight: 0},
|
||||||
|
{ID: 3, Enabled: true, Weight: 5, TotalLimit: 10, IssuedCount: 10},
|
||||||
|
{ID: 4, Enabled: true, Weight: 8, UserLimit: 1},
|
||||||
|
{ID: 5, Enabled: true, Weight: 7},
|
||||||
|
}, 1001, counter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PickAvailable() error = %v", err)
|
||||||
|
}
|
||||||
|
if picked.Item.ID != 5 || picked.Index != 4 {
|
||||||
|
t.Fatalf("picked = %+v, want item 5 at original index 4", picked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickAvailableNoAvailableItem(t *testing.T) {
|
||||||
|
_, err := Picker{}.PickAvailable(context.Background(), []Item{
|
||||||
|
{ID: 1, Enabled: true, Weight: 1, TotalLimit: 1, IssuedCount: 1},
|
||||||
|
}, 1001, nil)
|
||||||
|
if !errors.Is(err, ErrNoAvailableItem) {
|
||||||
|
t.Fatalf("error = %v, want ErrNoAvailableItem", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickAvailableRequiresCounterForUserLimit(t *testing.T) {
|
||||||
|
_, err := Picker{}.PickAvailable(context.Background(), []Item{
|
||||||
|
{ID: 1, Enabled: true, Weight: 1, UserLimit: 1},
|
||||||
|
}, 1001, nil)
|
||||||
|
if !errors.Is(err, ErrUserIssueCounterRequired) {
|
||||||
|
t.Fatalf("error = %v, want ErrUserIssueCounterRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedUsesWeights(t *testing.T) {
|
||||||
|
picker := Picker{RandomIntn: func(max int) (int, error) {
|
||||||
|
if max != 10 {
|
||||||
|
t.Fatalf("max = %d, want 10", max)
|
||||||
|
}
|
||||||
|
return 3, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
picked, err := picker.PickWeighted([]PickResult{
|
||||||
|
{Item: Item{ID: 1, Enabled: true, Weight: 3}, Index: 0},
|
||||||
|
{Item: Item{ID: 2, Enabled: true, Weight: 7}, Index: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PickWeighted() error = %v", err)
|
||||||
|
}
|
||||||
|
if picked.Item.ID != 2 {
|
||||||
|
t.Fatalf("picked item = %d, want 2", picked.Item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,15 +2,16 @@ package smashegg
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/idempotency"
|
||||||
|
"chatapp3-golang/internal/service/prizepool"
|
||||||
"chatapp3-golang/internal/utils"
|
"chatapp3-golang/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -42,51 +43,62 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
idempotencyRow, replayed, err := s.beginDrawIdempotency(ctx, sysOrigin, user.UserID, option.Times, drawRequestIdempotencyKey(req))
|
||||||
|
if err != nil || replayed != nil {
|
||||||
|
return replayed, err
|
||||||
|
}
|
||||||
|
failIdempotency := func(drawNo string, drawErr error) (*DrawResponse, error) {
|
||||||
|
if idempotencyRow != nil {
|
||||||
|
_ = idempotency.NewStore(s.db).MarkFailed(ctx, idempotencyRow.ID, drawNo, drawErr)
|
||||||
|
}
|
||||||
|
return nil, drawErr
|
||||||
|
}
|
||||||
|
|
||||||
poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user)
|
poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failIdempotency("", err)
|
||||||
}
|
}
|
||||||
if len(rewardPool) == 0 {
|
if len(rewardPool) == 0 {
|
||||||
return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
|
return failIdempotency("", NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured"))
|
||||||
}
|
}
|
||||||
rewardPool, err = rewardsWithAutoProbabilitiesForOption(bundle.Config.RTPBasisPoints, option, rewardPool)
|
rewardPool, err = rewardsWithAutoProbabilitiesForOption(bundle.Config.RTPBasisPoints, option, rewardPool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failIdempotency("", err)
|
||||||
}
|
}
|
||||||
selected := make([]model.SmashEggRewardConfig, 0, option.Times)
|
selected := make([]model.SmashEggRewardConfig, 0, option.Times)
|
||||||
for index := 0; index < option.Times; index++ {
|
for index := 0; index < option.Times; index++ {
|
||||||
reward, pickErr := pickReward(rewardPool)
|
reward, pickErr := pickReward(rewardPool)
|
||||||
if pickErr != nil {
|
if pickErr != nil {
|
||||||
return nil, pickErr
|
return failIdempotency("", pickErr)
|
||||||
}
|
}
|
||||||
selected = append(selected, reward)
|
selected = append(selected, reward)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawID, err := utils.NextID()
|
drawID, err := utils.NextID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failIdempotency("", err)
|
||||||
}
|
}
|
||||||
drawNo := fmt.Sprintf("SMASHEGG%d", drawID)
|
drawNo := fmt.Sprintf("SMASHEGG%d", drawID)
|
||||||
records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected)
|
records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failIdempotency(drawNo, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil {
|
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil {
|
||||||
_ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error())
|
_ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error())
|
||||||
return nil, mapWalletError("smash_egg_wallet_deduct_failed", err)
|
return failIdempotency(drawNo, mapWalletError("smash_egg_wallet_deduct_failed", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
for index := range records {
|
for index := range records {
|
||||||
record := &records[index]
|
record := &records[index]
|
||||||
if err := s.grantReward(ctx, record); err != nil {
|
if err := s.grantReward(ctx, record); err != nil {
|
||||||
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
|
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
|
||||||
return nil, NewAppError(http.StatusBadGateway, "smash_egg_reward_grant_failed", err.Error())
|
return failIdempotency(drawNo, NewAppError(http.StatusBadGateway, "smash_egg_reward_grant_failed", err.Error()))
|
||||||
}
|
}
|
||||||
record.Status = drawStatusSuccess
|
record.Status = drawStatusSuccess
|
||||||
record.UpdateTime = time.Now()
|
record.UpdateTime = time.Now()
|
||||||
if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil {
|
if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil {
|
||||||
return nil, err
|
return failIdempotency(drawNo, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +107,63 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
|
|||||||
balanceAfter = balanceMap[user.UserID]
|
balanceAfter = balanceMap[user.UserID]
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildDrawResponse(drawNo, user.UserID, sysOrigin, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records), nil
|
resp := buildDrawResponse(drawNo, user.UserID, sysOrigin, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records)
|
||||||
|
if idempotencyRow != nil {
|
||||||
|
_ = idempotency.NewStore(s.db).MarkSucceeded(ctx, idempotencyRow.ID, drawNo, resp)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawRequestIdempotencyKey(req DrawRequest) string {
|
||||||
|
for _, value := range []string{req.IdempotencyKey, req.IdempotencyKeySnake, req.RequestID, req.RequestIDSnake, req.Nonce} {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) beginDrawIdempotency(ctx context.Context, sysOrigin string, userID int64, times int, key string) (*model.ActivityIdempotency, *DrawResponse, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
result, err := idempotency.NewStore(s.db).Begin(ctx, idempotency.BeginRequest{
|
||||||
|
Activity: "SMASH_EGG",
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
UserID: userID,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
RequestHash: idempotency.Hash("SMASH_EGG", times),
|
||||||
|
})
|
||||||
|
if errors.Is(err, idempotency.ErrKeyTooLong) {
|
||||||
|
return nil, nil, NewAppError(http.StatusBadRequest, "idempotency_key_too_long", "idempotencyKey must be 128 characters or less")
|
||||||
|
}
|
||||||
|
if errors.Is(err, idempotency.ErrRequestMismatch) {
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_key_conflict", "idempotencyKey was already used for a different draw request")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if result.Created {
|
||||||
|
return &result.Record, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result.Record.Status {
|
||||||
|
case idempotency.StatusSucceeded:
|
||||||
|
var resp DrawResponse
|
||||||
|
if err := idempotency.DecodeResponse(result.Record, &resp); err != nil {
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_response_unavailable", "idempotency response is unavailable")
|
||||||
|
}
|
||||||
|
return nil, &resp, nil
|
||||||
|
case idempotency.StatusFailed:
|
||||||
|
message := strings.TrimSpace(result.Record.ErrorMessage)
|
||||||
|
if message == "" {
|
||||||
|
message = "draw request failed"
|
||||||
|
}
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_failed", message)
|
||||||
|
default:
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_processing", "draw request is still processing")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.SmashEggDrawOptionConfig, error) {
|
func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.SmashEggDrawOptionConfig, error) {
|
||||||
@ -111,32 +179,28 @@ func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) {
|
func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) {
|
||||||
total := 0
|
items := make([]prizepool.PickResult, 0, len(rewards))
|
||||||
for _, reward := range rewards {
|
for index, reward := range rewards {
|
||||||
if reward.Enabled && reward.Probability > 0 {
|
items = append(items, prizepool.PickResult{
|
||||||
total += reward.Probability
|
Item: prizepool.Item{
|
||||||
}
|
ID: reward.ID,
|
||||||
|
Enabled: reward.Enabled,
|
||||||
|
Weight: reward.Probability,
|
||||||
|
},
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if total <= 0 {
|
picked, err := prizepool.PickWeighted(items)
|
||||||
|
if errors.Is(err, prizepool.ErrNoAvailableItem) {
|
||||||
return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
|
return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SmashEggRewardConfig{}, err
|
return model.SmashEggRewardConfig{}, err
|
||||||
}
|
}
|
||||||
target := int(n.Int64()) + 1
|
if picked.Index < 0 || picked.Index >= len(rewards) {
|
||||||
accumulated := 0
|
return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
|
||||||
for _, reward := range rewards {
|
|
||||||
if !reward.Enabled || reward.Probability <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
accumulated += reward.Probability
|
|
||||||
if target <= accumulated {
|
|
||||||
return reward, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return rewards[len(rewards)-1], nil
|
return rewards[picked.Index], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) createPendingRecords(
|
func (s *Service) createPendingRecords(
|
||||||
|
|||||||
@ -3,15 +3,21 @@ package smashegg
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeSmashEggGateway struct {
|
type fakeSmashEggGateway struct {
|
||||||
giftCalls []fakeGiftBackpackCall
|
giftCalls []fakeGiftBackpackCall
|
||||||
propsCalls []integration.GivePropsBackpackRequest
|
propsCalls []integration.GivePropsBackpackRequest
|
||||||
switches int
|
switches int
|
||||||
|
changes []integration.GoldReceiptCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeGiftBackpackCall struct {
|
type fakeGiftBackpackCall struct {
|
||||||
@ -20,7 +26,8 @@ type fakeGiftBackpackCall struct {
|
|||||||
quantity int
|
quantity int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *fakeSmashEggGateway) ChangeGoldBalance(context.Context, integration.GoldReceiptCommand) error {
|
func (g *fakeSmashEggGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
|
g.changes = append(g.changes, cmd)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,3 +93,98 @@ func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) {
|
|||||||
t.Fatalf("switch use props calls = %d, want 0", gateway.switches)
|
t.Fatalf("switch use props calls = %d, want 0", gateway.switches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrawIdempotencyReturnsOriginalResultWithoutSecondCharge(t *testing.T) {
|
||||||
|
service, gateway, db := newSmashEggDrawTestService(t)
|
||||||
|
seedSmashEggDrawConfig(t, db)
|
||||||
|
req := DrawRequest{Times: 1, IdempotencyKey: "egg-h5-key-1"}
|
||||||
|
|
||||||
|
first, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() second error = %v", err)
|
||||||
|
}
|
||||||
|
if second.DrawNo != first.DrawNo {
|
||||||
|
t.Fatalf("second drawNo = %s, want %s", second.DrawNo, first.DrawNo)
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 {
|
||||||
|
t.Fatalf("wallet changes = %d, want one pay+reward", len(gateway.changes))
|
||||||
|
}
|
||||||
|
var recordCount int64
|
||||||
|
if err := db.Model(&model.SmashEggDrawRecord{}).Where("draw_no = ?", first.DrawNo).Count(&recordCount).Error; err != nil {
|
||||||
|
t.Fatalf("count records: %v", err)
|
||||||
|
}
|
||||||
|
if recordCount != 1 {
|
||||||
|
t.Fatalf("record count = %d, want 1", recordCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawIdempotencyRejectsDifferentRequest(t *testing.T) {
|
||||||
|
service, gateway, db := newSmashEggDrawTestService(t)
|
||||||
|
seedSmashEggDrawConfig(t, db)
|
||||||
|
|
||||||
|
_, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Times: 1, IdempotencyKey: "egg-h5-key-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Times: 10, IdempotencyKey: "egg-h5-key-2"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Draw() second expected idempotency conflict")
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 {
|
||||||
|
t.Fatalf("wallet changes = %d, want only first pay+reward", len(gateway.changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSmashEggDrawTestService(t *testing.T) (*Service, *fakeSmashEggGateway, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.SmashEggConfig{},
|
||||||
|
&model.SmashEggDrawOptionConfig{},
|
||||||
|
&model.SmashEggRewardConfig{},
|
||||||
|
&model.SmashEggDrawRecord{},
|
||||||
|
&model.ActivityIdempotency{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate sqlite: %v", err)
|
||||||
|
}
|
||||||
|
gateway := &fakeSmashEggGateway{}
|
||||||
|
return NewService(config.Config{}, db, gateway), gateway, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedSmashEggDrawConfig(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
configRow := model.SmashEggConfig{
|
||||||
|
ID: 1,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Enabled: true,
|
||||||
|
Timezone: defaultTimezone,
|
||||||
|
RTPBasisPoints: defaultRTPBasisPoints,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
options := []model.SmashEggDrawOptionConfig{
|
||||||
|
{ID: 2, ConfigID: 1, SysOrigin: "LIKEI", OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 100, Sort: 1, Enabled: true, CreateTime: now, UpdateTime: now},
|
||||||
|
{ID: 3, ConfigID: 1, SysOrigin: "LIKEI", OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 1000, Sort: 2, Enabled: true, CreateTime: now, UpdateTime: now},
|
||||||
|
}
|
||||||
|
rewards := []model.SmashEggRewardConfig{
|
||||||
|
{ID: 4, ConfigID: 1, SysOrigin: "LIKEI", PoolType: poolTypeGeneral, RewardType: rewardTypeGold, RewardGroupName: "50 Gold", GoldAmount: 50, RewardValueGold: 50, Sort: 1, Enabled: true, CreateTime: now, UpdateTime: now},
|
||||||
|
{ID: 5, ConfigID: 1, SysOrigin: "LIKEI", PoolType: poolTypeGeneral, RewardType: rewardTypeGold, RewardGroupName: "100 Gold", GoldAmount: 100, RewardValueGold: 100, Sort: 2, Enabled: true, CreateTime: now, UpdateTime: now},
|
||||||
|
}
|
||||||
|
if err := db.Create(&configRow).Error; err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&options).Error; err != nil {
|
||||||
|
t.Fatalf("seed options: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&rewards).Error; err != nil {
|
||||||
|
t.Fatalf("seed rewards: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -46,8 +46,8 @@ func (s *Service) LegacyPrizeList(ctx context.Context, user AuthUser) (map[strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LegacyStartHunt executes draw and returns the shape expected by the H5 bundle.
|
// LegacyStartHunt executes draw and returns the shape expected by the H5 bundle.
|
||||||
func (s *Service) LegacyStartHunt(ctx context.Context, user AuthUser, times int) (map[string]any, error) {
|
func (s *Service) LegacyStartHunt(ctx context.Context, user AuthUser, times int, idempotencyKey string) (map[string]any, error) {
|
||||||
resp, err := s.Draw(ctx, user, DrawRequest{Times: times})
|
resp, err := s.Draw(ctx, user, DrawRequest{Times: times, IdempotencyKey: idempotencyKey})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,7 +246,12 @@ type RewardGroupItem struct {
|
|||||||
|
|
||||||
// DrawRequest is an app draw request.
|
// DrawRequest is an app draw request.
|
||||||
type DrawRequest struct {
|
type DrawRequest struct {
|
||||||
Times int `json:"times"`
|
Times int `json:"times"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
IdempotencyKeySnake string `json:"idempotency_key,omitempty"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
RequestIDSnake string `json:"request_id,omitempty"`
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrawResponse is the app draw result.
|
// DrawResponse is the app draw result.
|
||||||
|
|||||||
@ -67,7 +67,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var configRow model.WheelConfig
|
var configRow model.WheelConfig
|
||||||
if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
if err := withWriteLock(tx).Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
nextID, idErr := utils.NextID()
|
nextID, idErr := utils.NextID()
|
||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
return idErr
|
return idErr
|
||||||
@ -91,6 +91,10 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingRewards, err := loadExistingRewardRows(ctx, tx, configRow.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -121,7 +125,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
})
|
})
|
||||||
|
|
||||||
for index, item := range category.Rewards {
|
for index, item := range category.Rewards {
|
||||||
nextID, rewardIDErr := utils.NextID()
|
rewardID, issuedCount, rewardIDErr := resolveRewardRowIdentity(existingRewards, item)
|
||||||
if rewardIDErr != nil {
|
if rewardIDErr != nil {
|
||||||
return rewardIDErr
|
return rewardIDErr
|
||||||
}
|
}
|
||||||
@ -130,7 +134,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
sortValue = index + 1
|
sortValue = index + 1
|
||||||
}
|
}
|
||||||
rewardRows = append(rewardRows, model.WheelRewardConfig{
|
rewardRows = append(rewardRows, model.WheelRewardConfig{
|
||||||
ID: nextID,
|
ID: rewardID,
|
||||||
ConfigID: configRow.ID,
|
ConfigID: configRow.ID,
|
||||||
SysOrigin: normalized.SysOrigin,
|
SysOrigin: normalized.SysOrigin,
|
||||||
Category: category.Category,
|
Category: category.Category,
|
||||||
@ -144,6 +148,9 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
DisplayGoldAmount: item.DisplayGoldAmount,
|
DisplayGoldAmount: item.DisplayGoldAmount,
|
||||||
GoldAmount: item.GoldAmount,
|
GoldAmount: item.GoldAmount,
|
||||||
Probability: item.Probability,
|
Probability: item.Probability,
|
||||||
|
TotalLimit: item.TotalLimit,
|
||||||
|
UserLimit: item.UserLimit,
|
||||||
|
IssuedCount: issuedCount,
|
||||||
Sort: sortValue,
|
Sort: sortValue,
|
||||||
Enabled: item.Enabled,
|
Enabled: item.Enabled,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
@ -177,7 +184,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var configRow model.WheelConfig
|
var configRow model.WheelConfig
|
||||||
if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
if err := withWriteLock(tx).Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
nextID, idErr := utils.NextID()
|
nextID, idErr := utils.NextID()
|
||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
return idErr
|
return idErr
|
||||||
@ -202,6 +209,10 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig
|
|||||||
|
|
||||||
category := normalized.Category
|
category := normalized.Category
|
||||||
meta, _ := categoryMeta(category.Category)
|
meta, _ := categoryMeta(category.Category)
|
||||||
|
existingRewards, err := loadExistingRewardRows(ctx, tx, configRow.ID, category.Category)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -232,7 +243,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig
|
|||||||
|
|
||||||
rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards))
|
rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards))
|
||||||
for index, item := range category.Rewards {
|
for index, item := range category.Rewards {
|
||||||
nextID, rewardIDErr := utils.NextID()
|
rewardID, issuedCount, rewardIDErr := resolveRewardRowIdentity(existingRewards, item)
|
||||||
if rewardIDErr != nil {
|
if rewardIDErr != nil {
|
||||||
return rewardIDErr
|
return rewardIDErr
|
||||||
}
|
}
|
||||||
@ -241,7 +252,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig
|
|||||||
sortValue = index + 1
|
sortValue = index + 1
|
||||||
}
|
}
|
||||||
rewardRows = append(rewardRows, model.WheelRewardConfig{
|
rewardRows = append(rewardRows, model.WheelRewardConfig{
|
||||||
ID: nextID,
|
ID: rewardID,
|
||||||
ConfigID: configRow.ID,
|
ConfigID: configRow.ID,
|
||||||
SysOrigin: normalized.SysOrigin,
|
SysOrigin: normalized.SysOrigin,
|
||||||
Category: category.Category,
|
Category: category.Category,
|
||||||
@ -255,6 +266,9 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig
|
|||||||
DisplayGoldAmount: item.DisplayGoldAmount,
|
DisplayGoldAmount: item.DisplayGoldAmount,
|
||||||
GoldAmount: item.GoldAmount,
|
GoldAmount: item.GoldAmount,
|
||||||
Probability: item.Probability,
|
Probability: item.Probability,
|
||||||
|
TotalLimit: item.TotalLimit,
|
||||||
|
UserLimit: item.UserLimit,
|
||||||
|
IssuedCount: issuedCount,
|
||||||
Sort: sortValue,
|
Sort: sortValue,
|
||||||
Enabled: item.Enabled,
|
Enabled: item.Enabled,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
@ -420,6 +434,12 @@ func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, re
|
|||||||
if item.Probability < 0 {
|
if item.Probability < 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_probability", "probability must be greater than or equal to 0")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_probability", "probability must be greater than or equal to 0")
|
||||||
}
|
}
|
||||||
|
if item.TotalLimit < 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_total_limit", "totalLimit must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if item.UserLimit < 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_user_limit", "userLimit must be greater than or equal to 0")
|
||||||
|
}
|
||||||
if item.Enabled {
|
if item.Enabled {
|
||||||
enabledCount++
|
enabledCount++
|
||||||
if requireEnabled && item.Probability <= 0 {
|
if requireEnabled && item.Probability <= 0 {
|
||||||
@ -433,20 +453,80 @@ func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, re
|
|||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_enabled_reward_count", fmt.Sprintf("%s must enable exactly %d rewards", meta.Category, meta.RewardCount))
|
return nil, NewAppError(http.StatusBadRequest, "invalid_enabled_reward_count", fmt.Sprintf("%s must enable exactly %d rewards", meta.Category, meta.RewardCount))
|
||||||
}
|
}
|
||||||
if enabledProbability != probabilityTotal {
|
if enabledProbability != probabilityTotal {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_probability_total", fmt.Sprintf("%s enabled reward probability total must equal 10000", meta.Category))
|
return nil, NewAppError(http.StatusBadRequest, "invalid_probability_total", fmt.Sprintf("%s enabled reward probability total must equal %d", meta.Category, probabilityTotal))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadExistingRewardRows(ctx context.Context, tx *gorm.DB, configID int64, category string) (map[int64]model.WheelRewardConfig, error) {
|
||||||
|
rows := make([]model.WheelRewardConfig, 0)
|
||||||
|
query := tx.WithContext(ctx).Where("config_id = ?", configID)
|
||||||
|
if strings.TrimSpace(category) != "" {
|
||||||
|
query = query.Where("category = ?", normalizeCategory(category))
|
||||||
|
}
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[int64]model.WheelRewardConfig, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.ID] = row
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRewardRowIdentity(existing map[int64]model.WheelRewardConfig, item RewardConfigInput) (int64, int64, error) {
|
||||||
|
if item.ID > 0 {
|
||||||
|
if row, ok := existing[item.ID]; ok && sameRewardIdentity(row, item) {
|
||||||
|
return item.ID, row.IssuedCount, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextID, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return nextID, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRewardIdentity(row model.WheelRewardConfig, item RewardConfigInput) bool {
|
||||||
|
if normalizeRewardType(row.RewardType) != normalizeRewardType(item.RewardType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if normalizeRewardType(item.RewardType) == rewardTypeGold {
|
||||||
|
return row.GoldAmount == item.GoldAmount
|
||||||
|
}
|
||||||
|
return int64PtrValue(row.ResourceID) == item.ResourceID.Int64() &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(row.ResourceType), strings.TrimSpace(item.ResourceType)) &&
|
||||||
|
row.DurationDays == item.DurationDays
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabledRewards bool) (*configBundle, error) {
|
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabledRewards bool) (*configBundle, error) {
|
||||||
|
var bundle *configBundle
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
loaded, err := s.loadConfigBundleTx(ctx, tx, sysOrigin, onlyEnabledRewards, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bundle = loaded
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadConfigBundleTx(ctx context.Context, tx *gorm.DB, sysOrigin string, onlyEnabledRewards bool, lockRows bool) (*configBundle, error) {
|
||||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
if sysOrigin == "" {
|
if sysOrigin == "" {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
|
return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var configRow model.WheelConfig
|
var configRow model.WheelConfig
|
||||||
err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error
|
configQuery := tx.WithContext(ctx).Where("sys_origin = ?", sysOrigin)
|
||||||
|
if lockRows {
|
||||||
|
configQuery = withWriteLock(configQuery)
|
||||||
|
}
|
||||||
|
err := configQuery.First(&configRow).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return &configBundle{}, nil
|
return &configBundle{}, nil
|
||||||
}
|
}
|
||||||
@ -455,19 +535,25 @@ func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
var categoryRows []model.WheelPoolConfig
|
var categoryRows []model.WheelPoolConfig
|
||||||
categoryQuery := s.db.WithContext(ctx).
|
categoryQuery := tx.WithContext(ctx).
|
||||||
Where("config_id = ?", configRow.ID).
|
Where("config_id = ?", configRow.ID).
|
||||||
Order("sort asc, id asc")
|
Order("sort asc, id asc")
|
||||||
|
if lockRows {
|
||||||
|
categoryQuery = withWriteLock(categoryQuery)
|
||||||
|
}
|
||||||
if err := categoryQuery.Find(&categoryRows).Error; err != nil {
|
if err := categoryQuery.Find(&categoryRows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rewardQuery := s.db.WithContext(ctx).
|
rewardQuery := tx.WithContext(ctx).
|
||||||
Where("config_id = ?", configRow.ID).
|
Where("config_id = ?", configRow.ID).
|
||||||
Order("category asc, sort asc, id asc")
|
Order("category asc, sort asc, id asc")
|
||||||
if onlyEnabledRewards {
|
if onlyEnabledRewards {
|
||||||
rewardQuery = rewardQuery.Where("enabled = ?", true)
|
rewardQuery = rewardQuery.Where("enabled = ?", true)
|
||||||
}
|
}
|
||||||
|
if lockRows {
|
||||||
|
rewardQuery = withWriteLock(rewardQuery)
|
||||||
|
}
|
||||||
var rewardRows []model.WheelRewardConfig
|
var rewardRows []model.WheelRewardConfig
|
||||||
if err := rewardQuery.Find(&rewardRows).Error; err != nil {
|
if err := rewardQuery.Find(&rewardRows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -619,5 +705,8 @@ func rewardPayloadFromConfig(row model.WheelRewardConfig) RewardConfigPayload {
|
|||||||
GoldAmount: row.GoldAmount,
|
GoldAmount: row.GoldAmount,
|
||||||
Probability: row.Probability,
|
Probability: row.Probability,
|
||||||
ProbabilityPercent: probabilityPercent(row.Probability),
|
ProbabilityPercent: probabilityPercent(row.Probability),
|
||||||
|
TotalLimit: row.TotalLimit,
|
||||||
|
UserLimit: row.UserLimit,
|
||||||
|
IssuedCount: row.IssuedCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,19 @@ package wheel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/idempotency"
|
||||||
|
"chatapp3-golang/internal/service/prizepool"
|
||||||
"chatapp3-golang/internal/utils"
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw executes one wheel draw request for an authenticated user.
|
// Draw executes one wheel draw request for an authenticated user.
|
||||||
@ -23,81 +26,147 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
|
|||||||
if s.java == nil {
|
if s.java == nil {
|
||||||
return nil, NewAppError(http.StatusServiceUnavailable, "wheel_gateway_unavailable", "reward gateway is unavailable")
|
return nil, NewAppError(http.StatusServiceUnavailable, "wheel_gateway_unavailable", "reward gateway is unavailable")
|
||||||
}
|
}
|
||||||
roomID, err := s.resolveCurrentRoomID(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
category := normalizeCategory(req.Category)
|
category := normalizeCategory(req.Category)
|
||||||
if _, ok := categoryMeta(category); !ok {
|
if _, ok := categoryMeta(category); !ok {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED")
|
||||||
}
|
}
|
||||||
|
|
||||||
times, err := normalizeDrawTimes(req.Times)
|
times, err := normalizeDrawTimes(req.Times)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
bundle, err := s.loadConfigBundle(ctx, sysOrigin, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if bundle.Config == nil || !bundle.Config.Enabled {
|
|
||||||
return nil, NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled")
|
|
||||||
}
|
|
||||||
pool, rewards, err := selectDrawPool(bundle, category)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(rewards) == 0 {
|
|
||||||
return nil, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured")
|
|
||||||
}
|
|
||||||
paidGold, err := priceForTimes(pool, times)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := make([]model.WheelRewardConfig, 0, times)
|
idempotencyRow, replayed, err := s.beginDrawIdempotency(ctx, sysOrigin, user.UserID, category, times, drawRequestIdempotencyKey(req))
|
||||||
for index := 0; index < times; index++ {
|
if err != nil || replayed != nil {
|
||||||
reward, pickErr := pickReward(rewards)
|
return replayed, err
|
||||||
if pickErr != nil {
|
}
|
||||||
return nil, pickErr
|
failIdempotency := func(drawNo string, drawErr error) (*DrawResponse, error) {
|
||||||
|
if idempotencyRow != nil {
|
||||||
|
_ = idempotency.NewStore(s.db).MarkFailed(ctx, idempotencyRow.ID, drawNo, drawErr)
|
||||||
}
|
}
|
||||||
selected = append(selected, reward)
|
return nil, drawErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roomID, err := s.resolveCurrentRoomID(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return failIdempotency("", err)
|
||||||
|
}
|
||||||
drawID, err := utils.NextID()
|
drawID, err := utils.NextID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failIdempotency("", err)
|
||||||
}
|
}
|
||||||
drawNo := fmt.Sprintf("WHEEL%d", drawID)
|
drawNo := fmt.Sprintf("WHEEL%d", drawID)
|
||||||
records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, category, times, paidGold, selected)
|
|
||||||
if err != nil {
|
var paidGold int64
|
||||||
return nil, err
|
var records []model.WheelDrawRecord
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
bundle, loadErr := s.loadConfigBundleTx(ctx, tx, sysOrigin, true, true)
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
if bundle.Config == nil || !bundle.Config.Enabled {
|
||||||
|
return NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled")
|
||||||
|
}
|
||||||
|
pool, rewards, selectErr := selectDrawPool(bundle, category)
|
||||||
|
if selectErr != nil {
|
||||||
|
return selectErr
|
||||||
|
}
|
||||||
|
if len(rewards) == 0 {
|
||||||
|
return NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured")
|
||||||
|
}
|
||||||
|
price, priceErr := priceForTimes(pool, times)
|
||||||
|
if priceErr != nil {
|
||||||
|
return priceErr
|
||||||
|
}
|
||||||
|
created, createErr := s.createReservedPendingRecords(ctx, tx, drawNo, user.UserID, sysOrigin, category, times, price, rewards)
|
||||||
|
if createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
paidGold = price
|
||||||
|
records = created
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return failIdempotency(drawNo, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil {
|
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil {
|
||||||
_ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error())
|
_ = s.releaseDrawReservations(ctx, records, drawStatusPayFailed, err.Error())
|
||||||
return nil, mapWalletError("wheel_wallet_deduct_failed", err)
|
return failIdempotency(drawNo, mapWalletError("wheel_wallet_deduct_failed", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grantFailed := false
|
||||||
for index := range records {
|
for index := range records {
|
||||||
record := &records[index]
|
if err := s.ProcessDrawRecord(ctx, records[index].ID); err != nil {
|
||||||
if err := s.grantReward(ctx, record); err != nil {
|
grantFailed = true
|
||||||
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
|
|
||||||
return nil, NewAppError(http.StatusBadGateway, "wheel_reward_grant_failed", err.Error())
|
|
||||||
}
|
|
||||||
record.Status = drawStatusSuccess
|
|
||||||
record.UpdateTime = time.Now()
|
|
||||||
if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
records, _ = s.loadDrawRecords(ctx, drawNo)
|
||||||
|
|
||||||
var balanceAfter int64
|
var balanceAfter int64
|
||||||
if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil {
|
if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil {
|
||||||
balanceAfter = balanceMap[user.UserID]
|
balanceAfter = balanceMap[user.UserID]
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records), nil
|
resp := buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records)
|
||||||
|
if grantFailed {
|
||||||
|
resp.GrantFailed = true
|
||||||
|
resp.Message = "some rewards are waiting for retry"
|
||||||
|
}
|
||||||
|
if idempotencyRow != nil {
|
||||||
|
_ = idempotency.NewStore(s.db).MarkSucceeded(ctx, idempotencyRow.ID, drawNo, resp)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawRequestIdempotencyKey(req DrawRequest) string {
|
||||||
|
for _, value := range []string{req.IdempotencyKey, req.IdempotencyKeySnake, req.RequestID, req.RequestIDSnake, req.Nonce} {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) beginDrawIdempotency(ctx context.Context, sysOrigin string, userID int64, category string, times int, key string) (*model.ActivityIdempotency, *DrawResponse, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
result, err := idempotency.NewStore(s.db).Begin(ctx, idempotency.BeginRequest{
|
||||||
|
Activity: "WHEEL",
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
UserID: userID,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
RequestHash: idempotency.Hash("WHEEL", category, times),
|
||||||
|
})
|
||||||
|
if errors.Is(err, idempotency.ErrKeyTooLong) {
|
||||||
|
return nil, nil, NewAppError(http.StatusBadRequest, "idempotency_key_too_long", "idempotencyKey must be 128 characters or less")
|
||||||
|
}
|
||||||
|
if errors.Is(err, idempotency.ErrRequestMismatch) {
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_key_conflict", "idempotencyKey was already used for a different draw request")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if result.Created {
|
||||||
|
return &result.Record, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result.Record.Status {
|
||||||
|
case idempotency.StatusSucceeded:
|
||||||
|
var resp DrawResponse
|
||||||
|
if err := idempotency.DecodeResponse(result.Record, &resp); err != nil {
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_response_unavailable", "idempotency response is unavailable")
|
||||||
|
}
|
||||||
|
return nil, &resp, nil
|
||||||
|
case idempotency.StatusFailed:
|
||||||
|
message := strings.TrimSpace(result.Record.ErrorMessage)
|
||||||
|
if message == "" {
|
||||||
|
message = "draw request failed"
|
||||||
|
}
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_failed", message)
|
||||||
|
default:
|
||||||
|
return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_processing", "draw request is still processing")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveCurrentRoomID(ctx context.Context, user AuthUser) (int64, error) {
|
func (s *Service) resolveCurrentRoomID(ctx context.Context, user AuthUser) (int64, error) {
|
||||||
@ -182,37 +251,9 @@ func priceForTimes(configRow model.WheelPoolConfig, times int) (int64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickReward(rewards []model.WheelRewardConfig) (model.WheelRewardConfig, error) {
|
func (s *Service) createReservedPendingRecords(
|
||||||
total := 0
|
|
||||||
for _, reward := range rewards {
|
|
||||||
if reward.Enabled && reward.Probability > 0 {
|
|
||||||
total += reward.Probability
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total <= 0 {
|
|
||||||
return model.WheelRewardConfig{}, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
|
||||||
if err != nil {
|
|
||||||
return model.WheelRewardConfig{}, err
|
|
||||||
}
|
|
||||||
target := int(n.Int64()) + 1
|
|
||||||
accumulated := 0
|
|
||||||
for _, reward := range rewards {
|
|
||||||
if !reward.Enabled || reward.Probability <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
accumulated += reward.Probability
|
|
||||||
if target <= accumulated {
|
|
||||||
return reward, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rewards[len(rewards)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) createPendingRecords(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
drawNo string,
|
drawNo string,
|
||||||
userID int64,
|
userID int64,
|
||||||
sysOrigin string,
|
sysOrigin string,
|
||||||
@ -222,13 +263,22 @@ func (s *Service) createPendingRecords(
|
|||||||
rewards []model.WheelRewardConfig,
|
rewards []model.WheelRewardConfig,
|
||||||
) ([]model.WheelDrawRecord, error) {
|
) ([]model.WheelDrawRecord, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
records := make([]model.WheelDrawRecord, 0, len(rewards))
|
records := make([]model.WheelDrawRecord, 0, times)
|
||||||
for index, reward := range rewards {
|
for index := 0; index < times; index++ {
|
||||||
|
rewardIndex, reward, err := s.pickAvailableReward(ctx, tx, rewards, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := reserveRewardIssue(ctx, tx, reward); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rewards[rewardIndex].IssuedCount++
|
||||||
|
|
||||||
nextID, err := utils.NextID()
|
nextID, err := utils.NextID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, model.WheelDrawRecord{
|
record := model.WheelDrawRecord{
|
||||||
ID: nextID,
|
ID: nextID,
|
||||||
DrawNo: drawNo,
|
DrawNo: drawNo,
|
||||||
EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1),
|
EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1),
|
||||||
@ -237,6 +287,7 @@ func (s *Service) createPendingRecords(
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
DrawTimes: times,
|
DrawTimes: times,
|
||||||
PaidGold: paidGold,
|
PaidGold: paidGold,
|
||||||
|
RewardConfigID: reward.ID,
|
||||||
RewardType: normalizeRewardType(reward.RewardType),
|
RewardType: normalizeRewardType(reward.RewardType),
|
||||||
ResourceID: reward.ResourceID,
|
ResourceID: reward.ResourceID,
|
||||||
ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)),
|
ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)),
|
||||||
@ -250,17 +301,73 @@ func (s *Service) createPendingRecords(
|
|||||||
Status: drawStatusPending,
|
Status: drawStatusPending,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
UpdateTime: now,
|
UpdateTime: now,
|
||||||
})
|
}
|
||||||
}
|
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
|
||||||
if len(records) == 0 {
|
return nil, err
|
||||||
return records, nil
|
}
|
||||||
}
|
records = append(records, record)
|
||||||
if err := s.db.WithContext(ctx).Create(&records).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) pickAvailableReward(ctx context.Context, tx *gorm.DB, rewards []model.WheelRewardConfig, userID int64) (int, model.WheelRewardConfig, error) {
|
||||||
|
picked, err := prizepool.PickAvailable(ctx, wheelPrizePoolItems(rewards), userID, prizepool.UserIssueCounterFunc(func(ctx context.Context, rewardConfigID int64, userID int64) (int64, error) {
|
||||||
|
return countUserRewardIssues(ctx, tx, rewardConfigID, userID)
|
||||||
|
}))
|
||||||
|
if errors.Is(err, prizepool.ErrNoAvailableItem) {
|
||||||
|
return 0, model.WheelRewardConfig{}, NewAppError(http.StatusConflict, "wheel_rewards_exhausted", "wheel rewards are exhausted")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, model.WheelRewardConfig{}, err
|
||||||
|
}
|
||||||
|
if picked.Index < 0 || picked.Index >= len(rewards) {
|
||||||
|
return 0, model.WheelRewardConfig{}, NewAppError(http.StatusConflict, "wheel_rewards_exhausted", "wheel rewards are exhausted")
|
||||||
|
}
|
||||||
|
return picked.Index, rewards[picked.Index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wheelPrizePoolItems(rewards []model.WheelRewardConfig) []prizepool.Item {
|
||||||
|
items := make([]prizepool.Item, 0, len(rewards))
|
||||||
|
for _, reward := range rewards {
|
||||||
|
items = append(items, prizepool.Item{
|
||||||
|
ID: reward.ID,
|
||||||
|
Enabled: reward.Enabled,
|
||||||
|
Weight: reward.Probability,
|
||||||
|
TotalLimit: reward.TotalLimit,
|
||||||
|
UserLimit: reward.UserLimit,
|
||||||
|
IssuedCount: reward.IssuedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func countUserRewardIssues(ctx context.Context, tx *gorm.DB, rewardConfigID int64, userID int64) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := tx.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||||
|
Where("reward_config_id = ? AND user_id = ? AND status IN ?", rewardConfigID, userID, capOccupyingStatuses()).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func reserveRewardIssue(ctx context.Context, tx *gorm.DB, reward model.WheelRewardConfig) error {
|
||||||
|
query := tx.WithContext(ctx).Model(&model.WheelRewardConfig{}).Where("id = ?", reward.ID)
|
||||||
|
if reward.TotalLimit > 0 {
|
||||||
|
query = query.Where("issued_count < total_limit")
|
||||||
|
}
|
||||||
|
result := query.UpdateColumn("issued_count", gorm.Expr("issued_count + ?", 1))
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return NewAppError(http.StatusConflict, "wheel_reward_exhausted", "wheel reward is exhausted")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capOccupyingStatuses() []string {
|
||||||
|
return []string{drawStatusPending, drawStatusGranting, drawStatusSuccess, drawStatusFailed}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error {
|
func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error {
|
||||||
if paidGold <= 0 {
|
if paidGold <= 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -279,12 +386,102 @@ func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryDrawRecord retries one paid wheel reward record. PAY_FAILED records are not retried
|
||||||
|
// because the user was not charged and the prize reservation has already been released.
|
||||||
|
func (s *Service) RetryDrawRecord(ctx context.Context, recordID int64) error {
|
||||||
|
if recordID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.ProcessDrawRecord(ctx, recordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessFailedRecords retries failed paid rewards in creation order.
|
||||||
|
func (s *Service) ProcessFailedRecords(ctx context.Context, limit int) (int, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
var rows []model.WheelDrawRecord
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("status = ?", drawStatusFailed).
|
||||||
|
Order("create_time ASC, id ASC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
processed := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := s.ProcessDrawRecord(ctx, row.ID); err != nil {
|
||||||
|
return processed, err
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
return processed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessDrawRecord grants one paid reward record and keeps failures retryable.
|
||||||
|
func (s *Service) ProcessDrawRecord(ctx context.Context, recordID int64) error {
|
||||||
|
var record model.WheelDrawRecord
|
||||||
|
shouldGrant := false
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := withWriteLock(tx).Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch record.Status {
|
||||||
|
case drawStatusSuccess:
|
||||||
|
return nil
|
||||||
|
case drawStatusPending, drawStatusFailed:
|
||||||
|
shouldGrant = true
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Model(&model.WheelDrawRecord{}).
|
||||||
|
Where("id = ? AND status IN ?", record.ID, []string{drawStatusPending, drawStatusFailed}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": drawStatusGranting,
|
||||||
|
"retry_count": gorm.Expr("retry_count + ?", 1),
|
||||||
|
"update_time": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record.Status = drawStatusGranting
|
||||||
|
record.RetryCount++
|
||||||
|
record.UpdateTime = now
|
||||||
|
return nil
|
||||||
|
case drawStatusGranting:
|
||||||
|
return NewAppError(http.StatusConflict, "wheel_reward_granting", "wheel reward is already being granted")
|
||||||
|
case drawStatusPayFailed:
|
||||||
|
return NewAppError(http.StatusConflict, "wheel_reward_not_paid", "wheel reward was not paid")
|
||||||
|
default:
|
||||||
|
return NewAppError(http.StatusConflict, "wheel_reward_not_retryable", "wheel reward is not retryable")
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !shouldGrant {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.grantReward(ctx, &record); err != nil {
|
||||||
|
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.markRecordSuccess(ctx, record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) grantReward(ctx context.Context, record *model.WheelDrawRecord) error {
|
func (s *Service) grantReward(ctx context.Context, record *model.WheelDrawRecord) error {
|
||||||
switch normalizeRewardType(record.RewardType) {
|
switch normalizeRewardType(record.RewardType) {
|
||||||
case rewardTypeGold:
|
case rewardTypeGold:
|
||||||
if record.GoldAmount <= 0 {
|
if record.GoldAmount <= 0 {
|
||||||
return fmt.Errorf("gold reward amount is empty")
|
return fmt.Errorf("gold reward amount is empty")
|
||||||
}
|
}
|
||||||
|
exists, err := s.java.ExistsGoldEvent(ctx, record.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
||||||
ReceiptType: walletReceiptIncome,
|
ReceiptType: walletReceiptIncome,
|
||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
@ -347,6 +544,36 @@ func (s *Service) grantResourceReward(ctx context.Context, record *model.WheelDr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) releaseDrawReservations(ctx context.Context, records []model.WheelDrawRecord, status string, message string) error {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, record := range records {
|
||||||
|
result := tx.Model(&model.WheelDrawRecord{}).
|
||||||
|
Where("id = ? AND status = ?", record.ID, drawStatusPending).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"error_message": trimErrorMessage(message),
|
||||||
|
"update_time": now,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 || record.RewardConfigID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.WheelRewardConfig{}).
|
||||||
|
Where("id = ?", record.RewardConfigID).
|
||||||
|
UpdateColumn("issued_count", gorm.Expr("CASE WHEN issued_count > 0 THEN issued_count - 1 ELSE 0 END")).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error {
|
func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error {
|
||||||
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||||
Where("draw_no = ?", drawNo).
|
Where("draw_no = ?", drawNo).
|
||||||
@ -357,6 +584,18 @@ func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status str
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) markRecordSuccess(ctx context.Context, id int64) error {
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": drawStatusSuccess,
|
||||||
|
"error_message": "",
|
||||||
|
"grant_time": now,
|
||||||
|
"update_time": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error {
|
func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error {
|
||||||
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
@ -367,6 +606,15 @@ func (s *Service) markRecordStatus(ctx context.Context, id int64, status string,
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadDrawRecords(ctx context.Context, drawNo string) ([]model.WheelDrawRecord, error) {
|
||||||
|
var rows []model.WheelDrawRecord
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("draw_no = ?", drawNo).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
func buildDrawResponse(
|
func buildDrawResponse(
|
||||||
drawNo string,
|
drawNo string,
|
||||||
userID int64,
|
userID int64,
|
||||||
|
|||||||
@ -2,16 +2,26 @@ package wheel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeWheelGateway struct {
|
type fakeWheelGateway struct {
|
||||||
giftCalls []fakeGiftBackpackCall
|
giftCalls []fakeGiftBackpackCall
|
||||||
propsCalls []integration.GivePropsBackpackRequest
|
propsCalls []integration.GivePropsBackpackRequest
|
||||||
switches int
|
switches int
|
||||||
|
changes []integration.GoldReceiptCommand
|
||||||
|
events map[string]struct{}
|
||||||
|
room integration.RoomProfile
|
||||||
|
failNextIncome bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeGiftBackpackCall struct {
|
type fakeGiftBackpackCall struct {
|
||||||
@ -20,10 +30,30 @@ type fakeGiftBackpackCall struct {
|
|||||||
quantity int
|
quantity int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *fakeWheelGateway) ChangeGoldBalance(context.Context, integration.GoldReceiptCommand) error {
|
func (g *fakeWheelGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
|
if g.events == nil {
|
||||||
|
g.events = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
if _, exists := g.events[cmd.EventID]; exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cmd.ReceiptType == walletReceiptIncome && g.failNextIncome {
|
||||||
|
g.failNextIncome = false
|
||||||
|
return errors.New("grant failed")
|
||||||
|
}
|
||||||
|
g.changes = append(g.changes, cmd)
|
||||||
|
g.events[cmd.EventID] = struct{}{}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *fakeWheelGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
|
||||||
|
if g.events == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_, exists := g.events[eventID]
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *fakeWheelGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
func (g *fakeWheelGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
||||||
g.propsCalls = append(g.propsCalls, req)
|
g.propsCalls = append(g.propsCalls, req)
|
||||||
return nil
|
return nil
|
||||||
@ -56,7 +86,7 @@ func (g *fakeWheelGateway) MapGoldBalance(context.Context, []int64) (map[int64]i
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *fakeWheelGateway) GetRoomProfileByUserID(context.Context, int64) (integration.RoomProfile, error) {
|
func (g *fakeWheelGateway) GetRoomProfileByUserID(context.Context, int64) (integration.RoomProfile, error) {
|
||||||
return integration.RoomProfile{}, nil
|
return g.room, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) {
|
func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) {
|
||||||
@ -86,3 +116,225 @@ func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) {
|
|||||||
t.Fatalf("switch use props calls = %d, want 0", gateway.switches)
|
t.Fatalf("switch use props calls = %d, want 0", gateway.switches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrawRespectsRewardTotalLimit(t *testing.T) {
|
||||||
|
service, gateway, db := newWheelDrawTestService(t)
|
||||||
|
seedWheelDrawConfig(t, db, 1, 0)
|
||||||
|
|
||||||
|
resp, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.GrantFailed {
|
||||||
|
t.Fatalf("Draw() first grant failed: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.Draw(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1}); err == nil {
|
||||||
|
t.Fatal("Draw() second expected exhausted error")
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 {
|
||||||
|
t.Fatalf("wallet changes = %d, want 2 pay+reward for first draw only", len(gateway.changes))
|
||||||
|
}
|
||||||
|
var reward model.WheelRewardConfig
|
||||||
|
if err := db.Where("sort = ?", 1).First(&reward).Error; err != nil {
|
||||||
|
t.Fatalf("load reward: %v", err)
|
||||||
|
}
|
||||||
|
if reward.IssuedCount != 1 {
|
||||||
|
t.Fatalf("issued_count = %d, want 1", reward.IssuedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawSkipsExhaustedRewardWhenFallbackAvailable(t *testing.T) {
|
||||||
|
service, gateway, db := newWheelDrawTestService(t)
|
||||||
|
seedWheelDrawConfig(t, db, 1, 0)
|
||||||
|
|
||||||
|
first, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
if len(first.Records) != 1 {
|
||||||
|
t.Fatalf("first records = %d, want 1", len(first.Records))
|
||||||
|
}
|
||||||
|
exhaustedRewardID := first.Records[0].RewardConfigID
|
||||||
|
|
||||||
|
if err := db.Model(&model.WheelRewardConfig{}).
|
||||||
|
Where("sort = ?", 2).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"probability": probabilityTotal,
|
||||||
|
"gold_amount": int64(2),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("enable fallback reward: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := service.Draw(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() second error = %v", err)
|
||||||
|
}
|
||||||
|
if second.GrantFailed {
|
||||||
|
t.Fatalf("Draw() second grant failed: %+v", second)
|
||||||
|
}
|
||||||
|
if len(second.Records) != 1 {
|
||||||
|
t.Fatalf("second records = %d, want 1", len(second.Records))
|
||||||
|
}
|
||||||
|
if second.Records[0].RewardConfigID == exhaustedRewardID {
|
||||||
|
t.Fatalf("second draw hit exhausted reward %d", exhaustedRewardID)
|
||||||
|
}
|
||||||
|
if second.Records[0].GoldAmount != 2 {
|
||||||
|
t.Fatalf("second reward gold = %d, want fallback gold 2", second.Records[0].GoldAmount)
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 4 {
|
||||||
|
t.Fatalf("wallet changes = %d, want 4 pay+reward twice", len(gateway.changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawFailedGrantCanBeRetriedWithoutNewPayment(t *testing.T) {
|
||||||
|
service, gateway, db := newWheelDrawTestService(t)
|
||||||
|
seedWheelDrawConfig(t, db, 0, 0)
|
||||||
|
gateway.failNextIncome = true
|
||||||
|
|
||||||
|
resp, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() error = %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GrantFailed {
|
||||||
|
t.Fatalf("Draw() grantFailed = false, want true")
|
||||||
|
}
|
||||||
|
if len(resp.Records) != 1 || resp.Records[0].Status != drawStatusFailed {
|
||||||
|
t.Fatalf("records = %+v, want one FAILED record", resp.Records)
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 1 || gateway.changes[0].ReceiptType != walletReceiptExpenditure {
|
||||||
|
t.Fatalf("wallet changes after failed grant = %+v, want only payment", gateway.changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.RetryDrawRecord(context.Background(), resp.Records[0].ID); err != nil {
|
||||||
|
t.Fatalf("RetryDrawRecord() error = %v", err)
|
||||||
|
}
|
||||||
|
var record model.WheelDrawRecord
|
||||||
|
if err := db.Where("id = ?", resp.Records[0].ID).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("load record: %v", err)
|
||||||
|
}
|
||||||
|
if record.Status != drawStatusSuccess {
|
||||||
|
t.Fatalf("record status = %s, want SUCCESS", record.Status)
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 || gateway.changes[1].ReceiptType != walletReceiptIncome {
|
||||||
|
t.Fatalf("wallet changes after retry = %+v, want reward income", gateway.changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawIdempotencyReturnsOriginalResultWithoutSecondCharge(t *testing.T) {
|
||||||
|
service, gateway, db := newWheelDrawTestService(t)
|
||||||
|
seedWheelDrawConfig(t, db, 0, 0)
|
||||||
|
req := DrawRequest{Category: categoryClassic, Times: 1, IdempotencyKey: "wheel-h5-key-1"}
|
||||||
|
|
||||||
|
first, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() second error = %v", err)
|
||||||
|
}
|
||||||
|
if second.DrawNo != first.DrawNo {
|
||||||
|
t.Fatalf("second drawNo = %s, want %s", second.DrawNo, first.DrawNo)
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 {
|
||||||
|
t.Fatalf("wallet changes = %d, want one pay+reward", len(gateway.changes))
|
||||||
|
}
|
||||||
|
var recordCount int64
|
||||||
|
if err := db.Model(&model.WheelDrawRecord{}).Where("draw_no = ?", first.DrawNo).Count(&recordCount).Error; err != nil {
|
||||||
|
t.Fatalf("count records: %v", err)
|
||||||
|
}
|
||||||
|
if recordCount != 1 {
|
||||||
|
t.Fatalf("record count = %d, want 1", recordCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawIdempotencyRejectsDifferentRequest(t *testing.T) {
|
||||||
|
service, gateway, db := newWheelDrawTestService(t)
|
||||||
|
seedWheelDrawConfig(t, db, 0, 0)
|
||||||
|
|
||||||
|
_, err := service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 1, IdempotencyKey: "wheel-h5-key-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw() first error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = service.Draw(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, DrawRequest{Category: categoryClassic, Times: 10, IdempotencyKey: "wheel-h5-key-2"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Draw() second expected idempotency conflict")
|
||||||
|
}
|
||||||
|
if len(gateway.changes) != 2 {
|
||||||
|
t.Fatalf("wallet changes = %d, want only first pay+reward", len(gateway.changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWheelDrawTestService(t *testing.T) (*Service, *fakeWheelGateway, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.WheelConfig{}, &model.WheelPoolConfig{}, &model.WheelRewardConfig{}, &model.WheelDrawRecord{}, &model.ActivityIdempotency{}); err != nil {
|
||||||
|
t.Fatalf("migrate sqlite: %v", err)
|
||||||
|
}
|
||||||
|
gateway := &fakeWheelGateway{
|
||||||
|
events: map[string]struct{}{},
|
||||||
|
room: integration.RoomProfile{
|
||||||
|
ID: integration.Int64Value(9001),
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return NewService(config.Config{}, db, gateway), gateway, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedWheelDrawConfig(t *testing.T, db *gorm.DB, totalLimit int64, userLimit int64) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
configRow := model.WheelConfig{ID: 1, SysOrigin: "LIKEI", Enabled: true, Timezone: defaultTimezone, CreateTime: now, UpdateTime: now}
|
||||||
|
pool := model.WheelPoolConfig{
|
||||||
|
ID: 2,
|
||||||
|
ConfigID: 1,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Category: categoryClassic,
|
||||||
|
Enabled: true,
|
||||||
|
PriceOneGold: 1,
|
||||||
|
PriceTenGold: 10,
|
||||||
|
PriceFiftyGold: 50,
|
||||||
|
Sort: 1,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
rewards := make([]model.WheelRewardConfig, 0, 8)
|
||||||
|
for index := 0; index < 8; index++ {
|
||||||
|
probability := 0
|
||||||
|
limit := int64(0)
|
||||||
|
perUserLimit := int64(0)
|
||||||
|
if index == 0 {
|
||||||
|
probability = probabilityTotal
|
||||||
|
limit = totalLimit
|
||||||
|
perUserLimit = userLimit
|
||||||
|
}
|
||||||
|
rewards = append(rewards, model.WheelRewardConfig{
|
||||||
|
ID: int64(100 + index),
|
||||||
|
ConfigID: 1,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Category: categoryClassic,
|
||||||
|
RewardType: rewardTypeGold,
|
||||||
|
GoldAmount: 1,
|
||||||
|
Probability: probability,
|
||||||
|
TotalLimit: limit,
|
||||||
|
UserLimit: perUserLimit,
|
||||||
|
Sort: index + 1,
|
||||||
|
Enabled: true,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := db.Create(&configRow).Error; err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&pool).Error; err != nil {
|
||||||
|
t.Fatalf("seed pool: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&rewards).Error; err != nil {
|
||||||
|
t.Fatalf("seed rewards: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
internal/service/wheel/locking.go
Normal file
18
internal/service/wheel/locking.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package wheel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withWriteLock(db *gorm.DB) *gorm.DB {
|
||||||
|
if db == nil || db.Dialector == nil {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "mysql", "postgres", "sqlserver":
|
||||||
|
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
default:
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/service/prizepool"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPickRewardDistribution1000000Draws(t *testing.T) {
|
func TestPickRewardDistribution1000000Draws(t *testing.T) {
|
||||||
@ -13,43 +13,58 @@ func TestPickRewardDistribution1000000Draws(t *testing.T) {
|
|||||||
maxAllowedDriftPercent = 1.0
|
maxAllowedDriftPercent = 1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
rewards := []model.WheelRewardConfig{
|
rewards := []struct {
|
||||||
{ID: 1, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Coral Crown", Probability: 3000},
|
id int64
|
||||||
{ID: 2, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Aurora Wing", Probability: 2200},
|
name string
|
||||||
{ID: 3, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Moon Card", Probability: 1500},
|
probability int
|
||||||
{ID: 4, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Snow Pet", Probability: 1200},
|
}{
|
||||||
{ID: 5, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Love Banner", Probability: 900},
|
{id: 1, name: "Coral Crown", probability: 300000},
|
||||||
{ID: 6, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Emerald Ring", Probability: 600},
|
{id: 2, name: "Aurora Wing", probability: 220000},
|
||||||
{ID: 7, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Royal Chest", Probability: 400},
|
{id: 3, name: "Moon Card", probability: 150000},
|
||||||
{ID: 8, Enabled: true, RewardType: rewardTypeGold, ResourceName: "Gold", Probability: 200},
|
{id: 4, name: "Snow Pet", probability: 120000},
|
||||||
|
{id: 5, name: "Love Banner", probability: 90000},
|
||||||
|
{id: 6, name: "Emerald Ring", probability: 60000},
|
||||||
|
{id: 7, name: "Royal Chest", probability: 40000},
|
||||||
|
{id: 8, name: "Gold", probability: 20000},
|
||||||
|
}
|
||||||
|
items := make([]prizepool.PickResult, 0, len(rewards))
|
||||||
|
for index, reward := range rewards {
|
||||||
|
items = append(items, prizepool.PickResult{
|
||||||
|
Item: prizepool.Item{
|
||||||
|
ID: reward.id,
|
||||||
|
Enabled: true,
|
||||||
|
Weight: reward.probability,
|
||||||
|
},
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
counts := make(map[int64]int, len(rewards))
|
counts := make(map[int64]int, len(rewards))
|
||||||
for index := 0; index < draws; index++ {
|
for index := 0; index < draws; index++ {
|
||||||
reward, err := pickReward(rewards)
|
reward, err := prizepool.PickWeighted(items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pickReward() error = %v", err)
|
t.Fatalf("PickWeighted() error = %v", err)
|
||||||
}
|
}
|
||||||
counts[reward.ID]++
|
counts[reward.Item.ID]++
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, reward := range rewards {
|
for _, reward := range rewards {
|
||||||
expectedPercent := float64(reward.Probability) / probabilityTotal * 100
|
expectedPercent := float64(reward.probability) / probabilityTotal * 100
|
||||||
actualPercent := float64(counts[reward.ID]) / draws * 100
|
actualPercent := float64(counts[reward.id]) / draws * 100
|
||||||
driftPercent := math.Abs(actualPercent - expectedPercent)
|
driftPercent := math.Abs(actualPercent - expectedPercent)
|
||||||
t.Logf(
|
t.Logf(
|
||||||
"reward=%s configured=%.2f%% actual=%.2f%% drift=%.2f%% hits=%d/%d",
|
"reward=%s configured=%.2f%% actual=%.2f%% drift=%.2f%% hits=%d/%d",
|
||||||
reward.ResourceName,
|
reward.name,
|
||||||
expectedPercent,
|
expectedPercent,
|
||||||
actualPercent,
|
actualPercent,
|
||||||
driftPercent,
|
driftPercent,
|
||||||
counts[reward.ID],
|
counts[reward.id],
|
||||||
draws,
|
draws,
|
||||||
)
|
)
|
||||||
if driftPercent > maxAllowedDriftPercent {
|
if driftPercent > maxAllowedDriftPercent {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"reward %s probability drift %.2f%% exceeds %.2f%%",
|
"reward %s probability drift %.2f%% exceeds %.2f%%",
|
||||||
reward.ResourceName,
|
reward.name,
|
||||||
driftPercent,
|
driftPercent,
|
||||||
maxAllowedDriftPercent,
|
maxAllowedDriftPercent,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -101,6 +101,9 @@ func (s *Service) Hints(ctx context.Context, user AuthUser, category string, lim
|
|||||||
DisplayGoldAmount: row.DisplayGoldAmount,
|
DisplayGoldAmount: row.DisplayGoldAmount,
|
||||||
GoldAmount: row.GoldAmount,
|
GoldAmount: row.GoldAmount,
|
||||||
Probability: row.Probability,
|
Probability: row.Probability,
|
||||||
|
TotalLimit: 0,
|
||||||
|
UserLimit: 0,
|
||||||
|
IssuedCount: 0,
|
||||||
Count: 1,
|
Count: 1,
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: displayValueForRecord(row),
|
Value: displayValueForRecord(row),
|
||||||
@ -131,6 +134,7 @@ func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload {
|
|||||||
Category: row.Category,
|
Category: row.Category,
|
||||||
DrawTimes: row.DrawTimes,
|
DrawTimes: row.DrawTimes,
|
||||||
PaidGold: row.PaidGold,
|
PaidGold: row.PaidGold,
|
||||||
|
RewardConfigID: row.RewardConfigID,
|
||||||
RewardType: row.RewardType,
|
RewardType: row.RewardType,
|
||||||
ResourceID: resourceIDValue(row.ResourceID),
|
ResourceID: resourceIDValue(row.ResourceID),
|
||||||
ResourceType: row.ResourceType,
|
ResourceType: row.ResourceType,
|
||||||
@ -142,7 +146,9 @@ func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload {
|
|||||||
GoldAmount: row.GoldAmount,
|
GoldAmount: row.GoldAmount,
|
||||||
Probability: row.Probability,
|
Probability: row.Probability,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
|
RetryCount: row.RetryCount,
|
||||||
ErrorMessage: row.ErrorMessage,
|
ErrorMessage: row.ErrorMessage,
|
||||||
|
GrantTime: formatOptionalDateTime(row.GrantTime),
|
||||||
CreateTime: formatDateTime(row.CreateTime),
|
CreateTime: formatDateTime(row.CreateTime),
|
||||||
UpdateTime: formatDateTime(row.UpdateTime),
|
UpdateTime: formatDateTime(row.UpdateTime),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
probabilityTotal = 10000
|
probabilityTotal = 1000000
|
||||||
|
|
||||||
defaultTimezone = "Asia/Riyadh"
|
defaultTimezone = "Asia/Riyadh"
|
||||||
|
|
||||||
@ -33,9 +33,11 @@ const (
|
|||||||
resourceTypeRoomBadge = "ROOM_BADGE"
|
resourceTypeRoomBadge = "ROOM_BADGE"
|
||||||
resourceTypeGift = "GIFT"
|
resourceTypeGift = "GIFT"
|
||||||
|
|
||||||
drawStatusPending = "PENDING"
|
drawStatusPending = "PENDING"
|
||||||
drawStatusSuccess = "SUCCESS"
|
drawStatusGranting = "GRANTING"
|
||||||
drawStatusFailed = "FAILED"
|
drawStatusSuccess = "SUCCESS"
|
||||||
|
drawStatusFailed = "FAILED"
|
||||||
|
drawStatusPayFailed = "PAY_FAILED"
|
||||||
|
|
||||||
walletReceiptIncome = "INCOME"
|
walletReceiptIncome = "INCOME"
|
||||||
walletReceiptExpenditure = "EXPENDITURE"
|
walletReceiptExpenditure = "EXPENDITURE"
|
||||||
@ -99,6 +101,7 @@ type wheelDB interface {
|
|||||||
|
|
||||||
type wheelGateway interface {
|
type wheelGateway interface {
|
||||||
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
|
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
|
||||||
|
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
|
||||||
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
||||||
IncrGiftBackpack(ctx context.Context, userID int64, giftID int64, quantity int) error
|
IncrGiftBackpack(ctx context.Context, userID int64, giftID int64, quantity int) error
|
||||||
SwitchUseProps(ctx context.Context, userID int64, propsID int64) error
|
SwitchUseProps(ctx context.Context, userID int64, propsID int64) error
|
||||||
@ -171,6 +174,8 @@ type RewardConfigInput struct {
|
|||||||
DisplayGoldAmount int64 `json:"displayGoldAmount"`
|
DisplayGoldAmount int64 `json:"displayGoldAmount"`
|
||||||
GoldAmount int64 `json:"goldAmount"`
|
GoldAmount int64 `json:"goldAmount"`
|
||||||
Probability int `json:"probability"`
|
Probability int `json:"probability"`
|
||||||
|
TotalLimit int64 `json:"totalLimit"`
|
||||||
|
UserLimit int64 `json:"userLimit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigResponse is returned to admin and app config readers.
|
// ConfigResponse is returned to admin and app config readers.
|
||||||
@ -214,12 +219,20 @@ type RewardConfigPayload struct {
|
|||||||
GoldAmount int64 `json:"goldAmount,omitempty"`
|
GoldAmount int64 `json:"goldAmount,omitempty"`
|
||||||
Probability int `json:"probability"`
|
Probability int `json:"probability"`
|
||||||
ProbabilityPercent string `json:"probabilityPercent"`
|
ProbabilityPercent string `json:"probabilityPercent"`
|
||||||
|
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||||
|
UserLimit int64 `json:"userLimit,omitempty"`
|
||||||
|
IssuedCount int64 `json:"issuedCount,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrawRequest is an app draw request.
|
// DrawRequest is an app draw request.
|
||||||
type DrawRequest struct {
|
type DrawRequest struct {
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Times int `json:"times"`
|
Times int `json:"times"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
IdempotencyKeySnake string `json:"idempotency_key,omitempty"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
RequestIDSnake string `json:"request_id,omitempty"`
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrawResponse is the app draw result.
|
// DrawResponse is the app draw result.
|
||||||
@ -235,6 +248,8 @@ type DrawResponse struct {
|
|||||||
Rewards []DrawRewardPayload `json:"rewards"`
|
Rewards []DrawRewardPayload `json:"rewards"`
|
||||||
Records []DrawRecordPayload `json:"records"`
|
Records []DrawRecordPayload `json:"records"`
|
||||||
BalanceAfter int64 `json:"balanceAfter,omitempty"`
|
BalanceAfter int64 `json:"balanceAfter,omitempty"`
|
||||||
|
GrantFailed bool `json:"grantFailed,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrawRewardPayload aggregates identical rewards in one draw response.
|
// DrawRewardPayload aggregates identical rewards in one draw response.
|
||||||
@ -249,6 +264,9 @@ type DrawRewardPayload struct {
|
|||||||
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
|
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
|
||||||
GoldAmount int64 `json:"goldAmount,omitempty"`
|
GoldAmount int64 `json:"goldAmount,omitempty"`
|
||||||
Probability int `json:"probability,omitempty"`
|
Probability int `json:"probability,omitempty"`
|
||||||
|
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||||
|
UserLimit int64 `json:"userLimit,omitempty"`
|
||||||
|
IssuedCount int64 `json:"issuedCount,omitempty"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Value int64 `json:"value,omitempty"`
|
Value int64 `json:"value,omitempty"`
|
||||||
@ -264,6 +282,7 @@ type DrawRecordPayload struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
DrawTimes int `json:"drawTimes"`
|
DrawTimes int `json:"drawTimes"`
|
||||||
PaidGold int64 `json:"paidGold"`
|
PaidGold int64 `json:"paidGold"`
|
||||||
|
RewardConfigID int64 `json:"rewardConfigId,omitempty"`
|
||||||
RewardType string `json:"rewardType"`
|
RewardType string `json:"rewardType"`
|
||||||
ResourceID ResourceID `json:"resourceId,omitempty"`
|
ResourceID ResourceID `json:"resourceId,omitempty"`
|
||||||
ResourceType string `json:"resourceType,omitempty"`
|
ResourceType string `json:"resourceType,omitempty"`
|
||||||
@ -275,7 +294,9 @@ type DrawRecordPayload struct {
|
|||||||
GoldAmount int64 `json:"goldAmount,omitempty"`
|
GoldAmount int64 `json:"goldAmount,omitempty"`
|
||||||
Probability int `json:"probability"`
|
Probability int `json:"probability"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
RetryCount int `json:"retryCount,omitempty"`
|
||||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
GrantTime string `json:"grantTime,omitempty"`
|
||||||
CreateTime string `json:"createTime,omitempty"`
|
CreateTime string `json:"createTime,omitempty"`
|
||||||
UpdateTime string `json:"updateTime,omitempty"`
|
UpdateTime string `json:"updateTime,omitempty"`
|
||||||
}
|
}
|
||||||
@ -349,6 +370,13 @@ func formatDateTime(t time.Time) string {
|
|||||||
return t.Format("2006-01-02 15:04:05")
|
return t.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatOptionalDateTime(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatDateTime(*t)
|
||||||
|
}
|
||||||
|
|
||||||
func trimErrorMessage(message string) string {
|
func trimErrorMessage(message string) string {
|
||||||
message = strings.TrimSpace(message)
|
message = strings.TrimSpace(message)
|
||||||
if len(message) <= 1024 {
|
if len(message) <= 1024 {
|
||||||
@ -385,7 +413,7 @@ func probabilityPercent(probability int) string {
|
|||||||
if probability <= 0 {
|
if probability <= 0 {
|
||||||
return "0.00"
|
return "0.00"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%.2f", float64(probability)/100)
|
return fmt.Sprintf("%.4f", float64(probability)*100/float64(probabilityTotal))
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeDrawTimes(times int) (int, error) {
|
func normalizeDrawTimes(times int) (int, error) {
|
||||||
|
|||||||
@ -40,7 +40,10 @@ CREATE TABLE IF NOT EXISTS `wheel_reward_config` (
|
|||||||
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数',
|
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数',
|
||||||
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格',
|
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格',
|
||||||
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量',
|
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量',
|
||||||
`probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率,万分比',
|
`probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率,百万分比,100%=1000000',
|
||||||
|
`total_limit` bigint NOT NULL DEFAULT '0' COMMENT '奖品总发放上限,0表示不限',
|
||||||
|
`user_limit` bigint NOT NULL DEFAULT '0' COMMENT '单用户发放上限,0表示不限',
|
||||||
|
`issued_count` bigint NOT NULL DEFAULT '0' COMMENT '已占用发放数量,PENDING/SUCCESS/FAILED发奖占用',
|
||||||
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
|
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
|
||||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
||||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||||
@ -59,6 +62,7 @@ CREATE TABLE IF NOT EXISTS `wheel_draw_record` (
|
|||||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||||
`draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数',
|
`draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数',
|
||||||
`paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币',
|
`paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币',
|
||||||
|
`reward_config_id` bigint NOT NULL DEFAULT '0' COMMENT '命中奖项配置ID',
|
||||||
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD',
|
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD',
|
||||||
`resource_id` bigint DEFAULT NULL COMMENT '资源ID快照',
|
`resource_id` bigint DEFAULT NULL COMMENT '资源ID快照',
|
||||||
`resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型快照',
|
`resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型快照',
|
||||||
@ -68,15 +72,18 @@ CREATE TABLE IF NOT EXISTS `wheel_draw_record` (
|
|||||||
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数快照',
|
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数快照',
|
||||||
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格快照',
|
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格快照',
|
||||||
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照',
|
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照',
|
||||||
`probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,万分比',
|
`probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,百万分比,100%=1000000',
|
||||||
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED',
|
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/GRANTING/SUCCESS/FAILED/PAY_FAILED',
|
||||||
|
`retry_count` int NOT NULL DEFAULT '0' COMMENT '发奖尝试次数',
|
||||||
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
|
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
`grant_time` datetime(3) DEFAULT NULL COMMENT '发奖成功时间',
|
||||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_wheel_draw_record_event` (`event_id`),
|
UNIQUE KEY `uk_wheel_draw_record_event` (`event_id`),
|
||||||
KEY `idx_wheel_draw_record_draw_no` (`draw_no`),
|
KEY `idx_wheel_draw_record_draw_no` (`draw_no`),
|
||||||
KEY `idx_wheel_draw_record_category` (`category`),
|
KEY `idx_wheel_draw_record_category` (`category`),
|
||||||
|
KEY `idx_wheel_draw_reward_user` (`reward_config_id`, `user_id`, `status`),
|
||||||
KEY `idx_wheel_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`),
|
KEY `idx_wheel_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`),
|
||||||
KEY `idx_wheel_draw_record_status` (`status`)
|
KEY `idx_wheel_draw_record_status` (`status`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘抽奖记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘抽奖记录';
|
||||||
|
|||||||
116
migrations/045_wheel_limits_probability_retry.sql
Normal file
116
migrations/045_wheel_limits_probability_retry.sql
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
SET @current_schema := DATABASE();
|
||||||
|
|
||||||
|
SET @add_wheel_reward_total_limit_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_reward_config'
|
||||||
|
AND column_name = 'total_limit'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_reward_config` ADD COLUMN `total_limit` bigint NOT NULL DEFAULT 0 COMMENT ''奖品总发放上限,0表示不限'' AFTER `probability`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_reward_total_limit_stmt FROM @add_wheel_reward_total_limit_sql;
|
||||||
|
EXECUTE add_wheel_reward_total_limit_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_reward_total_limit_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_reward_user_limit_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_reward_config'
|
||||||
|
AND column_name = 'user_limit'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_reward_config` ADD COLUMN `user_limit` bigint NOT NULL DEFAULT 0 COMMENT ''单用户发放上限,0表示不限'' AFTER `total_limit`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_reward_user_limit_stmt FROM @add_wheel_reward_user_limit_sql;
|
||||||
|
EXECUTE add_wheel_reward_user_limit_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_reward_user_limit_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_reward_issued_count_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_reward_config'
|
||||||
|
AND column_name = 'issued_count'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_reward_config` ADD COLUMN `issued_count` bigint NOT NULL DEFAULT 0 COMMENT ''已占用发放数量,PENDING/SUCCESS/FAILED发奖占用'' AFTER `user_limit`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_reward_issued_count_stmt FROM @add_wheel_reward_issued_count_sql;
|
||||||
|
EXECUTE add_wheel_reward_issued_count_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_reward_issued_count_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_draw_reward_config_id_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_draw_record'
|
||||||
|
AND column_name = 'reward_config_id'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_draw_record` ADD COLUMN `reward_config_id` bigint NOT NULL DEFAULT 0 COMMENT ''命中奖项配置ID'' AFTER `paid_gold`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_draw_reward_config_id_stmt FROM @add_wheel_draw_reward_config_id_sql;
|
||||||
|
EXECUTE add_wheel_draw_reward_config_id_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_draw_reward_config_id_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_draw_retry_count_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_draw_record'
|
||||||
|
AND column_name = 'retry_count'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_draw_record` ADD COLUMN `retry_count` int NOT NULL DEFAULT 0 COMMENT ''发奖尝试次数'' AFTER `status`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_draw_retry_count_stmt FROM @add_wheel_draw_retry_count_sql;
|
||||||
|
EXECUTE add_wheel_draw_retry_count_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_draw_retry_count_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_draw_grant_time_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_draw_record'
|
||||||
|
AND column_name = 'grant_time'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_draw_record` ADD COLUMN `grant_time` datetime(3) DEFAULT NULL COMMENT ''发奖成功时间'' AFTER `error_message`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_draw_grant_time_stmt FROM @add_wheel_draw_grant_time_sql;
|
||||||
|
EXECUTE add_wheel_draw_grant_time_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_draw_grant_time_stmt;
|
||||||
|
|
||||||
|
SET @add_wheel_draw_reward_user_idx_sql := IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @current_schema
|
||||||
|
AND table_name = 'wheel_draw_record'
|
||||||
|
AND index_name = 'idx_wheel_draw_reward_user'
|
||||||
|
),
|
||||||
|
'ALTER TABLE `wheel_draw_record` ADD INDEX `idx_wheel_draw_reward_user` (`reward_config_id`, `user_id`, `status`)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_wheel_draw_reward_user_idx_stmt FROM @add_wheel_draw_reward_user_idx_sql;
|
||||||
|
EXECUTE add_wheel_draw_reward_user_idx_stmt;
|
||||||
|
DEALLOCATE PREPARE add_wheel_draw_reward_user_idx_stmt;
|
||||||
|
|
||||||
|
SET @wheel_reward_probability_max := (
|
||||||
|
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_reward_config`
|
||||||
|
);
|
||||||
|
SET @wheel_record_probability_max := (
|
||||||
|
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_draw_record`
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE `wheel_reward_config`
|
||||||
|
SET `probability` = `probability` * 100
|
||||||
|
WHERE @wheel_reward_probability_max > 0
|
||||||
|
AND @wheel_reward_probability_max <= 10000;
|
||||||
|
|
||||||
|
UPDATE `wheel_draw_record`
|
||||||
|
SET `probability` = `probability` * 100
|
||||||
|
WHERE @wheel_record_probability_max > 0
|
||||||
|
AND @wheel_record_probability_max <= 10000;
|
||||||
18
migrations/046_activity_idempotency.sql
Normal file
18
migrations/046_activity_idempotency.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `activity_idempotency` (
|
||||||
|
`id` bigint NOT NULL COMMENT '主键ID',
|
||||||
|
`activity` varchar(64) NOT NULL COMMENT '活动标识',
|
||||||
|
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||||
|
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||||
|
`idempotency_key` varchar(128) NOT NULL COMMENT '客户端幂等键',
|
||||||
|
`request_hash` varchar(64) NOT NULL COMMENT '请求参数hash',
|
||||||
|
`business_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务单号',
|
||||||
|
`status` varchar(32) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCEEDED/FAILED',
|
||||||
|
`response_json` longtext COMMENT '成功响应快照',
|
||||||
|
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
`create_time` datetime(3) NOT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime(3) NOT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_activity_idempotency_key` (`activity`, `sys_origin`, `user_id`, `idempotency_key`),
|
||||||
|
KEY `idx_activity_idempotency_business` (`business_no`),
|
||||||
|
KEY `idx_activity_idempotency_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动付费请求幂等表';
|
||||||
541
scripts/wheel_local_verify.go
Normal file
541
scripts/wheel_local_verify.go
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/wheel"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
localSysOrigin = "LOCAL_WHEEL_VERIFY"
|
||||||
|
localCategory = "CLASSIC"
|
||||||
|
localTimezone = "Asia/Riyadh"
|
||||||
|
localUserID = int64(2042274349343506434)
|
||||||
|
localOtherUserID = int64(2042274349343506435)
|
||||||
|
localRoomID = int64(9001001)
|
||||||
|
probabilityTotal = 1000000
|
||||||
|
receiptIncome = "INCOME"
|
||||||
|
receiptExpense = "EXPENDITURE"
|
||||||
|
statusSuccess = "SUCCESS"
|
||||||
|
statusFailed = "FAILED"
|
||||||
|
statusPayFailed = "PAY_FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type localWheelGateway struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events map[string]integration.GoldReceiptCommand
|
||||||
|
changes []integration.GoldReceiptCommand
|
||||||
|
balance map[int64]int64
|
||||||
|
failNextIncome bool
|
||||||
|
failNextExpenditure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifySummary struct {
|
||||||
|
DSNHost string `json:"dsnHost"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
OtherUserID int64 `json:"otherUserId,string"`
|
||||||
|
ProbabilityTotal int `json:"probabilityTotal"`
|
||||||
|
HighRewardConfigID int64 `json:"highRewardConfigId,string"`
|
||||||
|
HighRewardProbability int `json:"highRewardProbability"`
|
||||||
|
ProbabilityPercent string `json:"probabilityPercent"`
|
||||||
|
PayFailedRecords int64 `json:"payFailedRecords"`
|
||||||
|
FailedDrawNo string `json:"failedDrawNo"`
|
||||||
|
FailedRecordID int64 `json:"failedRecordId,string"`
|
||||||
|
FailedRecordStatus string `json:"failedRecordStatus"`
|
||||||
|
GrantFailedInDraw bool `json:"grantFailedInDraw"`
|
||||||
|
RetryStatus string `json:"retryStatus"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
FallbackDrawNo string `json:"fallbackDrawNo"`
|
||||||
|
FallbackRecordID int64 `json:"fallbackRecordId,string"`
|
||||||
|
FallbackRewardConfigID int64 `json:"fallbackRewardConfigId,string"`
|
||||||
|
FallbackGoldAmount int64 `json:"fallbackGoldAmount"`
|
||||||
|
FallbackStatus string `json:"fallbackStatus"`
|
||||||
|
FallbackGrantFailed bool `json:"fallbackGrantFailed"`
|
||||||
|
HighRewardIssuedCount int64 `json:"highRewardIssuedCount"`
|
||||||
|
FallbackIssuedCount int64 `json:"fallbackIssuedCount"`
|
||||||
|
PaymentEvents int `json:"paymentEvents"`
|
||||||
|
IncomeEvents int `json:"incomeEvents"`
|
||||||
|
RecordPageTotal int64 `json:"recordPageTotal"`
|
||||||
|
UserBalance int64 `json:"userBalance"`
|
||||||
|
OtherUserBalance int64 `json:"otherUserBalance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
if g.events == nil {
|
||||||
|
g.events = map[string]integration.GoldReceiptCommand{}
|
||||||
|
}
|
||||||
|
if g.balance == nil {
|
||||||
|
g.balance = map[int64]int64{}
|
||||||
|
}
|
||||||
|
if _, exists := g.events[cmd.EventID]; exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(cmd.ReceiptType) {
|
||||||
|
case receiptExpense:
|
||||||
|
if g.failNextExpenditure {
|
||||||
|
g.failNextExpenditure = false
|
||||||
|
return errors.New("local simulated payment failure")
|
||||||
|
}
|
||||||
|
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
|
||||||
|
case receiptIncome:
|
||||||
|
if g.failNextIncome {
|
||||||
|
g.failNextIncome = false
|
||||||
|
return errors.New("local simulated grant failure")
|
||||||
|
}
|
||||||
|
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
g.events[cmd.EventID] = cmd
|
||||||
|
g.changes = append(g.changes, cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
_, exists := g.events[eventID]
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) SwitchUseProps(context.Context, int64, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
result := make(map[int64]int64, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
result[userID] = g.balance[userID]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
|
||||||
|
return integration.RoomProfile{
|
||||||
|
ID: integration.Int64Value(localRoomID),
|
||||||
|
UserID: integration.Int64Value(userID),
|
||||||
|
RoomName: "local-wheel-verify-room",
|
||||||
|
SysOrigin: localSysOrigin,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *localWheelGateway) countReceipt(receiptType string) int {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
count := 0
|
||||||
|
for _, change := range g.changes {
|
||||||
|
if strings.EqualFold(change.ReceiptType, receiptType) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, dsn, err := connectMySQL(ctx)
|
||||||
|
must(err, "connect mysql")
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
must(err, "unwrap mysql")
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
must(ensureWheelSchema(db), "ensure wheel schema")
|
||||||
|
must(cleanup(ctx, db, localSysOrigin), "cleanup local wheel data")
|
||||||
|
|
||||||
|
gateway := &localWheelGateway{
|
||||||
|
events: map[string]integration.GoldReceiptCommand{},
|
||||||
|
balance: map[int64]int64{localUserID: 1000, localOtherUserID: 1000},
|
||||||
|
}
|
||||||
|
service := wheel.NewService(config.Config{}, db, gateway)
|
||||||
|
|
||||||
|
saved, err := seedConfig(ctx, service)
|
||||||
|
must(err, "save wheel config")
|
||||||
|
assert(saved.ProbabilityTotal == probabilityTotal, "probability total mismatch")
|
||||||
|
|
||||||
|
reward, err := makeDrawDeterministic(ctx, db)
|
||||||
|
must(err, "make deterministic reward")
|
||||||
|
assert(reward.Probability == probabilityTotal && reward.TotalLimit == 1 && reward.UserLimit == 1, "deterministic reward mismatch")
|
||||||
|
|
||||||
|
configAfterSeed, err := service.GetConfig(ctx, localSysOrigin)
|
||||||
|
must(err, "get wheel config")
|
||||||
|
firstPayload := firstCategoryReward(configAfterSeed)
|
||||||
|
assert(firstPayload.Probability == probabilityTotal, "config probability mismatch")
|
||||||
|
|
||||||
|
gateway.failNextExpenditure = true
|
||||||
|
_, payErr := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||||
|
Category: localCategory,
|
||||||
|
Times: 1,
|
||||||
|
})
|
||||||
|
assert(payErr != nil, "payment failure draw should fail")
|
||||||
|
assert(appErrorCode(payErr) == "wheel_wallet_deduct_failed", fmt.Sprintf("unexpected payment error code: %s", appErrorCode(payErr)))
|
||||||
|
payFailedCount := countRecords(ctx, db, statusPayFailed)
|
||||||
|
assert(payFailedCount == 1, fmt.Sprintf("pay failed records = %d, want 1", payFailedCount))
|
||||||
|
rewardAfterPayFailure := loadReward(ctx, db, reward.ID)
|
||||||
|
assert(rewardAfterPayFailure.IssuedCount == 0, fmt.Sprintf("issued count after pay failure = %d, want 0", rewardAfterPayFailure.IssuedCount))
|
||||||
|
|
||||||
|
gateway.failNextIncome = true
|
||||||
|
drawResp, err := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||||
|
Category: localCategory,
|
||||||
|
Times: 1,
|
||||||
|
})
|
||||||
|
must(err, "draw with simulated grant failure")
|
||||||
|
assert(drawResp.GrantFailed, "draw should expose grantFailed")
|
||||||
|
assert(len(drawResp.Records) == 1, "draw record count mismatch")
|
||||||
|
failedRecord := loadRecord(ctx, db, drawResp.Records[0].ID)
|
||||||
|
assert(failedRecord.Status == statusFailed, fmt.Sprintf("failed record status = %s", failedRecord.Status))
|
||||||
|
assert(gateway.countReceipt(receiptExpense) == 1, "payment should be charged once after grant failure")
|
||||||
|
assert(gateway.countReceipt(receiptIncome) == 0, "income should not be granted before retry")
|
||||||
|
|
||||||
|
must(service.RetryDrawRecord(ctx, failedRecord.ID), "retry failed reward")
|
||||||
|
retriedRecord := loadRecord(ctx, db, failedRecord.ID)
|
||||||
|
assert(retriedRecord.Status == statusSuccess, fmt.Sprintf("retry status = %s", retriedRecord.Status))
|
||||||
|
assert(gateway.countReceipt(receiptExpense) == 1, "retry should not charge again")
|
||||||
|
assert(gateway.countReceipt(receiptIncome) == 1, "retry should grant reward once")
|
||||||
|
|
||||||
|
fallbackReward, err := enableFallbackReward(ctx, db)
|
||||||
|
must(err, "enable fallback reward")
|
||||||
|
fallbackResp, err := service.Draw(ctx, common.AuthUser{UserID: localOtherUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||||
|
Category: localCategory,
|
||||||
|
Times: 1,
|
||||||
|
})
|
||||||
|
must(err, "draw after high reward exhausted")
|
||||||
|
assert(!fallbackResp.GrantFailed, "fallback draw should be user-transparent")
|
||||||
|
assert(len(fallbackResp.Records) == 1, "fallback draw record count mismatch")
|
||||||
|
fallbackRecord := loadRecord(ctx, db, fallbackResp.Records[0].ID)
|
||||||
|
assert(fallbackRecord.Status == statusSuccess, fmt.Sprintf("fallback record status = %s", fallbackRecord.Status))
|
||||||
|
assert(fallbackRecord.RewardConfigID == fallbackReward.ID, "fallback draw did not hit fallback reward")
|
||||||
|
assert(fallbackRecord.RewardConfigID != reward.ID, "fallback draw hit exhausted high reward")
|
||||||
|
assert(gateway.countReceipt(receiptExpense) == 2, "fallback draw should charge once")
|
||||||
|
assert(gateway.countReceipt(receiptIncome) == 2, "fallback draw should grant reward once")
|
||||||
|
|
||||||
|
finalReward := loadReward(ctx, db, reward.ID)
|
||||||
|
finalFallbackReward := loadReward(ctx, db, fallbackReward.ID)
|
||||||
|
assert(finalReward.IssuedCount == 1, fmt.Sprintf("high reward issued count = %d, want 1", finalReward.IssuedCount))
|
||||||
|
assert(finalFallbackReward.IssuedCount == 1, fmt.Sprintf("fallback issued count = %d, want 1", finalFallbackReward.IssuedCount))
|
||||||
|
page, err := service.PageRecords(ctx, localSysOrigin, localCategory, 0, "", 1, 20)
|
||||||
|
must(err, "page records")
|
||||||
|
balances, err := gateway.MapGoldBalance(ctx, []int64{localUserID, localOtherUserID})
|
||||||
|
must(err, "map balance")
|
||||||
|
|
||||||
|
summary := verifySummary{
|
||||||
|
DSNHost: dsnLabel(dsn),
|
||||||
|
SysOrigin: localSysOrigin,
|
||||||
|
UserID: localUserID,
|
||||||
|
OtherUserID: localOtherUserID,
|
||||||
|
ProbabilityTotal: configAfterSeed.ProbabilityTotal,
|
||||||
|
HighRewardConfigID: finalReward.ID,
|
||||||
|
HighRewardProbability: firstPayload.Probability,
|
||||||
|
ProbabilityPercent: firstPayload.ProbabilityPercent,
|
||||||
|
PayFailedRecords: payFailedCount,
|
||||||
|
FailedDrawNo: drawResp.DrawNo,
|
||||||
|
FailedRecordID: failedRecord.ID,
|
||||||
|
FailedRecordStatus: failedRecord.Status,
|
||||||
|
GrantFailedInDraw: drawResp.GrantFailed,
|
||||||
|
RetryStatus: retriedRecord.Status,
|
||||||
|
RetryCount: retriedRecord.RetryCount,
|
||||||
|
FallbackDrawNo: fallbackResp.DrawNo,
|
||||||
|
FallbackRecordID: fallbackRecord.ID,
|
||||||
|
FallbackRewardConfigID: fallbackRecord.RewardConfigID,
|
||||||
|
FallbackGoldAmount: fallbackRecord.GoldAmount,
|
||||||
|
FallbackStatus: fallbackRecord.Status,
|
||||||
|
FallbackGrantFailed: fallbackResp.GrantFailed,
|
||||||
|
HighRewardIssuedCount: finalReward.IssuedCount,
|
||||||
|
FallbackIssuedCount: finalFallbackReward.IssuedCount,
|
||||||
|
PaymentEvents: gateway.countReceipt(receiptExpense),
|
||||||
|
IncomeEvents: gateway.countReceipt(receiptIncome),
|
||||||
|
RecordPageTotal: page.Total,
|
||||||
|
UserBalance: balances[localUserID],
|
||||||
|
OtherUserBalance: balances[localOtherUserID],
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(summary, "", " ")
|
||||||
|
must(err, "marshal summary")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
|
||||||
|
candidates := uniqueStrings([]string{
|
||||||
|
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
|
||||||
|
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
|
||||||
|
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
})
|
||||||
|
var errs []string
|
||||||
|
for _, dsn := range candidates {
|
||||||
|
if dsn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return db, dsn, nil
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureWheelSchema(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(
|
||||||
|
&model.WheelConfig{},
|
||||||
|
&model.WheelPoolConfig{},
|
||||||
|
&model.WheelRewardConfig{},
|
||||||
|
&model.WheelDrawRecord{},
|
||||||
|
&model.ActivityIdempotency{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
|
||||||
|
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var configIDs []int64
|
||||||
|
if err := tx.Model(&model.WheelConfig{}).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
Pluck("id", &configIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(configIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedConfig(ctx context.Context, service *wheel.Service) (*wheel.ConfigResponse, error) {
|
||||||
|
probabilities := []int{999993, 1, 1, 1, 1, 1, 1, 1}
|
||||||
|
rewards := make([]wheel.RewardConfigInput, 0, len(probabilities))
|
||||||
|
for index, probability := range probabilities {
|
||||||
|
totalLimit := int64(0)
|
||||||
|
userLimit := int64(0)
|
||||||
|
if index == 0 {
|
||||||
|
totalLimit = 1
|
||||||
|
userLimit = 1
|
||||||
|
}
|
||||||
|
rewards = append(rewards, wheel.RewardConfigInput{
|
||||||
|
Enabled: true,
|
||||||
|
Sort: index + 1,
|
||||||
|
RewardType: "GOLD",
|
||||||
|
GoldAmount: int64(7 + index),
|
||||||
|
Probability: probability,
|
||||||
|
TotalLimit: totalLimit,
|
||||||
|
UserLimit: userLimit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return service.SaveCategoryConfig(ctx, wheel.SaveCategoryConfigRequest{
|
||||||
|
SysOrigin: localSysOrigin,
|
||||||
|
Timezone: localTimezone,
|
||||||
|
Category: wheel.CategoryConfigInput{
|
||||||
|
Category: localCategory,
|
||||||
|
Enabled: true,
|
||||||
|
PriceOneGold: 3,
|
||||||
|
PriceTenGold: 30,
|
||||||
|
PriceFiftyGold: 150,
|
||||||
|
Rewards: rewards,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDrawDeterministic(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
|
||||||
|
var reward model.WheelRewardConfig
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 1).
|
||||||
|
First(&reward).Error; err != nil {
|
||||||
|
return model.WheelRewardConfig{}, err
|
||||||
|
}
|
||||||
|
return reward, db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.WheelRewardConfig{}).
|
||||||
|
Where("sys_origin = ? AND category = ?", localSysOrigin, localCategory).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"probability": 0,
|
||||||
|
"total_limit": 0,
|
||||||
|
"user_limit": 0,
|
||||||
|
"issued_count": 0,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.WheelRewardConfig{}).
|
||||||
|
Where("id = ?", reward.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"probability": probabilityTotal,
|
||||||
|
"total_limit": 1,
|
||||||
|
"user_limit": 1,
|
||||||
|
"issued_count": 0,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ?", reward.ID).First(&reward).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableFallbackReward(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
|
||||||
|
var reward model.WheelRewardConfig
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 2).
|
||||||
|
First(&reward).Error; err != nil {
|
||||||
|
return model.WheelRewardConfig{}, err
|
||||||
|
}
|
||||||
|
err := db.WithContext(ctx).Model(&model.WheelRewardConfig{}).
|
||||||
|
Where("id = ?", reward.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"probability": probabilityTotal,
|
||||||
|
"total_limit": 0,
|
||||||
|
"user_limit": 0,
|
||||||
|
"issued_count": 0,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return model.WheelRewardConfig{}, err
|
||||||
|
}
|
||||||
|
return reward, db.WithContext(ctx).Where("id = ?", reward.ID).First(&reward).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstCategoryReward(resp *wheel.ConfigResponse) wheel.RewardConfigPayload {
|
||||||
|
for _, category := range resp.Categories {
|
||||||
|
if category.Category == localCategory && len(category.Rewards) > 0 {
|
||||||
|
return category.Rewards[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Fatal("missing first category reward")
|
||||||
|
return wheel.RewardConfigPayload{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRecords(ctx context.Context, db *gorm.DB, status string) int64 {
|
||||||
|
var count int64
|
||||||
|
must(db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||||
|
Where("sys_origin = ? AND status = ?", localSysOrigin, status).
|
||||||
|
Count(&count).Error, "count records")
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadReward(ctx context.Context, db *gorm.DB, id int64) model.WheelRewardConfig {
|
||||||
|
var reward model.WheelRewardConfig
|
||||||
|
must(db.WithContext(ctx).Where("id = ?", id).First(&reward).Error, "load reward")
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecord(ctx context.Context, db *gorm.DB, id int64) model.WheelDrawRecord {
|
||||||
|
var record model.WheelDrawRecord
|
||||||
|
must(db.WithContext(ctx).Where("id = ?", id).First(&record).Error, "load record")
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func appErrorCode(err error) string {
|
||||||
|
var appErr *common.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
return appErr.Code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsnLabel(dsn string) string {
|
||||||
|
if at := strings.Index(dsn, "@tcp("); at >= 0 {
|
||||||
|
rest := dsn[at+len("@tcp("):]
|
||||||
|
hostEnd := strings.Index(rest, ")")
|
||||||
|
if hostEnd >= 0 {
|
||||||
|
host := rest[:hostEnd]
|
||||||
|
dbName := ""
|
||||||
|
after := rest[hostEnd+1:]
|
||||||
|
if strings.HasPrefix(after, "/") {
|
||||||
|
dbPart := strings.TrimPrefix(after, "/")
|
||||||
|
if question := strings.Index(dbPart, "?"); question >= 0 {
|
||||||
|
dbPart = dbPart[:question]
|
||||||
|
}
|
||||||
|
dbName = dbPart
|
||||||
|
}
|
||||||
|
if dbName != "" {
|
||||||
|
return host + "/" + dbName
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom-dsn"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error, step string) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%s: %v", step, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assert(ok bool, message string) {
|
||||||
|
if !ok {
|
||||||
|
log.Fatal(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
388
scripts/wheel_pure_random_simulate.go
Normal file
388
scripts/wheel_pure_random_simulate.go
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/service/prizepool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
probabilityTotal = 1000000
|
||||||
|
defaultTarget = 100000
|
||||||
|
defaultUsers = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
type categorySpec struct {
|
||||||
|
Category string
|
||||||
|
RewardCount int
|
||||||
|
GoldBase int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardConfig struct {
|
||||||
|
ID int64
|
||||||
|
Category string
|
||||||
|
Sort int
|
||||||
|
GoldAmount int64
|
||||||
|
Probability int
|
||||||
|
TotalLimit int64
|
||||||
|
UserLimit int64
|
||||||
|
IssuedCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardSummary struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
RewardID int64 `json:"rewardId"`
|
||||||
|
GoldAmount int64 `json:"goldAmount"`
|
||||||
|
Probability int `json:"probability"`
|
||||||
|
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||||
|
UserLimit int64 `json:"userLimit,omitempty"`
|
||||||
|
IssuedCount int64 `json:"issuedCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
TargetRewardItems int `json:"targetRewardItems"`
|
||||||
|
ActualRewardItems int64 `json:"actualRewardItems"`
|
||||||
|
DrawRequests int64 `json:"drawRequests"`
|
||||||
|
RandomUsers int `json:"randomUsers"`
|
||||||
|
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||||
|
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||||
|
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
|
||||||
|
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
|
||||||
|
LimitedRewards int `json:"limitedRewards"`
|
||||||
|
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
|
||||||
|
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||||
|
UserLimitViolations int `json:"userLimitViolations"`
|
||||||
|
NoAvailableHits int64 `json:"noAvailableHits"`
|
||||||
|
LimitedRewardDetails []rewardSummary `json:"limitedRewardDetails"`
|
||||||
|
TopRewards []rewardSummary `json:"topRewards"`
|
||||||
|
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRewardKey struct {
|
||||||
|
UserID int64
|
||||||
|
RewardID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
startedAt := time.Now()
|
||||||
|
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||||
|
target := envInt("WHEEL_SIM_TARGET_REWARDS", defaultTarget)
|
||||||
|
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
|
||||||
|
rng := rand.New(rand.NewSource(seed))
|
||||||
|
|
||||||
|
rewardsByCategory, allRewards := buildRandomConfig(rng)
|
||||||
|
userRewardCounts := map[userRewardKey]int64{}
|
||||||
|
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
categoryRewardItems := map[string]int64{}
|
||||||
|
uniqueUsers := map[int64]struct{}{}
|
||||||
|
|
||||||
|
var drawRequests int64
|
||||||
|
var rewardItems int64
|
||||||
|
var noAvailableHits int64
|
||||||
|
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
|
||||||
|
return rng.Intn(max), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
for rewardItems < int64(target) {
|
||||||
|
userID := int64(1 + rng.Intn(userCount))
|
||||||
|
category := specs()[rng.Intn(len(specs()))].Category
|
||||||
|
categoryRewards := rewardsByCategory[category]
|
||||||
|
times := randomDrawTimes(rng)
|
||||||
|
drawRequests++
|
||||||
|
uniqueUsers[userID] = struct{}{}
|
||||||
|
|
||||||
|
timesKey := strconv.Itoa(times)
|
||||||
|
drawTimesRequests[timesKey]++
|
||||||
|
for i := 0; i < times; i++ {
|
||||||
|
picked, err := picker.PickAvailable(ctx, prizePoolItems(categoryRewards), userID, prizepool.UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
|
||||||
|
return userRewardCounts[userRewardKey{UserID: userID, RewardID: itemID}], nil
|
||||||
|
}))
|
||||||
|
if errors.Is(err, prizepool.ErrNoAvailableItem) {
|
||||||
|
noAvailableHits++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
reward := categoryRewards[picked.Index]
|
||||||
|
reward.IssuedCount++
|
||||||
|
if reward.UserLimit > 0 {
|
||||||
|
userRewardCounts[userRewardKey{UserID: userID, RewardID: reward.ID}]++
|
||||||
|
}
|
||||||
|
rewardItems++
|
||||||
|
drawTimesRewardItems[timesKey]++
|
||||||
|
categoryRewardItems[category]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(allRewards)
|
||||||
|
totalLimitViolations := countTotalLimitViolations(allRewards)
|
||||||
|
userLimitViolations := countUserLimitViolations(allRewards, userRewardCounts)
|
||||||
|
limitedRewardDetails := limitedRewardSummaries(allRewards)
|
||||||
|
topRewards := topRewardSummaries(allRewards, 14)
|
||||||
|
|
||||||
|
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
|
||||||
|
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
|
||||||
|
assert(noAvailableHits == 0, fmt.Sprintf("no available hits = %d", noAvailableHits))
|
||||||
|
|
||||||
|
result := summary{
|
||||||
|
Seed: seed,
|
||||||
|
TargetRewardItems: target,
|
||||||
|
ActualRewardItems: rewardItems,
|
||||||
|
DrawRequests: drawRequests,
|
||||||
|
RandomUsers: userCount,
|
||||||
|
UniqueUsersDrawn: len(uniqueUsers),
|
||||||
|
DrawTimesRequests: drawTimesRequests,
|
||||||
|
DrawTimesRewardItems: drawTimesRewardItems,
|
||||||
|
CategoryRewardItems: categoryRewardItems,
|
||||||
|
LimitedRewards: limitedRewards,
|
||||||
|
ExhaustedLimitedRewards: exhaustedLimitedRewards,
|
||||||
|
TotalLimitViolations: totalLimitViolations,
|
||||||
|
UserLimitViolations: userLimitViolations,
|
||||||
|
NoAvailableHits: noAvailableHits,
|
||||||
|
LimitedRewardDetails: limitedRewardDetails,
|
||||||
|
TopRewards: topRewards,
|
||||||
|
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func specs() []categorySpec {
|
||||||
|
return []categorySpec{
|
||||||
|
{Category: "CLASSIC", RewardCount: 8, GoldBase: 10},
|
||||||
|
{Category: "LUXURY", RewardCount: 8, GoldBase: 100},
|
||||||
|
{Category: "ADVANCED", RewardCount: 12, GoldBase: 500},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRandomConfig(rng *rand.Rand) (map[string][]*rewardConfig, []*rewardConfig) {
|
||||||
|
byCategory := map[string][]*rewardConfig{}
|
||||||
|
all := make([]*rewardConfig, 0, 28)
|
||||||
|
var rewardID int64 = 1
|
||||||
|
for _, spec := range specs() {
|
||||||
|
probabilities := randomProbabilities(spec.RewardCount, rng)
|
||||||
|
rewards := make([]*rewardConfig, 0, spec.RewardCount)
|
||||||
|
for index := 0; index < spec.RewardCount; index++ {
|
||||||
|
totalLimit := int64(0)
|
||||||
|
userLimit := int64(0)
|
||||||
|
if index == 0 {
|
||||||
|
totalLimit = int64(120 + rng.Intn(81))
|
||||||
|
userLimit = 1
|
||||||
|
}
|
||||||
|
if index == 1 {
|
||||||
|
totalLimit = int64(260 + rng.Intn(121))
|
||||||
|
userLimit = 2
|
||||||
|
}
|
||||||
|
reward := &rewardConfig{
|
||||||
|
ID: rewardID,
|
||||||
|
Category: spec.Category,
|
||||||
|
Sort: index + 1,
|
||||||
|
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
|
||||||
|
Probability: probabilities[index],
|
||||||
|
TotalLimit: totalLimit,
|
||||||
|
UserLimit: userLimit,
|
||||||
|
}
|
||||||
|
rewardID++
|
||||||
|
rewards = append(rewards, reward)
|
||||||
|
all = append(all, reward)
|
||||||
|
}
|
||||||
|
byCategory[spec.Category] = rewards
|
||||||
|
}
|
||||||
|
return byCategory, all
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomProbabilities(count int, rng *rand.Rand) []int {
|
||||||
|
raw := make([]int, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
raw[i] = rng.Intn(1000) + 1
|
||||||
|
}
|
||||||
|
raw[0] += 7000 + rng.Intn(3000)
|
||||||
|
raw[1] += 3500 + rng.Intn(2000)
|
||||||
|
|
||||||
|
rawTotal := 0
|
||||||
|
for _, value := range raw {
|
||||||
|
rawTotal += value
|
||||||
|
}
|
||||||
|
result := make([]int, count)
|
||||||
|
assigned := 0
|
||||||
|
for i, value := range raw {
|
||||||
|
probability := value * probabilityTotal / rawTotal
|
||||||
|
if probability <= 0 {
|
||||||
|
probability = 1
|
||||||
|
}
|
||||||
|
result[i] = probability
|
||||||
|
assigned += probability
|
||||||
|
}
|
||||||
|
result[0] += probabilityTotal - assigned
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDrawTimes(rng *rand.Rand) int {
|
||||||
|
switch rng.Intn(3) {
|
||||||
|
case 0:
|
||||||
|
return 1
|
||||||
|
case 1:
|
||||||
|
return 10
|
||||||
|
default:
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prizePoolItems(rewards []*rewardConfig) []prizepool.Item {
|
||||||
|
items := make([]prizepool.Item, 0, len(rewards))
|
||||||
|
for _, reward := range rewards {
|
||||||
|
items = append(items, prizepool.Item{
|
||||||
|
ID: reward.ID,
|
||||||
|
Enabled: true,
|
||||||
|
Weight: reward.Probability,
|
||||||
|
TotalLimit: reward.TotalLimit,
|
||||||
|
UserLimit: reward.UserLimit,
|
||||||
|
IssuedCount: reward.IssuedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLimitedRewards(rewards []*rewardConfig) (int, int) {
|
||||||
|
limited := 0
|
||||||
|
exhausted := 0
|
||||||
|
for _, reward := range rewards {
|
||||||
|
if reward.TotalLimit <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limited++
|
||||||
|
if reward.IssuedCount >= reward.TotalLimit {
|
||||||
|
exhausted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limited, exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalLimitViolations(rewards []*rewardConfig) int {
|
||||||
|
violations := 0
|
||||||
|
for _, reward := range rewards {
|
||||||
|
if reward.TotalLimit > 0 && reward.IssuedCount > reward.TotalLimit {
|
||||||
|
violations++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func countUserLimitViolations(rewards []*rewardConfig, userRewardCounts map[userRewardKey]int64) int {
|
||||||
|
userLimits := map[int64]int64{}
|
||||||
|
for _, reward := range rewards {
|
||||||
|
if reward.UserLimit > 0 {
|
||||||
|
userLimits[reward.ID] = reward.UserLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations := 0
|
||||||
|
for key, count := range userRewardCounts {
|
||||||
|
if limit := userLimits[key.RewardID]; limit > 0 && count > limit {
|
||||||
|
violations++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitedRewardSummaries(rewards []*rewardConfig) []rewardSummary {
|
||||||
|
result := make([]rewardSummary, 0)
|
||||||
|
for _, reward := range rewards {
|
||||||
|
if reward.TotalLimit <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, rewardSummary{
|
||||||
|
Category: reward.Category,
|
||||||
|
Sort: reward.Sort,
|
||||||
|
RewardID: reward.ID,
|
||||||
|
GoldAmount: reward.GoldAmount,
|
||||||
|
Probability: reward.Probability,
|
||||||
|
TotalLimit: reward.TotalLimit,
|
||||||
|
UserLimit: reward.UserLimit,
|
||||||
|
IssuedCount: reward.IssuedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(result, func(i, j int) bool {
|
||||||
|
if result[i].Category == result[j].Category {
|
||||||
|
return result[i].Sort < result[j].Sort
|
||||||
|
}
|
||||||
|
return result[i].Category < result[j].Category
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func topRewardSummaries(rewards []*rewardConfig, limit int) []rewardSummary {
|
||||||
|
copied := append([]*rewardConfig(nil), rewards...)
|
||||||
|
sort.SliceStable(copied, func(i, j int) bool {
|
||||||
|
if copied[i].IssuedCount == copied[j].IssuedCount {
|
||||||
|
return copied[i].ID < copied[j].ID
|
||||||
|
}
|
||||||
|
return copied[i].IssuedCount > copied[j].IssuedCount
|
||||||
|
})
|
||||||
|
if len(copied) > limit {
|
||||||
|
copied = copied[:limit]
|
||||||
|
}
|
||||||
|
result := make([]rewardSummary, 0, len(copied))
|
||||||
|
for _, reward := range copied {
|
||||||
|
result = append(result, rewardSummary{
|
||||||
|
Category: reward.Category,
|
||||||
|
Sort: reward.Sort,
|
||||||
|
RewardID: reward.ID,
|
||||||
|
GoldAmount: reward.GoldAmount,
|
||||||
|
Probability: reward.Probability,
|
||||||
|
TotalLimit: reward.TotalLimit,
|
||||||
|
UserLimit: reward.UserLimit,
|
||||||
|
IssuedCount: reward.IssuedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, fallback int) int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt64(name string, fallback int64) int64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func assert(ok bool, message string) {
|
||||||
|
if !ok {
|
||||||
|
log.Fatal(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
638
scripts/wheel_random_simulate.go
Normal file
638
scripts/wheel_random_simulate.go
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/wheel"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
simSysOrigin = "LOCAL_WHEEL_RANDOM_SIM"
|
||||||
|
simRoomID = int64(9002001)
|
||||||
|
simUserIDBase = int64(2042274349300000000)
|
||||||
|
probabilityTotal = 1000000
|
||||||
|
targetRewardCount = 100000
|
||||||
|
|
||||||
|
receiptIncome = "INCOME"
|
||||||
|
receiptExpense = "EXPENDITURE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type categorySpec struct {
|
||||||
|
Category string
|
||||||
|
RewardCount int
|
||||||
|
PriceOneGold int64
|
||||||
|
PriceTenGold int64
|
||||||
|
PriceFiftyGold int64
|
||||||
|
GoldBase int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type simGateway struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events map[string]integration.GoldReceiptCommand
|
||||||
|
changes []integration.GoldReceiptCommand
|
||||||
|
balance map[int64]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type topRewardSummary struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
RewardID int64 `json:"rewardId,string"`
|
||||||
|
GoldAmount int64 `json:"goldAmount"`
|
||||||
|
Probability int `json:"probability"`
|
||||||
|
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||||
|
UserLimit int64 `json:"userLimit,omitempty"`
|
||||||
|
IssuedCount int64 `json:"issuedCount"`
|
||||||
|
ActualCount int64 `json:"actualCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type simSummary struct {
|
||||||
|
DSNHost string `json:"dsnHost"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
TargetRewardItems int `json:"targetRewardItems"`
|
||||||
|
ActualRewardItems int64 `json:"actualRewardItems"`
|
||||||
|
DrawRequests int64 `json:"drawRequests"`
|
||||||
|
RandomUsers int `json:"randomUsers"`
|
||||||
|
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||||
|
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||||
|
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
|
||||||
|
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
|
||||||
|
StatusCounts map[string]int64 `json:"statusCounts"`
|
||||||
|
PaymentEvents int `json:"paymentEvents"`
|
||||||
|
IncomeEvents int `json:"incomeEvents"`
|
||||||
|
LimitedRewards int `json:"limitedRewards"`
|
||||||
|
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
|
||||||
|
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||||
|
UserLimitViolations int `json:"userLimitViolations"`
|
||||||
|
IssuedCountMismatches int `json:"issuedCountMismatches"`
|
||||||
|
TopRewards []topRewardSummary `json:"topRewards"`
|
||||||
|
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.events == nil {
|
||||||
|
g.events = map[string]integration.GoldReceiptCommand{}
|
||||||
|
}
|
||||||
|
if g.balance == nil {
|
||||||
|
g.balance = map[int64]int64{}
|
||||||
|
}
|
||||||
|
if _, exists := g.events[cmd.EventID]; exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(cmd.ReceiptType) {
|
||||||
|
case receiptExpense:
|
||||||
|
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
|
||||||
|
case receiptIncome:
|
||||||
|
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
|
||||||
|
}
|
||||||
|
g.events[cmd.EventID] = cmd
|
||||||
|
g.changes = append(g.changes, cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
_, exists := g.events[eventID]
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) SwitchUseProps(context.Context, int64, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
result := make(map[int64]int64, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
result[userID] = g.balance[userID]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
|
||||||
|
return integration.RoomProfile{
|
||||||
|
ID: integration.Int64Value(simRoomID),
|
||||||
|
UserID: integration.Int64Value(userID),
|
||||||
|
RoomName: "local-wheel-random-sim-room",
|
||||||
|
SysOrigin: simSysOrigin,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *simGateway) countReceipt(receiptType string) int {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
count := 0
|
||||||
|
for _, change := range g.changes {
|
||||||
|
if strings.EqualFold(change.ReceiptType, receiptType) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
startedAt := time.Now()
|
||||||
|
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||||
|
target := envInt("WHEEL_SIM_TARGET_REWARDS", targetRewardCount)
|
||||||
|
userCount := envInt("WHEEL_SIM_USERS", 5000)
|
||||||
|
rng := rand.New(rand.NewSource(seed))
|
||||||
|
|
||||||
|
db, dsn, err := connectMySQL(ctx)
|
||||||
|
must(err, "connect mysql")
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
must(err, "unwrap mysql")
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
must(ensureWheelSchema(db), "ensure wheel schema")
|
||||||
|
must(cleanup(ctx, db, simSysOrigin), "cleanup previous sim data")
|
||||||
|
|
||||||
|
gateway := &simGateway{
|
||||||
|
events: map[string]integration.GoldReceiptCommand{},
|
||||||
|
balance: make(map[int64]int64, userCount),
|
||||||
|
}
|
||||||
|
for i := 0; i < userCount; i++ {
|
||||||
|
gateway.balance[simUserIDBase+int64(i)] = 1_000_000_000
|
||||||
|
}
|
||||||
|
service := wheel.NewService(config.Config{}, db, gateway)
|
||||||
|
must(seedRandomConfig(ctx, service, rng), "seed random wheel config")
|
||||||
|
|
||||||
|
categorySpecs := specs()
|
||||||
|
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
categoryRewardItems := map[string]int64{}
|
||||||
|
uniqueUsers := map[int64]struct{}{}
|
||||||
|
var drawRequests int64
|
||||||
|
var rewardItems int64
|
||||||
|
|
||||||
|
for rewardItems < int64(target) {
|
||||||
|
userID := simUserIDBase + int64(rng.Intn(userCount))
|
||||||
|
category := categorySpecs[rng.Intn(len(categorySpecs))].Category
|
||||||
|
times := randomDrawTimes(rng)
|
||||||
|
|
||||||
|
resp, err := service.Draw(ctx, common.AuthUser{UserID: userID, SysOrigin: simSysOrigin}, wheel.DrawRequest{
|
||||||
|
Category: category,
|
||||||
|
Times: times,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("draw failed after requests=%d rewards=%d user=%d category=%s times=%d: %v", drawRequests, rewardItems, userID, category, times, err)
|
||||||
|
}
|
||||||
|
if resp.GrantFailed {
|
||||||
|
log.Fatalf("grant failed after requests=%d rewards=%d drawNo=%s", drawRequests, rewardItems, resp.DrawNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRequests++
|
||||||
|
uniqueUsers[userID] = struct{}{}
|
||||||
|
key := strconv.Itoa(times)
|
||||||
|
drawTimesRequests[key]++
|
||||||
|
drawTimesRewardItems[key] += int64(len(resp.Records))
|
||||||
|
for _, record := range resp.Records {
|
||||||
|
rewardItems++
|
||||||
|
categoryRewardItems[record.Category]++
|
||||||
|
}
|
||||||
|
if rewardItems > 0 && rewardItems%20000 < int64(times) {
|
||||||
|
log.Printf("progress rewards=%d requests=%d", rewardItems, drawRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCounts := loadStatusCounts(ctx, db)
|
||||||
|
topRewards := loadTopRewards(ctx, db, 12)
|
||||||
|
totalLimitViolations := countTotalLimitViolations(ctx, db)
|
||||||
|
userLimitViolations := countUserLimitViolations(ctx, db)
|
||||||
|
issuedCountMismatches := countIssuedCountMismatches(ctx, db)
|
||||||
|
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(ctx, db)
|
||||||
|
|
||||||
|
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
|
||||||
|
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
|
||||||
|
assert(issuedCountMismatches == 0, fmt.Sprintf("issued count mismatches = %d", issuedCountMismatches))
|
||||||
|
assert(statusCounts["SUCCESS"] == rewardItems, fmt.Sprintf("success count = %d, rewards = %d", statusCounts["SUCCESS"], rewardItems))
|
||||||
|
|
||||||
|
summary := simSummary{
|
||||||
|
DSNHost: dsnLabel(dsn),
|
||||||
|
SysOrigin: simSysOrigin,
|
||||||
|
Seed: seed,
|
||||||
|
TargetRewardItems: target,
|
||||||
|
ActualRewardItems: rewardItems,
|
||||||
|
DrawRequests: drawRequests,
|
||||||
|
RandomUsers: userCount,
|
||||||
|
UniqueUsersDrawn: len(uniqueUsers),
|
||||||
|
DrawTimesRequests: drawTimesRequests,
|
||||||
|
DrawTimesRewardItems: drawTimesRewardItems,
|
||||||
|
CategoryRewardItems: categoryRewardItems,
|
||||||
|
StatusCounts: statusCounts,
|
||||||
|
PaymentEvents: gateway.countReceipt(receiptExpense),
|
||||||
|
IncomeEvents: gateway.countReceipt(receiptIncome),
|
||||||
|
LimitedRewards: limitedRewards,
|
||||||
|
ExhaustedLimitedRewards: exhaustedLimitedRewards,
|
||||||
|
TotalLimitViolations: totalLimitViolations,
|
||||||
|
UserLimitViolations: userLimitViolations,
|
||||||
|
IssuedCountMismatches: issuedCountMismatches,
|
||||||
|
TopRewards: topRewards,
|
||||||
|
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(summary, "", " ")
|
||||||
|
must(err, "marshal summary")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func specs() []categorySpec {
|
||||||
|
return []categorySpec{
|
||||||
|
{Category: "CLASSIC", RewardCount: 8, PriceOneGold: 3, PriceTenGold: 30, PriceFiftyGold: 150, GoldBase: 10},
|
||||||
|
{Category: "LUXURY", RewardCount: 8, PriceOneGold: 30, PriceTenGold: 300, PriceFiftyGold: 1500, GoldBase: 100},
|
||||||
|
{Category: "ADVANCED", RewardCount: 12, PriceOneGold: 100, PriceTenGold: 1000, PriceFiftyGold: 5000, GoldBase: 500},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRandomConfig(ctx context.Context, service *wheel.Service, rng *rand.Rand) error {
|
||||||
|
categories := make([]wheel.CategoryConfigInput, 0, len(specs()))
|
||||||
|
for _, spec := range specs() {
|
||||||
|
probabilities := randomProbabilities(spec.RewardCount, rng)
|
||||||
|
rewards := make([]wheel.RewardConfigInput, 0, spec.RewardCount)
|
||||||
|
for index := 0; index < spec.RewardCount; index++ {
|
||||||
|
totalLimit := int64(0)
|
||||||
|
userLimit := int64(0)
|
||||||
|
if index == 0 {
|
||||||
|
totalLimit = int64(120 + rng.Intn(81))
|
||||||
|
userLimit = 1
|
||||||
|
}
|
||||||
|
if index == 1 {
|
||||||
|
totalLimit = int64(260 + rng.Intn(121))
|
||||||
|
userLimit = 2
|
||||||
|
}
|
||||||
|
rewards = append(rewards, wheel.RewardConfigInput{
|
||||||
|
Enabled: true,
|
||||||
|
Sort: index + 1,
|
||||||
|
RewardType: "GOLD",
|
||||||
|
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
|
||||||
|
Probability: probabilities[index],
|
||||||
|
TotalLimit: totalLimit,
|
||||||
|
UserLimit: userLimit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
categories = append(categories, wheel.CategoryConfigInput{
|
||||||
|
Category: spec.Category,
|
||||||
|
Enabled: true,
|
||||||
|
PriceOneGold: spec.PriceOneGold,
|
||||||
|
PriceTenGold: spec.PriceTenGold,
|
||||||
|
PriceFiftyGold: spec.PriceFiftyGold,
|
||||||
|
Rewards: rewards,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, err := service.SaveConfig(ctx, wheel.SaveConfigRequest{
|
||||||
|
SysOrigin: simSysOrigin,
|
||||||
|
Enabled: true,
|
||||||
|
Timezone: "Asia/Riyadh",
|
||||||
|
Categories: categories,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomProbabilities(count int, rng *rand.Rand) []int {
|
||||||
|
raw := make([]int, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
raw[i] = rng.Intn(1000) + 1
|
||||||
|
}
|
||||||
|
raw[0] += 7000 + rng.Intn(3000)
|
||||||
|
raw[1] += 3500 + rng.Intn(2000)
|
||||||
|
|
||||||
|
rawTotal := 0
|
||||||
|
for _, value := range raw {
|
||||||
|
rawTotal += value
|
||||||
|
}
|
||||||
|
result := make([]int, count)
|
||||||
|
assigned := 0
|
||||||
|
for i, value := range raw {
|
||||||
|
probability := value * probabilityTotal / rawTotal
|
||||||
|
if probability <= 0 {
|
||||||
|
probability = 1
|
||||||
|
}
|
||||||
|
result[i] = probability
|
||||||
|
assigned += probability
|
||||||
|
}
|
||||||
|
result[0] += probabilityTotal - assigned
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDrawTimes(rng *rand.Rand) int {
|
||||||
|
switch rng.Intn(3) {
|
||||||
|
case 0:
|
||||||
|
return 1
|
||||||
|
case 1:
|
||||||
|
return 10
|
||||||
|
default:
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStatusCounts(ctx context.Context, db *gorm.DB) map[string]int64 {
|
||||||
|
var rows []struct {
|
||||||
|
Status string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT status, COUNT(*) AS count
|
||||||
|
FROM wheel_draw_record
|
||||||
|
WHERE sys_origin = ?
|
||||||
|
GROUP BY status
|
||||||
|
`, simSysOrigin).Scan(&rows).Error, "load status counts")
|
||||||
|
result := map[string]int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.Status] = row.Count
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTopRewards(ctx context.Context, db *gorm.DB, limit int) []topRewardSummary {
|
||||||
|
var rows []topRewardSummary
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
c.category AS category,
|
||||||
|
c.sort AS sort,
|
||||||
|
c.id AS reward_id,
|
||||||
|
c.gold_amount AS gold_amount,
|
||||||
|
c.probability AS probability,
|
||||||
|
c.total_limit AS total_limit,
|
||||||
|
c.user_limit AS user_limit,
|
||||||
|
c.issued_count AS issued_count,
|
||||||
|
COUNT(r.id) AS actual_count
|
||||||
|
FROM wheel_reward_config c
|
||||||
|
LEFT JOIN wheel_draw_record r
|
||||||
|
ON r.reward_config_id = c.id
|
||||||
|
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||||
|
WHERE c.sys_origin = ?
|
||||||
|
GROUP BY c.category, c.sort, c.id, c.gold_amount, c.probability, c.total_limit, c.user_limit, c.issued_count
|
||||||
|
ORDER BY actual_count DESC, c.category ASC, c.sort ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, simSysOrigin, limit).Scan(&rows).Error, "load top rewards")
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
if rows[i].ActualCount == rows[j].ActualCount {
|
||||||
|
return rows[i].RewardID < rows[j].RewardID
|
||||||
|
}
|
||||||
|
return rows[i].ActualCount > rows[j].ActualCount
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalLimitViolations(ctx context.Context, db *gorm.DB) int {
|
||||||
|
var count int64
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT c.id
|
||||||
|
FROM wheel_reward_config c
|
||||||
|
LEFT JOIN wheel_draw_record r
|
||||||
|
ON r.reward_config_id = c.id
|
||||||
|
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||||
|
WHERE c.sys_origin = ?
|
||||||
|
AND c.total_limit > 0
|
||||||
|
GROUP BY c.id, c.total_limit
|
||||||
|
HAVING COUNT(r.id) > c.total_limit
|
||||||
|
) t
|
||||||
|
`, simSysOrigin).Scan(&count).Error, "count total limit violations")
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countUserLimitViolations(ctx context.Context, db *gorm.DB) int {
|
||||||
|
var count int64
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT r.reward_config_id, r.user_id
|
||||||
|
FROM wheel_draw_record r
|
||||||
|
JOIN wheel_reward_config c ON c.id = r.reward_config_id
|
||||||
|
WHERE r.sys_origin = ?
|
||||||
|
AND c.user_limit > 0
|
||||||
|
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||||
|
GROUP BY r.reward_config_id, r.user_id, c.user_limit
|
||||||
|
HAVING COUNT(*) > c.user_limit
|
||||||
|
) t
|
||||||
|
`, simSysOrigin).Scan(&count).Error, "count user limit violations")
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countIssuedCountMismatches(ctx context.Context, db *gorm.DB) int {
|
||||||
|
var count int64
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT c.id
|
||||||
|
FROM wheel_reward_config c
|
||||||
|
LEFT JOIN wheel_draw_record r
|
||||||
|
ON r.reward_config_id = c.id
|
||||||
|
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||||
|
WHERE c.sys_origin = ?
|
||||||
|
GROUP BY c.id, c.issued_count
|
||||||
|
HAVING c.issued_count <> COUNT(r.id)
|
||||||
|
) t
|
||||||
|
`, simSysOrigin).Scan(&count).Error, "count issued count mismatches")
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLimitedRewards(ctx context.Context, db *gorm.DB) (int, int) {
|
||||||
|
var rows []struct {
|
||||||
|
TotalLimit int64
|
||||||
|
IssuedCount int64
|
||||||
|
}
|
||||||
|
must(db.WithContext(ctx).Raw(`
|
||||||
|
SELECT total_limit AS total_limit, issued_count AS issued_count
|
||||||
|
FROM wheel_reward_config
|
||||||
|
WHERE sys_origin = ?
|
||||||
|
AND total_limit > 0
|
||||||
|
`, simSysOrigin).Scan(&rows).Error, "count limited rewards")
|
||||||
|
exhausted := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.IssuedCount >= row.TotalLimit {
|
||||||
|
exhausted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(rows), exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
|
||||||
|
candidates := uniqueStrings([]string{
|
||||||
|
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
|
||||||
|
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
|
||||||
|
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
})
|
||||||
|
var errs []string
|
||||||
|
for _, dsn := range candidates {
|
||||||
|
if dsn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return db, dsn, nil
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureWheelSchema(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(
|
||||||
|
&model.WheelConfig{},
|
||||||
|
&model.WheelPoolConfig{},
|
||||||
|
&model.WheelRewardConfig{},
|
||||||
|
&model.WheelDrawRecord{},
|
||||||
|
&model.ActivityIdempotency{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
|
||||||
|
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var configIDs []int64
|
||||||
|
if err := tx.Model(&model.WheelConfig{}).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
Pluck("id", &configIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(configIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func appErrorCode(err error) string {
|
||||||
|
var appErr *common.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
return appErr.Code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsnLabel(dsn string) string {
|
||||||
|
if at := strings.Index(dsn, "@tcp("); at >= 0 {
|
||||||
|
rest := dsn[at+len("@tcp("):]
|
||||||
|
hostEnd := strings.Index(rest, ")")
|
||||||
|
if hostEnd >= 0 {
|
||||||
|
host := rest[:hostEnd]
|
||||||
|
dbName := ""
|
||||||
|
after := rest[hostEnd+1:]
|
||||||
|
if strings.HasPrefix(after, "/") {
|
||||||
|
dbPart := strings.TrimPrefix(after, "/")
|
||||||
|
if question := strings.Index(dbPart, "?"); question >= 0 {
|
||||||
|
dbPart = dbPart[:question]
|
||||||
|
}
|
||||||
|
dbName = dbPart
|
||||||
|
}
|
||||||
|
if dbName != "" {
|
||||||
|
return host + "/" + dbName
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom-dsn"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, fallback int) int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt64(name string, fallback int64) int64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error, step string) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%s: %v", step, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assert(ok bool, message string) {
|
||||||
|
if !ok {
|
||||||
|
log.Fatal(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
534
scripts/wheel_rtp_random_simulate.go
Normal file
534
scripts/wheel_rtp_random_simulate.go
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/service/prizepool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
probabilityTotal = 1000000
|
||||||
|
defaultTarget = 1000000
|
||||||
|
defaultUsers = 10000
|
||||||
|
targetRTPBasis = 9800
|
||||||
|
)
|
||||||
|
|
||||||
|
type categoryConfig struct {
|
||||||
|
Category string
|
||||||
|
PricePerDraw int64
|
||||||
|
Rewards []*rewardConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardConfig struct {
|
||||||
|
ID int64
|
||||||
|
Category string
|
||||||
|
Sort int
|
||||||
|
GoldAmount int64
|
||||||
|
Probability int
|
||||||
|
Hits int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type categorySummary struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
DrawItems int64 `json:"drawItems"`
|
||||||
|
PaidGold int64 `json:"paidGold"`
|
||||||
|
PayoutGold int64 `json:"payoutGold"`
|
||||||
|
RTPPercent float64 `json:"rtpPercent"`
|
||||||
|
TheoryRTP float64 `json:"theoryRtpPercent"`
|
||||||
|
ExpectedPayout float64 `json:"expectedPayoutGold"`
|
||||||
|
DeviationGold float64 `json:"deviationGold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardSummary struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
RewardID int64 `json:"rewardId"`
|
||||||
|
GoldAmount int64 `json:"goldAmount"`
|
||||||
|
Probability int `json:"probability"`
|
||||||
|
TheoryRate float64 `json:"theoryRatePercent"`
|
||||||
|
Hits int64 `json:"hits"`
|
||||||
|
ActualRate float64 `json:"actualRatePercent"`
|
||||||
|
ExpectedHits float64 `json:"expectedHits"`
|
||||||
|
HitDeviation float64 `json:"hitDeviation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSummary struct {
|
||||||
|
ActiveUsers int `json:"activeUsers"`
|
||||||
|
PositiveUsers int `json:"positiveUsers"`
|
||||||
|
NegativeUsers int `json:"negativeUsers"`
|
||||||
|
BreakEvenUsers int `json:"breakEvenUsers"`
|
||||||
|
MinRTP float64 `json:"minRtpPercent"`
|
||||||
|
P50RTP float64 `json:"p50RtpPercent"`
|
||||||
|
P90RTP float64 `json:"p90RtpPercent"`
|
||||||
|
P99RTP float64 `json:"p99RtpPercent"`
|
||||||
|
MaxRTP float64 `json:"maxRtpPercent"`
|
||||||
|
MinNetGold int64 `json:"minNetGold"`
|
||||||
|
P50NetGold int64 `json:"p50NetGold"`
|
||||||
|
P90NetGold int64 `json:"p90NetGold"`
|
||||||
|
P99NetGold int64 `json:"p99NetGold"`
|
||||||
|
MaxNetGold int64 `json:"maxNetGold"`
|
||||||
|
AveragePaidGold float64 `json:"averagePaidGold"`
|
||||||
|
AverageNetGold float64 `json:"averageNetGold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
TargetRTPPercent float64 `json:"targetRtpPercent"`
|
||||||
|
TargetDrawItems int `json:"targetDrawItems"`
|
||||||
|
ActualDrawItems int64 `json:"actualDrawItems"`
|
||||||
|
DrawRequests int64 `json:"drawRequests"`
|
||||||
|
RandomUsers int `json:"randomUsers"`
|
||||||
|
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||||
|
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||||
|
DrawTimesItems map[string]int64 `json:"drawTimesItems"`
|
||||||
|
PaidGold int64 `json:"paidGold"`
|
||||||
|
PayoutGold int64 `json:"payoutGold"`
|
||||||
|
NetGold int64 `json:"netGold"`
|
||||||
|
ActualRTPPercent float64 `json:"actualRtpPercent"`
|
||||||
|
ExpectedPayoutGold float64 `json:"expectedPayoutGold"`
|
||||||
|
DeviationGold float64 `json:"deviationGold"`
|
||||||
|
DeviationPercent float64 `json:"deviationPercent"`
|
||||||
|
StopLossEnabled bool `json:"stopLossEnabled"`
|
||||||
|
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||||
|
UserLimitViolations int `json:"userLimitViolations"`
|
||||||
|
CategorySummaries []categorySummary `json:"categorySummaries"`
|
||||||
|
UserSummary userSummary `json:"userSummary"`
|
||||||
|
TopRewardsByHits []rewardSummary `json:"topRewardsByHits"`
|
||||||
|
RewardSummaries map[string][]rewardSummary `json:"rewardSummaries"`
|
||||||
|
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userStat struct {
|
||||||
|
paid int64
|
||||||
|
payout int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryStat struct {
|
||||||
|
items int64
|
||||||
|
paid int64
|
||||||
|
payout int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
startedAt := time.Now()
|
||||||
|
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||||
|
target := envInt("WHEEL_SIM_TARGET_DRAWS", defaultTarget)
|
||||||
|
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
|
||||||
|
rng := rand.New(rand.NewSource(seed))
|
||||||
|
|
||||||
|
categories := buildRTPConfig(rng)
|
||||||
|
itemsByCategory := map[string][]prizepool.PickResult{}
|
||||||
|
for _, category := range categories {
|
||||||
|
itemsByCategory[category.Category] = prizePoolItems(category.Rewards)
|
||||||
|
}
|
||||||
|
|
||||||
|
userStats := make([]userStat, userCount)
|
||||||
|
categoryStats := map[string]*categoryStat{}
|
||||||
|
for _, category := range categories {
|
||||||
|
categoryStats[category.Category] = &categoryStat{}
|
||||||
|
}
|
||||||
|
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
drawTimesItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||||
|
uniqueUsers := map[int]struct{}{}
|
||||||
|
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
|
||||||
|
return rng.Intn(max), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
var drawRequests int64
|
||||||
|
var drawItems int64
|
||||||
|
var paidGold int64
|
||||||
|
var payoutGold int64
|
||||||
|
|
||||||
|
for drawItems < int64(target) {
|
||||||
|
userIndex := rng.Intn(userCount)
|
||||||
|
category := categories[rng.Intn(len(categories))]
|
||||||
|
times := randomDrawTimes(rng)
|
||||||
|
timesKey := strconv.Itoa(times)
|
||||||
|
requestPaid := category.PricePerDraw * int64(times)
|
||||||
|
|
||||||
|
drawRequests++
|
||||||
|
drawTimesRequests[timesKey]++
|
||||||
|
drawTimesItems[timesKey] += int64(times)
|
||||||
|
uniqueUsers[userIndex] = struct{}{}
|
||||||
|
userStats[userIndex].paid += requestPaid
|
||||||
|
categoryStats[category.Category].paid += requestPaid
|
||||||
|
paidGold += requestPaid
|
||||||
|
|
||||||
|
for i := 0; i < times; i++ {
|
||||||
|
picked, err := picker.PickWeighted(itemsByCategory[category.Category])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
reward := category.Rewards[picked.Index]
|
||||||
|
reward.Hits++
|
||||||
|
drawItems++
|
||||||
|
payoutGold += reward.GoldAmount
|
||||||
|
userStats[userIndex].payout += reward.GoldAmount
|
||||||
|
categoryStats[category.Category].items++
|
||||||
|
categoryStats[category.Category].payout += reward.GoldAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPayout := float64(paidGold) * float64(targetRTPBasis) / 10000
|
||||||
|
categorySummaries := buildCategorySummaries(categories, categoryStats)
|
||||||
|
rewardSummaries, topRewards := buildRewardSummaries(categories, categoryStats, 14)
|
||||||
|
result := summary{
|
||||||
|
Seed: seed,
|
||||||
|
TargetRTPPercent: float64(targetRTPBasis) / 100,
|
||||||
|
TargetDrawItems: target,
|
||||||
|
ActualDrawItems: drawItems,
|
||||||
|
DrawRequests: drawRequests,
|
||||||
|
RandomUsers: userCount,
|
||||||
|
UniqueUsersDrawn: len(uniqueUsers),
|
||||||
|
DrawTimesRequests: drawTimesRequests,
|
||||||
|
DrawTimesItems: drawTimesItems,
|
||||||
|
PaidGold: paidGold,
|
||||||
|
PayoutGold: payoutGold,
|
||||||
|
NetGold: payoutGold - paidGold,
|
||||||
|
ActualRTPPercent: percent(float64(payoutGold), float64(paidGold)),
|
||||||
|
ExpectedPayoutGold: expectedPayout,
|
||||||
|
DeviationGold: float64(payoutGold) - expectedPayout,
|
||||||
|
DeviationPercent: percent(float64(payoutGold)-expectedPayout, expectedPayout),
|
||||||
|
StopLossEnabled: false,
|
||||||
|
TotalLimitViolations: 0,
|
||||||
|
UserLimitViolations: 0,
|
||||||
|
CategorySummaries: categorySummaries,
|
||||||
|
UserSummary: buildUserSummary(userStats),
|
||||||
|
TopRewardsByHits: topRewards,
|
||||||
|
RewardSummaries: rewardSummaries,
|
||||||
|
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRTPConfig(rng *rand.Rand) []categoryConfig {
|
||||||
|
configs := []struct {
|
||||||
|
category string
|
||||||
|
price int64
|
||||||
|
values []int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
category: "CLASSIC",
|
||||||
|
price: 100,
|
||||||
|
values: []int64{20, 40, 60, 80, 120, 200, 500, 2000},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "LUXURY",
|
||||||
|
price: 1000,
|
||||||
|
values: []int64{200, 400, 700, 1000, 1500, 3000, 10000, 50000},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "ADVANCED",
|
||||||
|
price: 10000,
|
||||||
|
values: []int64{2000, 4000, 7000, 10000, 15000, 30000, 80000, 200000, 500000, 1000000, 3000000, 10000000},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var rewardID int64 = 1
|
||||||
|
result := make([]categoryConfig, 0, len(configs))
|
||||||
|
for _, cfg := range configs {
|
||||||
|
probabilities := probabilitiesForTargetRTP(cfg.values, cfg.price, rng)
|
||||||
|
rewards := make([]*rewardConfig, 0, len(cfg.values))
|
||||||
|
for index, value := range cfg.values {
|
||||||
|
rewards = append(rewards, &rewardConfig{
|
||||||
|
ID: rewardID,
|
||||||
|
Category: cfg.category,
|
||||||
|
Sort: index + 1,
|
||||||
|
GoldAmount: value,
|
||||||
|
Probability: probabilities[index],
|
||||||
|
})
|
||||||
|
rewardID++
|
||||||
|
}
|
||||||
|
result = append(result, categoryConfig{
|
||||||
|
Category: cfg.category,
|
||||||
|
PricePerDraw: cfg.price,
|
||||||
|
Rewards: rewards,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func probabilitiesForTargetRTP(values []int64, price int64, rng *rand.Rand) []int {
|
||||||
|
targetValueNumerator := price * int64(targetRTPBasis) * int64(probabilityTotal)
|
||||||
|
targetValueNumerator /= 10000
|
||||||
|
|
||||||
|
for attempt := 0; attempt < 200; attempt++ {
|
||||||
|
probabilities := make([]int, len(values))
|
||||||
|
noiseTotal := 50000
|
||||||
|
remainingNoise := noiseTotal
|
||||||
|
for i := range probabilities {
|
||||||
|
if i == len(probabilities)-1 {
|
||||||
|
probabilities[i] = remainingNoise
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := rng.Intn(remainingNoise / 2)
|
||||||
|
probabilities[i] = value
|
||||||
|
remainingNoise -= value
|
||||||
|
}
|
||||||
|
rng.Shuffle(len(probabilities), func(i, j int) {
|
||||||
|
probabilities[i], probabilities[j] = probabilities[j], probabilities[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
usedProbability := 0
|
||||||
|
usedValueNumerator := int64(0)
|
||||||
|
for index, probability := range probabilities {
|
||||||
|
usedProbability += probability
|
||||||
|
usedValueNumerator += int64(probability) * values[index]
|
||||||
|
}
|
||||||
|
remainingProbability := probabilityTotal - usedProbability
|
||||||
|
remainingValueNumerator := targetValueNumerator - usedValueNumerator
|
||||||
|
if remainingProbability <= 0 || remainingValueNumerator <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remainingAverage := float64(remainingValueNumerator) / float64(remainingProbability)
|
||||||
|
lowIndex, highIndex := bracketIndexes(values, remainingAverage)
|
||||||
|
if lowIndex < 0 || highIndex < 0 || lowIndex == highIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
low := values[lowIndex]
|
||||||
|
high := values[highIndex]
|
||||||
|
highProbability := int(math.Round(float64(remainingValueNumerator-int64(remainingProbability)*low) / float64(high-low)))
|
||||||
|
if highProbability < 0 || highProbability > remainingProbability {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lowProbability := remainingProbability - highProbability
|
||||||
|
probabilities[lowIndex] += lowProbability
|
||||||
|
probabilities[highIndex] += highProbability
|
||||||
|
normalizeProbabilityTotal(probabilities)
|
||||||
|
return probabilities
|
||||||
|
}
|
||||||
|
log.Fatal("failed to build probability config for target RTP")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bracketIndexes(values []int64, target float64) (int, int) {
|
||||||
|
lowIndex := -1
|
||||||
|
highIndex := -1
|
||||||
|
for index, value := range values {
|
||||||
|
if float64(value) <= target {
|
||||||
|
lowIndex = index
|
||||||
|
}
|
||||||
|
if float64(value) >= target {
|
||||||
|
highIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lowIndex, highIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProbabilityTotal(probabilities []int) {
|
||||||
|
total := 0
|
||||||
|
maxIndex := 0
|
||||||
|
for index, probability := range probabilities {
|
||||||
|
total += probability
|
||||||
|
if probability > probabilities[maxIndex] {
|
||||||
|
maxIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probabilities[maxIndex] += probabilityTotal - total
|
||||||
|
}
|
||||||
|
|
||||||
|
func prizePoolItems(rewards []*rewardConfig) []prizepool.PickResult {
|
||||||
|
items := make([]prizepool.PickResult, 0, len(rewards))
|
||||||
|
for index, reward := range rewards {
|
||||||
|
items = append(items, prizepool.PickResult{
|
||||||
|
Item: prizepool.Item{
|
||||||
|
ID: reward.ID,
|
||||||
|
Enabled: true,
|
||||||
|
Weight: reward.Probability,
|
||||||
|
},
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDrawTimes(rng *rand.Rand) int {
|
||||||
|
switch rng.Intn(3) {
|
||||||
|
case 0:
|
||||||
|
return 1
|
||||||
|
case 1:
|
||||||
|
return 10
|
||||||
|
default:
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCategorySummaries(categories []categoryConfig, stats map[string]*categoryStat) []categorySummary {
|
||||||
|
result := make([]categorySummary, 0, len(categories))
|
||||||
|
for _, category := range categories {
|
||||||
|
stat := stats[category.Category]
|
||||||
|
theoryRTP := theoreticalRTP(category)
|
||||||
|
expectedPayout := float64(stat.paid) * theoryRTP / 100
|
||||||
|
result = append(result, categorySummary{
|
||||||
|
Category: category.Category,
|
||||||
|
DrawItems: stat.items,
|
||||||
|
PaidGold: stat.paid,
|
||||||
|
PayoutGold: stat.payout,
|
||||||
|
RTPPercent: percent(float64(stat.payout), float64(stat.paid)),
|
||||||
|
TheoryRTP: theoryRTP,
|
||||||
|
ExpectedPayout: expectedPayout,
|
||||||
|
DeviationGold: float64(stat.payout) - expectedPayout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRewardSummaries(categories []categoryConfig, stats map[string]*categoryStat, topLimit int) (map[string][]rewardSummary, []rewardSummary) {
|
||||||
|
byCategory := map[string][]rewardSummary{}
|
||||||
|
all := make([]rewardSummary, 0)
|
||||||
|
for _, category := range categories {
|
||||||
|
stat := stats[category.Category]
|
||||||
|
rows := make([]rewardSummary, 0, len(category.Rewards))
|
||||||
|
for _, reward := range category.Rewards {
|
||||||
|
expectedHits := float64(stat.items) * float64(reward.Probability) / probabilityTotal
|
||||||
|
row := rewardSummary{
|
||||||
|
Category: reward.Category,
|
||||||
|
Sort: reward.Sort,
|
||||||
|
RewardID: reward.ID,
|
||||||
|
GoldAmount: reward.GoldAmount,
|
||||||
|
Probability: reward.Probability,
|
||||||
|
TheoryRate: float64(reward.Probability) / 10000,
|
||||||
|
Hits: reward.Hits,
|
||||||
|
ActualRate: percent(float64(reward.Hits), float64(stat.items)),
|
||||||
|
ExpectedHits: expectedHits,
|
||||||
|
HitDeviation: float64(reward.Hits) - expectedHits,
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
all = append(all, row)
|
||||||
|
}
|
||||||
|
byCategory[category.Category] = rows
|
||||||
|
}
|
||||||
|
sort.SliceStable(all, func(i, j int) bool {
|
||||||
|
if all[i].Hits == all[j].Hits {
|
||||||
|
return all[i].RewardID < all[j].RewardID
|
||||||
|
}
|
||||||
|
return all[i].Hits > all[j].Hits
|
||||||
|
})
|
||||||
|
if len(all) > topLimit {
|
||||||
|
all = all[:topLimit]
|
||||||
|
}
|
||||||
|
return byCategory, all
|
||||||
|
}
|
||||||
|
|
||||||
|
func theoreticalRTP(category categoryConfig) float64 {
|
||||||
|
expected := float64(0)
|
||||||
|
for _, reward := range category.Rewards {
|
||||||
|
expected += float64(reward.Probability) / probabilityTotal * float64(reward.GoldAmount)
|
||||||
|
}
|
||||||
|
return percent(expected, float64(category.PricePerDraw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserSummary(userStats []userStat) userSummary {
|
||||||
|
rtps := make([]float64, 0, len(userStats))
|
||||||
|
nets := make([]int64, 0, len(userStats))
|
||||||
|
active := 0
|
||||||
|
positive := 0
|
||||||
|
negative := 0
|
||||||
|
breakEven := 0
|
||||||
|
totalPaid := int64(0)
|
||||||
|
totalNet := int64(0)
|
||||||
|
for _, stat := range userStats {
|
||||||
|
if stat.paid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active++
|
||||||
|
net := stat.payout - stat.paid
|
||||||
|
switch {
|
||||||
|
case net > 0:
|
||||||
|
positive++
|
||||||
|
case net < 0:
|
||||||
|
negative++
|
||||||
|
default:
|
||||||
|
breakEven++
|
||||||
|
}
|
||||||
|
rtps = append(rtps, percent(float64(stat.payout), float64(stat.paid)))
|
||||||
|
nets = append(nets, net)
|
||||||
|
totalPaid += stat.paid
|
||||||
|
totalNet += net
|
||||||
|
}
|
||||||
|
sort.Float64s(rtps)
|
||||||
|
sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] })
|
||||||
|
return userSummary{
|
||||||
|
ActiveUsers: active,
|
||||||
|
PositiveUsers: positive,
|
||||||
|
NegativeUsers: negative,
|
||||||
|
BreakEvenUsers: breakEven,
|
||||||
|
MinRTP: percentileFloat(rtps, 0),
|
||||||
|
P50RTP: percentileFloat(rtps, 0.50),
|
||||||
|
P90RTP: percentileFloat(rtps, 0.90),
|
||||||
|
P99RTP: percentileFloat(rtps, 0.99),
|
||||||
|
MaxRTP: percentileFloat(rtps, 1),
|
||||||
|
MinNetGold: percentileInt(nets, 0),
|
||||||
|
P50NetGold: percentileInt(nets, 0.50),
|
||||||
|
P90NetGold: percentileInt(nets, 0.90),
|
||||||
|
P99NetGold: percentileInt(nets, 0.99),
|
||||||
|
MaxNetGold: percentileInt(nets, 1),
|
||||||
|
AveragePaidGold: float64(totalPaid) / float64(active),
|
||||||
|
AverageNetGold: float64(totalNet) / float64(active),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentileFloat(values []float64, p float64) float64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
index := int(math.Round(p * float64(len(values)-1)))
|
||||||
|
return values[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentileInt(values []int64, p float64) int64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
index := int(math.Round(p * float64(len(values)-1)))
|
||||||
|
return values[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func percent(numerator, denominator float64) float64 {
|
||||||
|
if denominator == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return numerator / denominator * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, fallback int) int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt64(name string, fallback int64) int64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user