From 886bc8998b91c015c9e448f318badf42b83e91cc Mon Sep 17 00:00:00 2001 From: hy001 Date: Tue, 19 May 2026 19:50:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8A=BD=E5=A5=96=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/activity_idempotency.go | 23 + internal/model/wheel_models.go | 50 +- internal/router/router.go | 2 +- internal/router/smash_egg_routes.go | 22 +- internal/router/wheel_routes.go | 31 +- internal/service/idempotency/store.go | 157 +++++ internal/service/prizepool/pool.go | 141 ++++ internal/service/prizepool/pool_test.go | 74 ++ internal/service/smashegg/draw.go | 126 +++- internal/service/smashegg/draw_test.go | 104 ++- internal/service/smashegg/legacy.go | 4 +- internal/service/smashegg/types.go | 7 +- internal/service/wheel/config.go | 109 ++- internal/service/wheel/draw.go | 422 +++++++++--- internal/service/wheel/draw_test.go | 262 ++++++- internal/service/wheel/locking.go | 18 + internal/service/wheel/probability_test.go | 51 +- internal/service/wheel/records.go | 6 + internal/service/wheel/types.go | 42 +- migrations/033_resident_wheel.sql | 13 +- .../045_wheel_limits_probability_retry.sql | 116 ++++ migrations/046_activity_idempotency.sql | 18 + scripts/wheel_local_verify.go | 541 +++++++++++++++ scripts/wheel_pure_random_simulate.go | 388 +++++++++++ scripts/wheel_random_simulate.go | 638 ++++++++++++++++++ scripts/wheel_rtp_random_simulate.go | 534 +++++++++++++++ 26 files changed, 3709 insertions(+), 190 deletions(-) create mode 100644 internal/model/activity_idempotency.go create mode 100644 internal/service/idempotency/store.go create mode 100644 internal/service/prizepool/pool.go create mode 100644 internal/service/prizepool/pool_test.go create mode 100644 internal/service/wheel/locking.go create mode 100644 migrations/045_wheel_limits_probability_retry.sql create mode 100644 migrations/046_activity_idempotency.sql create mode 100644 scripts/wheel_local_verify.go create mode 100644 scripts/wheel_pure_random_simulate.go create mode 100644 scripts/wheel_random_simulate.go create mode 100644 scripts/wheel_rtp_random_simulate.go diff --git a/internal/model/activity_idempotency.go b/internal/model/activity_idempotency.go new file mode 100644 index 0000000..da6da9b --- /dev/null +++ b/internal/model/activity_idempotency.go @@ -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" } diff --git a/internal/model/wheel_models.go b/internal/model/wheel_models.go index f1fcab9..5e81553 100644 --- a/internal/model/wheel_models.go +++ b/internal/model/wheel_models.go @@ -49,6 +49,9 @@ type WheelRewardConfig struct { DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` GoldAmount int64 `gorm:"column:gold_amount"` 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"` Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"` 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. type WheelDrawRecord struct { - ID int64 `gorm:"column:id;primaryKey"` - 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"` - 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"` - UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"` - DrawTimes int `gorm:"column:draw_times"` - PaidGold int64 `gorm:"column:paid_gold"` - RewardType string `gorm:"column:reward_type;size:32"` - ResourceID *int64 `gorm:"column:resource_id"` - ResourceType string `gorm:"column:resource_type;size:64"` - ResourceName string `gorm:"column:resource_name;size:255"` - ResourceURL string `gorm:"column:resource_url;size:512"` - CoverURL string `gorm:"column:cover_url;size:512"` - DurationDays int `gorm:"column:duration_days"` - DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` - GoldAmount int64 `gorm:"column:gold_amount"` - Probability int `gorm:"column:probability"` - Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"` - ErrorMessage string `gorm:"column:error_message;size:1024"` - CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` + 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"` + 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"` + UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"` + DrawTimes int `gorm:"column:draw_times"` + PaidGold int64 `gorm:"column:paid_gold"` + RewardConfigID int64 `gorm:"column:reward_config_id;index:idx_wheel_draw_reward_user,priority:1"` + RewardType string `gorm:"column:reward_type;size:32"` + ResourceID *int64 `gorm:"column:resource_id"` + ResourceType string `gorm:"column:resource_type;size:64"` + ResourceName string `gorm:"column:resource_name;size:255"` + ResourceURL string `gorm:"column:resource_url;size:512"` + CoverURL string `gorm:"column:cover_url;size:512"` + DurationDays int `gorm:"column:duration_days"` + DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` + GoldAmount int64 `gorm:"column:gold_amount"` + Probability int `gorm:"column:probability"` + Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"` + RetryCount int `gorm:"column:retry_count"` + 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. diff --git a/internal/router/router.go b/internal/router/router.go index 12ff108..67118bf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -88,7 +88,7 @@ func NewRouter( registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerUserBadgeRoutes(engine, javaClient, services.UserBadge) registerVipRoutes(engine, javaClient, services.VIP) - registerWheelRoutes(engine, javaClient, services.Wheel) + registerWheelRoutes(engine, cfg, javaClient, services.Wheel) registerSmashEggRoutes(engine, javaClient, services.SmashEgg) registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup) registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) diff --git a/internal/router/smash_egg_routes.go b/internal/router/smash_egg_routes.go index 27f841f..829210f 100644 --- a/internal/router/smash_egg_routes.go +++ b/internal/router/smash_egg_routes.go @@ -35,6 +35,7 @@ func registerSmashEggRoutes(engine *gin.Engine, javaClient authGateway, service writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } + req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey) resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req) if err != nil { writeError(c, err) @@ -157,7 +158,7 @@ func handleLegacySmashEggAction(c *gin.Context, javaClient authGateway, service if times <= 0 { 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": start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start"))) limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num"))) @@ -219,6 +220,25 @@ func firstNonEmptyQuery(c *gin.Context, keys ...string) string { 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) { c.JSON(http.StatusOK, gin.H{ "response_data": data, diff --git a/internal/router/wheel_routes.go b/internal/router/wheel_routes.go index 7809eaf..652e3cd 100644 --- a/internal/router/wheel_routes.go +++ b/internal/router/wheel_routes.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "chatapp3-golang/internal/config" "chatapp3-golang/internal/service/wheel" "github.com/gin-gonic/gin" @@ -13,7 +14,7 @@ import ( const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png" // 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 { return } @@ -34,6 +35,7 @@ func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelServic writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } + req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey) resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req) if err != nil { writeError(c, err) @@ -114,6 +116,32 @@ func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelServic } 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) } @@ -206,6 +234,7 @@ func legacyDrawHandler(wheelService *wheel.Service, category string) gin.Handler var req wheel.DrawRequest _ = c.ShouldBindJSON(&req) req.Category = category + req.IdempotencyKey = requestIdempotencyKey(c, req.IdempotencyKey) resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req) if err != nil { legacyError(c, err) diff --git a/internal/service/idempotency/store.go b/internal/service/idempotency/store.go new file mode 100644 index 0000000..3fe1b5a --- /dev/null +++ b/internal/service/idempotency/store.go @@ -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) +} diff --git a/internal/service/prizepool/pool.go b/internal/service/prizepool/pool.go new file mode 100644 index 0000000..d4c7bb5 --- /dev/null +++ b/internal/service/prizepool/pool.go @@ -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) +} diff --git a/internal/service/prizepool/pool_test.go b/internal/service/prizepool/pool_test.go new file mode 100644 index 0000000..0850bef --- /dev/null +++ b/internal/service/prizepool/pool_test.go @@ -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) + } +} diff --git a/internal/service/smashegg/draw.go b/internal/service/smashegg/draw.go index a4f9648..990f230 100644 --- a/internal/service/smashegg/draw.go +++ b/internal/service/smashegg/draw.go @@ -2,15 +2,16 @@ package smashegg import ( "context" - "crypto/rand" + "errors" "fmt" - "math/big" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/idempotency" + "chatapp3-golang/internal/service/prizepool" "chatapp3-golang/internal/utils" ) @@ -42,51 +43,62 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr if err != nil { 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) if err != nil { - return nil, err + return failIdempotency("", err) } 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) if err != nil { - return nil, err + return failIdempotency("", err) } selected := make([]model.SmashEggRewardConfig, 0, option.Times) for index := 0; index < option.Times; index++ { reward, pickErr := pickReward(rewardPool) if pickErr != nil { - return nil, pickErr + return failIdempotency("", pickErr) } selected = append(selected, reward) } drawID, err := utils.NextID() if err != nil { - return nil, err + return failIdempotency("", err) } drawNo := fmt.Sprintf("SMASHEGG%d", drawID) records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected) if err != nil { - return nil, err + return failIdempotency(drawNo, err) } if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil { _ = 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 { record := &records[index] if err := s.grantReward(ctx, record); err != nil { _ = 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.UpdateTime = time.Now() 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] } - 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) { @@ -111,32 +179,28 @@ func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model. } func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) { - total := 0 - for _, reward := range rewards { - if reward.Enabled && reward.Probability > 0 { - total += reward.Probability - } + items := make([]prizepool.PickResult, 0, len(rewards)) + for index, reward := range rewards { + items = append(items, prizepool.PickResult{ + 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") } - - n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) if err != nil { return model.SmashEggRewardConfig{}, 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 - } + if picked.Index < 0 || picked.Index >= len(rewards) { + return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } - return rewards[len(rewards)-1], nil + return rewards[picked.Index], nil } func (s *Service) createPendingRecords( diff --git a/internal/service/smashegg/draw_test.go b/internal/service/smashegg/draw_test.go index b64907d..5eeb705 100644 --- a/internal/service/smashegg/draw_test.go +++ b/internal/service/smashegg/draw_test.go @@ -3,15 +3,21 @@ package smashegg import ( "context" "testing" + "time" + "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) type fakeSmashEggGateway struct { giftCalls []fakeGiftBackpackCall propsCalls []integration.GivePropsBackpackRequest switches int + changes []integration.GoldReceiptCommand } type fakeGiftBackpackCall struct { @@ -20,7 +26,8 @@ type fakeGiftBackpackCall struct { 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 } @@ -86,3 +93,98 @@ func TestGrantResourceRewardRoutesGiftToGiftBackpack(t *testing.T) { 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) + } +} diff --git a/internal/service/smashegg/legacy.go b/internal/service/smashegg/legacy.go index 1ef4a94..7f11bc8 100644 --- a/internal/service/smashegg/legacy.go +++ b/internal/service/smashegg/legacy.go @@ -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. -func (s *Service) LegacyStartHunt(ctx context.Context, user AuthUser, times int) (map[string]any, error) { - resp, err := s.Draw(ctx, user, DrawRequest{Times: times}) +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, IdempotencyKey: idempotencyKey}) if err != nil { return nil, err } diff --git a/internal/service/smashegg/types.go b/internal/service/smashegg/types.go index ab4c6b8..d710585 100644 --- a/internal/service/smashegg/types.go +++ b/internal/service/smashegg/types.go @@ -246,7 +246,12 @@ type RewardGroupItem struct { // DrawRequest is an app draw request. 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. diff --git a/internal/service/wheel/config.go b/internal/service/wheel/config.go index 803b1cd..add406d 100644 --- a/internal/service/wheel/config.go +++ b/internal/service/wheel/config.go @@ -67,7 +67,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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() if idErr != nil { return idErr @@ -91,6 +91,10 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi 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 { return err } @@ -121,7 +125,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi }) for index, item := range category.Rewards { - nextID, rewardIDErr := utils.NextID() + rewardID, issuedCount, rewardIDErr := resolveRewardRowIdentity(existingRewards, item) if rewardIDErr != nil { return rewardIDErr } @@ -130,7 +134,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi sortValue = index + 1 } rewardRows = append(rewardRows, model.WheelRewardConfig{ - ID: nextID, + ID: rewardID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, @@ -144,6 +148,9 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi DisplayGoldAmount: item.DisplayGoldAmount, GoldAmount: item.GoldAmount, Probability: item.Probability, + TotalLimit: item.TotalLimit, + UserLimit: item.UserLimit, + IssuedCount: issuedCount, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, @@ -177,7 +184,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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() if idErr != nil { return idErr @@ -202,6 +209,10 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig category := normalized.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 { return err } @@ -232,7 +243,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards)) for index, item := range category.Rewards { - nextID, rewardIDErr := utils.NextID() + rewardID, issuedCount, rewardIDErr := resolveRewardRowIdentity(existingRewards, item) if rewardIDErr != nil { return rewardIDErr } @@ -241,7 +252,7 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig sortValue = index + 1 } rewardRows = append(rewardRows, model.WheelRewardConfig{ - ID: nextID, + ID: rewardID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, @@ -255,6 +266,9 @@ func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfig DisplayGoldAmount: item.DisplayGoldAmount, GoldAmount: item.GoldAmount, Probability: item.Probability, + TotalLimit: item.TotalLimit, + UserLimit: item.UserLimit, + IssuedCount: issuedCount, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, @@ -420,6 +434,12 @@ func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, re if item.Probability < 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 { enabledCount++ 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)) } 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 } +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) { + 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) if sysOrigin == "" { return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } 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) { return &configBundle{}, nil } @@ -455,19 +535,25 @@ func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEn } var categoryRows []model.WheelPoolConfig - categoryQuery := s.db.WithContext(ctx). + categoryQuery := tx.WithContext(ctx). Where("config_id = ?", configRow.ID). Order("sort asc, id asc") + if lockRows { + categoryQuery = withWriteLock(categoryQuery) + } if err := categoryQuery.Find(&categoryRows).Error; err != nil { return nil, err } - rewardQuery := s.db.WithContext(ctx). + rewardQuery := tx.WithContext(ctx). Where("config_id = ?", configRow.ID). Order("category asc, sort asc, id asc") if onlyEnabledRewards { rewardQuery = rewardQuery.Where("enabled = ?", true) } + if lockRows { + rewardQuery = withWriteLock(rewardQuery) + } var rewardRows []model.WheelRewardConfig if err := rewardQuery.Find(&rewardRows).Error; err != nil { return nil, err @@ -619,5 +705,8 @@ func rewardPayloadFromConfig(row model.WheelRewardConfig) RewardConfigPayload { GoldAmount: row.GoldAmount, Probability: row.Probability, ProbabilityPercent: probabilityPercent(row.Probability), + TotalLimit: row.TotalLimit, + UserLimit: row.UserLimit, + IssuedCount: row.IssuedCount, } } diff --git a/internal/service/wheel/draw.go b/internal/service/wheel/draw.go index f7fc0e4..9a6f4f3 100644 --- a/internal/service/wheel/draw.go +++ b/internal/service/wheel/draw.go @@ -2,16 +2,19 @@ package wheel import ( "context" - "crypto/rand" + "errors" "fmt" - "math/big" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/idempotency" + "chatapp3-golang/internal/service/prizepool" "chatapp3-golang/internal/utils" + + "gorm.io/gorm" ) // 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 { 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) if _, ok := categoryMeta(category); !ok { return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") } - times, err := normalizeDrawTimes(req.Times) if err != nil { 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) - for index := 0; index < times; index++ { - reward, pickErr := pickReward(rewards) - if pickErr != nil { - return nil, pickErr + idempotencyRow, replayed, err := s.beginDrawIdempotency(ctx, sysOrigin, user.UserID, category, 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) } - selected = append(selected, reward) + return nil, drawErr } + roomID, err := s.resolveCurrentRoomID(ctx, user) + if err != nil { + return failIdempotency("", err) + } drawID, err := utils.NextID() if err != nil { - return nil, err + return failIdempotency("", err) } drawNo := fmt.Sprintf("WHEEL%d", drawID) - records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, category, times, paidGold, selected) - if err != nil { - return nil, err + + var paidGold int64 + 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 { - _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) - return nil, mapWalletError("wheel_wallet_deduct_failed", err) + _ = s.releaseDrawReservations(ctx, records, drawStatusPayFailed, err.Error()) + return failIdempotency(drawNo, mapWalletError("wheel_wallet_deduct_failed", err)) } + grantFailed := false for index := range records { - record := &records[index] - if err := s.grantReward(ctx, record); err != nil { - _ = 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 + if err := s.ProcessDrawRecord(ctx, records[index].ID); err != nil { + grantFailed = true } } + records, _ = s.loadDrawRecords(ctx, drawNo) var balanceAfter int64 if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { 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) { @@ -182,37 +251,9 @@ func priceForTimes(configRow model.WheelPoolConfig, times int) (int64, error) { } } -func pickReward(rewards []model.WheelRewardConfig) (model.WheelRewardConfig, error) { - 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( +func (s *Service) createReservedPendingRecords( ctx context.Context, + tx *gorm.DB, drawNo string, userID int64, sysOrigin string, @@ -222,13 +263,22 @@ func (s *Service) createPendingRecords( rewards []model.WheelRewardConfig, ) ([]model.WheelDrawRecord, error) { now := time.Now() - records := make([]model.WheelDrawRecord, 0, len(rewards)) - for index, reward := range rewards { + records := make([]model.WheelDrawRecord, 0, times) + 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() if err != nil { return nil, err } - records = append(records, model.WheelDrawRecord{ + record := model.WheelDrawRecord{ ID: nextID, DrawNo: drawNo, EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1), @@ -237,6 +287,7 @@ func (s *Service) createPendingRecords( UserID: userID, DrawTimes: times, PaidGold: paidGold, + RewardConfigID: reward.ID, RewardType: normalizeRewardType(reward.RewardType), ResourceID: reward.ResourceID, ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)), @@ -250,17 +301,73 @@ func (s *Service) createPendingRecords( Status: drawStatusPending, CreateTime: now, UpdateTime: now, - }) - } - if len(records) == 0 { - return records, nil - } - if err := s.db.WithContext(ctx).Create(&records).Error; err != nil { - return nil, err + } + if err := tx.WithContext(ctx).Create(&record).Error; err != nil { + return nil, err + } + records = append(records, record) } 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 { if paidGold <= 0 { 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 { switch normalizeRewardType(record.RewardType) { case rewardTypeGold: if record.GoldAmount <= 0 { 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{ ReceiptType: walletReceiptIncome, UserID: record.UserID, @@ -347,6 +544,36 @@ func (s *Service) grantResourceReward(ctx context.Context, record *model.WheelDr 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 { return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("draw_no = ?", drawNo). @@ -357,6 +584,18 @@ func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status str }).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 { return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("id = ?", id). @@ -367,6 +606,15 @@ func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, }).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( drawNo string, userID int64, diff --git a/internal/service/wheel/draw_test.go b/internal/service/wheel/draw_test.go index d7e6bc6..ed19c4d 100644 --- a/internal/service/wheel/draw_test.go +++ b/internal/service/wheel/draw_test.go @@ -2,16 +2,26 @@ package wheel import ( "context" + "errors" "testing" + "time" + "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) type fakeWheelGateway struct { - giftCalls []fakeGiftBackpackCall - propsCalls []integration.GivePropsBackpackRequest - switches int + giftCalls []fakeGiftBackpackCall + propsCalls []integration.GivePropsBackpackRequest + switches int + changes []integration.GoldReceiptCommand + events map[string]struct{} + room integration.RoomProfile + failNextIncome bool } type fakeGiftBackpackCall struct { @@ -20,10 +30,30 @@ type fakeGiftBackpackCall struct { 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 } +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 { g.propsCalls = append(g.propsCalls, req) 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) { - return integration.RoomProfile{}, nil + return g.room, nil } 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) } } + +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) + } +} diff --git a/internal/service/wheel/locking.go b/internal/service/wheel/locking.go new file mode 100644 index 0000000..1386ae2 --- /dev/null +++ b/internal/service/wheel/locking.go @@ -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 + } +} diff --git a/internal/service/wheel/probability_test.go b/internal/service/wheel/probability_test.go index 8bb870b..e27b67b 100644 --- a/internal/service/wheel/probability_test.go +++ b/internal/service/wheel/probability_test.go @@ -4,7 +4,7 @@ import ( "math" "testing" - "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/prizepool" ) func TestPickRewardDistribution1000000Draws(t *testing.T) { @@ -13,43 +13,58 @@ func TestPickRewardDistribution1000000Draws(t *testing.T) { maxAllowedDriftPercent = 1.0 ) - rewards := []model.WheelRewardConfig{ - {ID: 1, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Coral Crown", Probability: 3000}, - {ID: 2, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Aurora Wing", Probability: 2200}, - {ID: 3, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Moon Card", Probability: 1500}, - {ID: 4, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Snow Pet", Probability: 1200}, - {ID: 5, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Love Banner", Probability: 900}, - {ID: 6, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Emerald Ring", Probability: 600}, - {ID: 7, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Royal Chest", Probability: 400}, - {ID: 8, Enabled: true, RewardType: rewardTypeGold, ResourceName: "Gold", Probability: 200}, + rewards := []struct { + id int64 + name string + probability int + }{ + {id: 1, name: "Coral Crown", probability: 300000}, + {id: 2, name: "Aurora Wing", probability: 220000}, + {id: 3, name: "Moon Card", probability: 150000}, + {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)) for index := 0; index < draws; index++ { - reward, err := pickReward(rewards) + reward, err := prizepool.PickWeighted(items) 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 { - expectedPercent := float64(reward.Probability) / probabilityTotal * 100 - actualPercent := float64(counts[reward.ID]) / draws * 100 + expectedPercent := float64(reward.probability) / probabilityTotal * 100 + actualPercent := float64(counts[reward.id]) / draws * 100 driftPercent := math.Abs(actualPercent - expectedPercent) t.Logf( "reward=%s configured=%.2f%% actual=%.2f%% drift=%.2f%% hits=%d/%d", - reward.ResourceName, + reward.name, expectedPercent, actualPercent, driftPercent, - counts[reward.ID], + counts[reward.id], draws, ) if driftPercent > maxAllowedDriftPercent { t.Fatalf( "reward %s probability drift %.2f%% exceeds %.2f%%", - reward.ResourceName, + reward.name, driftPercent, maxAllowedDriftPercent, ) diff --git a/internal/service/wheel/records.go b/internal/service/wheel/records.go index 97e0f3b..863d48a 100644 --- a/internal/service/wheel/records.go +++ b/internal/service/wheel/records.go @@ -101,6 +101,9 @@ func (s *Service) Hints(ctx context.Context, user AuthUser, category string, lim DisplayGoldAmount: row.DisplayGoldAmount, GoldAmount: row.GoldAmount, Probability: row.Probability, + TotalLimit: 0, + UserLimit: 0, + IssuedCount: 0, Count: 1, Name: name, Value: displayValueForRecord(row), @@ -131,6 +134,7 @@ func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload { Category: row.Category, DrawTimes: row.DrawTimes, PaidGold: row.PaidGold, + RewardConfigID: row.RewardConfigID, RewardType: row.RewardType, ResourceID: resourceIDValue(row.ResourceID), ResourceType: row.ResourceType, @@ -142,7 +146,9 @@ func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload { GoldAmount: row.GoldAmount, Probability: row.Probability, Status: row.Status, + RetryCount: row.RetryCount, ErrorMessage: row.ErrorMessage, + GrantTime: formatOptionalDateTime(row.GrantTime), CreateTime: formatDateTime(row.CreateTime), UpdateTime: formatDateTime(row.UpdateTime), } diff --git a/internal/service/wheel/types.go b/internal/service/wheel/types.go index c025dd6..5108f92 100644 --- a/internal/service/wheel/types.go +++ b/internal/service/wheel/types.go @@ -18,7 +18,7 @@ import ( ) const ( - probabilityTotal = 10000 + probabilityTotal = 1000000 defaultTimezone = "Asia/Riyadh" @@ -33,9 +33,11 @@ const ( resourceTypeRoomBadge = "ROOM_BADGE" resourceTypeGift = "GIFT" - drawStatusPending = "PENDING" - drawStatusSuccess = "SUCCESS" - drawStatusFailed = "FAILED" + drawStatusPending = "PENDING" + drawStatusGranting = "GRANTING" + drawStatusSuccess = "SUCCESS" + drawStatusFailed = "FAILED" + drawStatusPayFailed = "PAY_FAILED" walletReceiptIncome = "INCOME" walletReceiptExpenditure = "EXPENDITURE" @@ -99,6 +101,7 @@ type wheelDB interface { type wheelGateway interface { ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error IncrGiftBackpack(ctx context.Context, userID int64, giftID int64, quantity int) error SwitchUseProps(ctx context.Context, userID int64, propsID int64) error @@ -171,6 +174,8 @@ type RewardConfigInput struct { DisplayGoldAmount int64 `json:"displayGoldAmount"` GoldAmount int64 `json:"goldAmount"` Probability int `json:"probability"` + TotalLimit int64 `json:"totalLimit"` + UserLimit int64 `json:"userLimit"` } // ConfigResponse is returned to admin and app config readers. @@ -214,12 +219,20 @@ type RewardConfigPayload struct { GoldAmount int64 `json:"goldAmount,omitempty"` Probability int `json:"probability"` 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. type DrawRequest struct { - Category string `json:"category"` - Times int `json:"times"` + Category string `json:"category"` + 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. @@ -235,6 +248,8 @@ type DrawResponse struct { Rewards []DrawRewardPayload `json:"rewards"` Records []DrawRecordPayload `json:"records"` BalanceAfter int64 `json:"balanceAfter,omitempty"` + GrantFailed bool `json:"grantFailed,omitempty"` + Message string `json:"message,omitempty"` } // DrawRewardPayload aggregates identical rewards in one draw response. @@ -249,6 +264,9 @@ type DrawRewardPayload struct { DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` GoldAmount int64 `json:"goldAmount,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"` Name string `json:"name,omitempty"` Value int64 `json:"value,omitempty"` @@ -264,6 +282,7 @@ type DrawRecordPayload struct { Category string `json:"category"` DrawTimes int `json:"drawTimes"` PaidGold int64 `json:"paidGold"` + RewardConfigID int64 `json:"rewardConfigId,omitempty"` RewardType string `json:"rewardType"` ResourceID ResourceID `json:"resourceId,omitempty"` ResourceType string `json:"resourceType,omitempty"` @@ -275,7 +294,9 @@ type DrawRecordPayload struct { GoldAmount int64 `json:"goldAmount,omitempty"` Probability int `json:"probability"` Status string `json:"status"` + RetryCount int `json:"retryCount,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` + GrantTime string `json:"grantTime,omitempty"` CreateTime string `json:"createTime,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") } +func formatOptionalDateTime(t *time.Time) string { + if t == nil { + return "" + } + return formatDateTime(*t) +} + func trimErrorMessage(message string) string { message = strings.TrimSpace(message) if len(message) <= 1024 { @@ -385,7 +413,7 @@ func probabilityPercent(probability int) string { if probability <= 0 { 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) { diff --git a/migrations/033_resident_wheel.sql b/migrations/033_resident_wheel.sql index 8951f66..f6de643 100644 --- a/migrations/033_resident_wheel.sql +++ b/migrations/033_resident_wheel.sql @@ -40,7 +40,10 @@ CREATE TABLE IF NOT EXISTS `wheel_reward_config` ( `duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数', `display_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 '排序', `enabled` tinyint(1) NOT NULL DEFAULT '1' 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', `draw_times` int NOT NULL DEFAULT '1' 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', `resource_id` bigint DEFAULT NULL COMMENT '资源ID快照', `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 '资源有效天数快照', `display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格快照', `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照', - `probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,万分比', - `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED', + `probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,百万分比,100%=1000000', + `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 '失败原因', + `grant_time` datetime(3) DEFAULT NULL COMMENT '发奖成功时间', `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_wheel_draw_record_event` (`event_id`), KEY `idx_wheel_draw_record_draw_no` (`draw_no`), 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_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘抽奖记录'; diff --git a/migrations/045_wheel_limits_probability_retry.sql b/migrations/045_wheel_limits_probability_retry.sql new file mode 100644 index 0000000..5e5a077 --- /dev/null +++ b/migrations/045_wheel_limits_probability_retry.sql @@ -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; diff --git a/migrations/046_activity_idempotency.sql b/migrations/046_activity_idempotency.sql new file mode 100644 index 0000000..490226f --- /dev/null +++ b/migrations/046_activity_idempotency.sql @@ -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='活动付费请求幂等表'; diff --git a/scripts/wheel_local_verify.go b/scripts/wheel_local_verify.go new file mode 100644 index 0000000..f956a06 --- /dev/null +++ b/scripts/wheel_local_verify.go @@ -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) + } +} diff --git a/scripts/wheel_pure_random_simulate.go b/scripts/wheel_pure_random_simulate.go new file mode 100644 index 0000000..142b163 --- /dev/null +++ b/scripts/wheel_pure_random_simulate.go @@ -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) + } +} diff --git a/scripts/wheel_random_simulate.go b/scripts/wheel_random_simulate.go new file mode 100644 index 0000000..9080308 --- /dev/null +++ b/scripts/wheel_random_simulate.go @@ -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) + } +} diff --git a/scripts/wheel_rtp_random_simulate.go b/scripts/wheel_rtp_random_simulate.go new file mode 100644 index 0000000..62a7ab2 --- /dev/null +++ b/scripts/wheel_rtp_random_simulate.go @@ -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 +}