完善抽奖池

This commit is contained in:
hy001 2026-05-19 19:50:50 +08:00
parent 6195544950
commit 886bc8998b
26 changed files with 3709 additions and 190 deletions

View File

@ -0,0 +1,23 @@
package model
import "time"
// ActivityIdempotency stores client-side idempotency keys for paid activity draw
// requests. The same activity/sysOrigin/user/key may only map to one request hash.
type ActivityIdempotency struct {
ID int64 `gorm:"column:id;primaryKey"`
Activity string `gorm:"column:activity;size:64;uniqueIndex:uk_activity_idempotency_key,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_activity_idempotency_key,priority:2"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_activity_idempotency_key,priority:3"`
IdempotencyKey string `gorm:"column:idempotency_key;size:128;uniqueIndex:uk_activity_idempotency_key,priority:4"`
RequestHash string `gorm:"column:request_hash;size:64"`
BusinessNo string `gorm:"column:business_no;size:64;index:idx_activity_idempotency_business"`
Status string `gorm:"column:status;size:32;index:idx_activity_idempotency_status"`
ResponseJSON string `gorm:"column:response_json;type:longtext"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the activity idempotency table name.
func (ActivityIdempotency) TableName() string { return "activity_idempotency" }

View File

@ -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.

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -0,0 +1,157 @@
package idempotency
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
StatusProcessing = "PROCESSING"
StatusSucceeded = "SUCCEEDED"
StatusFailed = "FAILED"
MaxKeyLength = 128
)
var (
ErrEmptyKey = errors.New("idempotency: key is empty")
ErrKeyTooLong = errors.New("idempotency: key is too long")
ErrRequestMismatch = errors.New("idempotency: request hash mismatch")
)
type DB interface {
WithContext(ctx context.Context) *gorm.DB
}
type Store struct {
db DB
}
type BeginRequest struct {
Activity string
SysOrigin string
UserID int64
IdempotencyKey string
RequestHash string
}
type BeginResult struct {
Record model.ActivityIdempotency
Created bool
}
func NewStore(db DB) Store {
return Store{db: db}
}
func NormalizeKey(key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", ErrEmptyKey
}
if len(key) > MaxKeyLength {
return "", ErrKeyTooLong
}
return key, nil
}
func Hash(parts ...any) string {
payload, _ := json.Marshal(parts)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func (s Store) Begin(ctx context.Context, req BeginRequest) (BeginResult, error) {
key, err := NormalizeKey(req.IdempotencyKey)
if err != nil {
return BeginResult{}, err
}
now := time.Now()
id, err := utils.NextID()
if err != nil {
return BeginResult{}, err
}
row := model.ActivityIdempotency{
ID: id,
Activity: strings.ToUpper(strings.TrimSpace(req.Activity)),
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
UserID: req.UserID,
IdempotencyKey: key,
RequestHash: req.RequestHash,
Status: StatusProcessing,
CreateTime: now,
UpdateTime: now,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
if result.Error != nil {
return BeginResult{}, result.Error
}
if result.RowsAffected == 1 {
return BeginResult{Record: row, Created: true}, nil
}
var existing model.ActivityIdempotency
err = s.db.WithContext(ctx).
Where("activity = ? AND sys_origin = ? AND user_id = ? AND idempotency_key = ?", row.Activity, row.SysOrigin, row.UserID, row.IdempotencyKey).
First(&existing).Error
if err != nil {
return BeginResult{}, err
}
if existing.RequestHash != req.RequestHash {
return BeginResult{}, ErrRequestMismatch
}
return BeginResult{Record: existing, Created: false}, nil
}
func (s Store) MarkSucceeded(ctx context.Context, id int64, businessNo string, response any) error {
responseJSON, err := json.Marshal(response)
if err != nil {
return err
}
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
Where("id = ?", id).
Updates(map[string]any{
"business_no": strings.TrimSpace(businessNo),
"status": StatusSucceeded,
"response_json": string(responseJSON),
"error_message": "",
"update_time": time.Now(),
}).Error
}
func (s Store) MarkFailed(ctx context.Context, id int64, businessNo string, err error) error {
message := ""
if err != nil {
message = err.Error()
}
if len(message) > 1024 {
message = message[:1024]
}
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
Where("id = ?", id).
Updates(map[string]any{
"business_no": strings.TrimSpace(businessNo),
"status": StatusFailed,
"error_message": message,
"update_time": time.Now(),
}).Error
}
func DecodeResponse(record model.ActivityIdempotency, out any) error {
if strings.TrimSpace(record.ResponseJSON) == "" {
return fmt.Errorf("idempotency response is empty")
}
return json.Unmarshal([]byte(record.ResponseJSON), out)
}

View File

@ -0,0 +1,141 @@
package prizepool
import (
"context"
"crypto/rand"
"errors"
"math/big"
)
var (
// ErrNoAvailableItem means every configured item was filtered out by switch,
// weight, total limit, or user limit.
ErrNoAvailableItem = errors.New("prizepool: no available item")
// ErrUserIssueCounterRequired means at least one item has a user limit, but no
// counter was provided to check how many times the user has already received it.
ErrUserIssueCounterRequired = errors.New("prizepool: user issue counter required")
)
// Item is the activity-neutral shape used by the prize pool picker.
// TotalLimit/UserLimit use 0 to mean unlimited.
type Item struct {
ID int64
Enabled bool
Weight int
TotalLimit int64
UserLimit int64
IssuedCount int64
}
// PickResult returns the selected item and its original index in the input slice.
type PickResult struct {
Item Item
Index int
}
// UserIssueCounter returns how many cap-occupying records one user already has
// for a prize item.
type UserIssueCounter interface {
CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error)
}
// UserIssueCounterFunc adapts a function to UserIssueCounter.
type UserIssueCounterFunc func(ctx context.Context, itemID int64, userID int64) (int64, error)
func (f UserIssueCounterFunc) CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error) {
return f(ctx, itemID, userID)
}
// RandomIntn returns a number in [0, max).
type RandomIntn func(max int) (int, error)
// Picker owns the randomness used by the prize pool. A zero-value Picker uses
// crypto/rand so production callers do not need to seed a global RNG.
type Picker struct {
RandomIntn RandomIntn
}
// PickAvailable filters unavailable items and selects one by Weight.
func (p Picker) PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
available := make([]PickResult, 0, len(items))
for index, item := range items {
if !item.Enabled || item.Weight <= 0 {
continue
}
if item.TotalLimit > 0 && item.IssuedCount >= item.TotalLimit {
continue
}
if item.UserLimit > 0 {
if counter == nil {
return PickResult{}, ErrUserIssueCounterRequired
}
used, err := counter.CountUserIssues(ctx, item.ID, userID)
if err != nil {
return PickResult{}, err
}
if used >= item.UserLimit {
continue
}
}
available = append(available, PickResult{Item: item, Index: index})
}
if len(available) == 0 {
return PickResult{}, ErrNoAvailableItem
}
return p.PickWeighted(available)
}
// PickWeighted selects one already-filtered item by Weight.
func (p Picker) PickWeighted(items []PickResult) (PickResult, error) {
total := 0
for _, item := range items {
if item.Item.Enabled && item.Item.Weight > 0 {
total += item.Item.Weight
}
}
if total <= 0 {
return PickResult{}, ErrNoAvailableItem
}
n, err := p.randomIntn(total)
if err != nil {
return PickResult{}, err
}
target := n + 1
accumulated := 0
for _, item := range items {
if !item.Item.Enabled || item.Item.Weight <= 0 {
continue
}
accumulated += item.Item.Weight
if target <= accumulated {
return item, nil
}
}
return PickResult{}, ErrNoAvailableItem
}
func (p Picker) randomIntn(max int) (int, error) {
if max <= 0 {
return 0, ErrNoAvailableItem
}
if p.RandomIntn != nil {
return p.RandomIntn(max)
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err != nil {
return 0, err
}
return int(n.Int64()), nil
}
// PickAvailable uses a zero-value Picker.
func PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
return Picker{}.PickAvailable(ctx, items, userID, counter)
}
// PickWeighted uses a zero-value Picker.
func PickWeighted(items []PickResult) (PickResult, error) {
return Picker{}.PickWeighted(items)
}

View File

@ -0,0 +1,74 @@
package prizepool
import (
"context"
"errors"
"testing"
)
func TestPickAvailableSkipsUnavailableItems(t *testing.T) {
counter := UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
if itemID == 4 && userID == 1001 {
return 1, nil
}
return 0, nil
})
picker := Picker{RandomIntn: func(max int) (int, error) {
if max != 7 {
t.Fatalf("max = %d, want 7", max)
}
return 6, nil
}}
picked, err := picker.PickAvailable(context.Background(), []Item{
{ID: 1, Enabled: false, Weight: 100},
{ID: 2, Enabled: true, Weight: 0},
{ID: 3, Enabled: true, Weight: 5, TotalLimit: 10, IssuedCount: 10},
{ID: 4, Enabled: true, Weight: 8, UserLimit: 1},
{ID: 5, Enabled: true, Weight: 7},
}, 1001, counter)
if err != nil {
t.Fatalf("PickAvailable() error = %v", err)
}
if picked.Item.ID != 5 || picked.Index != 4 {
t.Fatalf("picked = %+v, want item 5 at original index 4", picked)
}
}
func TestPickAvailableNoAvailableItem(t *testing.T) {
_, err := Picker{}.PickAvailable(context.Background(), []Item{
{ID: 1, Enabled: true, Weight: 1, TotalLimit: 1, IssuedCount: 1},
}, 1001, nil)
if !errors.Is(err, ErrNoAvailableItem) {
t.Fatalf("error = %v, want ErrNoAvailableItem", err)
}
}
func TestPickAvailableRequiresCounterForUserLimit(t *testing.T) {
_, err := Picker{}.PickAvailable(context.Background(), []Item{
{ID: 1, Enabled: true, Weight: 1, UserLimit: 1},
}, 1001, nil)
if !errors.Is(err, ErrUserIssueCounterRequired) {
t.Fatalf("error = %v, want ErrUserIssueCounterRequired", err)
}
}
func TestPickWeightedUsesWeights(t *testing.T) {
picker := Picker{RandomIntn: func(max int) (int, error) {
if max != 10 {
t.Fatalf("max = %d, want 10", max)
}
return 3, nil
}}
picked, err := picker.PickWeighted([]PickResult{
{Item: Item{ID: 1, Enabled: true, Weight: 3}, Index: 0},
{Item: Item{ID: 2, Enabled: true, Weight: 7}, Index: 1},
})
if err != nil {
t.Fatalf("PickWeighted() error = %v", err)
}
if picked.Item.ID != 2 {
t.Fatalf("picked item = %d, want 2", picked.Item.ID)
}
}

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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.

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -0,0 +1,18 @@
package wheel
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func withWriteLock(db *gorm.DB) *gorm.DB {
if db == nil || db.Dialector == nil {
return db
}
switch db.Dialector.Name() {
case "mysql", "postgres", "sqlserver":
return db.Clauses(clause.Locking{Strength: "UPDATE"})
default:
return db
}
}

View File

@ -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,
)

View File

@ -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),
}

View File

@ -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) {

View File

@ -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='常驻活动转盘抽奖记录';

View File

@ -0,0 +1,116 @@
SET @current_schema := DATABASE();
SET @add_wheel_reward_total_limit_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'total_limit'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `total_limit` bigint NOT NULL DEFAULT 0 COMMENT ''奖品总发放上限0表示不限'' AFTER `probability`',
'SELECT 1'
);
PREPARE add_wheel_reward_total_limit_stmt FROM @add_wheel_reward_total_limit_sql;
EXECUTE add_wheel_reward_total_limit_stmt;
DEALLOCATE PREPARE add_wheel_reward_total_limit_stmt;
SET @add_wheel_reward_user_limit_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'user_limit'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `user_limit` bigint NOT NULL DEFAULT 0 COMMENT ''单用户发放上限0表示不限'' AFTER `total_limit`',
'SELECT 1'
);
PREPARE add_wheel_reward_user_limit_stmt FROM @add_wheel_reward_user_limit_sql;
EXECUTE add_wheel_reward_user_limit_stmt;
DEALLOCATE PREPARE add_wheel_reward_user_limit_stmt;
SET @add_wheel_reward_issued_count_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'issued_count'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `issued_count` bigint NOT NULL DEFAULT 0 COMMENT ''已占用发放数量PENDING/SUCCESS/FAILED发奖占用'' AFTER `user_limit`',
'SELECT 1'
);
PREPARE add_wheel_reward_issued_count_stmt FROM @add_wheel_reward_issued_count_sql;
EXECUTE add_wheel_reward_issued_count_stmt;
DEALLOCATE PREPARE add_wheel_reward_issued_count_stmt;
SET @add_wheel_draw_reward_config_id_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'reward_config_id'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `reward_config_id` bigint NOT NULL DEFAULT 0 COMMENT ''命中奖项配置ID'' AFTER `paid_gold`',
'SELECT 1'
);
PREPARE add_wheel_draw_reward_config_id_stmt FROM @add_wheel_draw_reward_config_id_sql;
EXECUTE add_wheel_draw_reward_config_id_stmt;
DEALLOCATE PREPARE add_wheel_draw_reward_config_id_stmt;
SET @add_wheel_draw_retry_count_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'retry_count'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `retry_count` int NOT NULL DEFAULT 0 COMMENT ''发奖尝试次数'' AFTER `status`',
'SELECT 1'
);
PREPARE add_wheel_draw_retry_count_stmt FROM @add_wheel_draw_retry_count_sql;
EXECUTE add_wheel_draw_retry_count_stmt;
DEALLOCATE PREPARE add_wheel_draw_retry_count_stmt;
SET @add_wheel_draw_grant_time_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'grant_time'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `grant_time` datetime(3) DEFAULT NULL COMMENT ''发奖成功时间'' AFTER `error_message`',
'SELECT 1'
);
PREPARE add_wheel_draw_grant_time_stmt FROM @add_wheel_draw_grant_time_sql;
EXECUTE add_wheel_draw_grant_time_stmt;
DEALLOCATE PREPARE add_wheel_draw_grant_time_stmt;
SET @add_wheel_draw_reward_user_idx_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND index_name = 'idx_wheel_draw_reward_user'
),
'ALTER TABLE `wheel_draw_record` ADD INDEX `idx_wheel_draw_reward_user` (`reward_config_id`, `user_id`, `status`)',
'SELECT 1'
);
PREPARE add_wheel_draw_reward_user_idx_stmt FROM @add_wheel_draw_reward_user_idx_sql;
EXECUTE add_wheel_draw_reward_user_idx_stmt;
DEALLOCATE PREPARE add_wheel_draw_reward_user_idx_stmt;
SET @wheel_reward_probability_max := (
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_reward_config`
);
SET @wheel_record_probability_max := (
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_draw_record`
);
UPDATE `wheel_reward_config`
SET `probability` = `probability` * 100
WHERE @wheel_reward_probability_max > 0
AND @wheel_reward_probability_max <= 10000;
UPDATE `wheel_draw_record`
SET `probability` = `probability` * 100
WHERE @wheel_record_probability_max > 0
AND @wheel_record_probability_max <= 10000;

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `activity_idempotency` (
`id` bigint NOT NULL COMMENT '主键ID',
`activity` varchar(64) NOT NULL COMMENT '活动标识',
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
`user_id` bigint NOT NULL COMMENT '用户ID',
`idempotency_key` varchar(128) NOT NULL COMMENT '客户端幂等键',
`request_hash` varchar(64) NOT NULL COMMENT '请求参数hash',
`business_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务单号',
`status` varchar(32) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCEEDED/FAILED',
`response_json` longtext COMMENT '成功响应快照',
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
`create_time` datetime(3) NOT NULL COMMENT '创建时间',
`update_time` datetime(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_idempotency_key` (`activity`, `sys_origin`, `user_id`, `idempotency_key`),
KEY `idx_activity_idempotency_business` (`business_no`),
KEY `idx_activity_idempotency_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动付费请求幂等表';

View File

@ -0,0 +1,541 @@
//go:build ignore
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/wheel"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
localSysOrigin = "LOCAL_WHEEL_VERIFY"
localCategory = "CLASSIC"
localTimezone = "Asia/Riyadh"
localUserID = int64(2042274349343506434)
localOtherUserID = int64(2042274349343506435)
localRoomID = int64(9001001)
probabilityTotal = 1000000
receiptIncome = "INCOME"
receiptExpense = "EXPENDITURE"
statusSuccess = "SUCCESS"
statusFailed = "FAILED"
statusPayFailed = "PAY_FAILED"
)
type localWheelGateway struct {
mu sync.Mutex
events map[string]integration.GoldReceiptCommand
changes []integration.GoldReceiptCommand
balance map[int64]int64
failNextIncome bool
failNextExpenditure bool
}
type verifySummary struct {
DSNHost string `json:"dsnHost"`
SysOrigin string `json:"sysOrigin"`
UserID int64 `json:"userId,string"`
OtherUserID int64 `json:"otherUserId,string"`
ProbabilityTotal int `json:"probabilityTotal"`
HighRewardConfigID int64 `json:"highRewardConfigId,string"`
HighRewardProbability int `json:"highRewardProbability"`
ProbabilityPercent string `json:"probabilityPercent"`
PayFailedRecords int64 `json:"payFailedRecords"`
FailedDrawNo string `json:"failedDrawNo"`
FailedRecordID int64 `json:"failedRecordId,string"`
FailedRecordStatus string `json:"failedRecordStatus"`
GrantFailedInDraw bool `json:"grantFailedInDraw"`
RetryStatus string `json:"retryStatus"`
RetryCount int `json:"retryCount"`
FallbackDrawNo string `json:"fallbackDrawNo"`
FallbackRecordID int64 `json:"fallbackRecordId,string"`
FallbackRewardConfigID int64 `json:"fallbackRewardConfigId,string"`
FallbackGoldAmount int64 `json:"fallbackGoldAmount"`
FallbackStatus string `json:"fallbackStatus"`
FallbackGrantFailed bool `json:"fallbackGrantFailed"`
HighRewardIssuedCount int64 `json:"highRewardIssuedCount"`
FallbackIssuedCount int64 `json:"fallbackIssuedCount"`
PaymentEvents int `json:"paymentEvents"`
IncomeEvents int `json:"incomeEvents"`
RecordPageTotal int64 `json:"recordPageTotal"`
UserBalance int64 `json:"userBalance"`
OtherUserBalance int64 `json:"otherUserBalance"`
}
func (g *localWheelGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.events == nil {
g.events = map[string]integration.GoldReceiptCommand{}
}
if g.balance == nil {
g.balance = map[int64]int64{}
}
if _, exists := g.events[cmd.EventID]; exists {
return nil
}
switch strings.ToUpper(cmd.ReceiptType) {
case receiptExpense:
if g.failNextExpenditure {
g.failNextExpenditure = false
return errors.New("local simulated payment failure")
}
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
case receiptIncome:
if g.failNextIncome {
g.failNextIncome = false
return errors.New("local simulated grant failure")
}
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
}
g.events[cmd.EventID] = cmd
g.changes = append(g.changes, cmd)
return nil
}
func (g *localWheelGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
g.mu.Lock()
defer g.mu.Unlock()
_, exists := g.events[eventID]
return exists, nil
}
func (g *localWheelGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
return nil
}
func (g *localWheelGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
return nil
}
func (g *localWheelGateway) SwitchUseProps(context.Context, int64, int64) error {
return nil
}
func (g *localWheelGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
return nil
}
func (g *localWheelGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
return nil
}
func (g *localWheelGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
g.mu.Lock()
defer g.mu.Unlock()
result := make(map[int64]int64, len(userIDs))
for _, userID := range userIDs {
result[userID] = g.balance[userID]
}
return result, nil
}
func (g *localWheelGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
return integration.RoomProfile{
ID: integration.Int64Value(localRoomID),
UserID: integration.Int64Value(userID),
RoomName: "local-wheel-verify-room",
SysOrigin: localSysOrigin,
}, nil
}
func (g *localWheelGateway) countReceipt(receiptType string) int {
g.mu.Lock()
defer g.mu.Unlock()
count := 0
for _, change := range g.changes {
if strings.EqualFold(change.ReceiptType, receiptType) {
count++
}
}
return count
}
func main() {
ctx := context.Background()
db, dsn, err := connectMySQL(ctx)
must(err, "connect mysql")
sqlDB, err := db.DB()
must(err, "unwrap mysql")
defer sqlDB.Close()
must(ensureWheelSchema(db), "ensure wheel schema")
must(cleanup(ctx, db, localSysOrigin), "cleanup local wheel data")
gateway := &localWheelGateway{
events: map[string]integration.GoldReceiptCommand{},
balance: map[int64]int64{localUserID: 1000, localOtherUserID: 1000},
}
service := wheel.NewService(config.Config{}, db, gateway)
saved, err := seedConfig(ctx, service)
must(err, "save wheel config")
assert(saved.ProbabilityTotal == probabilityTotal, "probability total mismatch")
reward, err := makeDrawDeterministic(ctx, db)
must(err, "make deterministic reward")
assert(reward.Probability == probabilityTotal && reward.TotalLimit == 1 && reward.UserLimit == 1, "deterministic reward mismatch")
configAfterSeed, err := service.GetConfig(ctx, localSysOrigin)
must(err, "get wheel config")
firstPayload := firstCategoryReward(configAfterSeed)
assert(firstPayload.Probability == probabilityTotal, "config probability mismatch")
gateway.failNextExpenditure = true
_, payErr := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
Category: localCategory,
Times: 1,
})
assert(payErr != nil, "payment failure draw should fail")
assert(appErrorCode(payErr) == "wheel_wallet_deduct_failed", fmt.Sprintf("unexpected payment error code: %s", appErrorCode(payErr)))
payFailedCount := countRecords(ctx, db, statusPayFailed)
assert(payFailedCount == 1, fmt.Sprintf("pay failed records = %d, want 1", payFailedCount))
rewardAfterPayFailure := loadReward(ctx, db, reward.ID)
assert(rewardAfterPayFailure.IssuedCount == 0, fmt.Sprintf("issued count after pay failure = %d, want 0", rewardAfterPayFailure.IssuedCount))
gateway.failNextIncome = true
drawResp, err := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
Category: localCategory,
Times: 1,
})
must(err, "draw with simulated grant failure")
assert(drawResp.GrantFailed, "draw should expose grantFailed")
assert(len(drawResp.Records) == 1, "draw record count mismatch")
failedRecord := loadRecord(ctx, db, drawResp.Records[0].ID)
assert(failedRecord.Status == statusFailed, fmt.Sprintf("failed record status = %s", failedRecord.Status))
assert(gateway.countReceipt(receiptExpense) == 1, "payment should be charged once after grant failure")
assert(gateway.countReceipt(receiptIncome) == 0, "income should not be granted before retry")
must(service.RetryDrawRecord(ctx, failedRecord.ID), "retry failed reward")
retriedRecord := loadRecord(ctx, db, failedRecord.ID)
assert(retriedRecord.Status == statusSuccess, fmt.Sprintf("retry status = %s", retriedRecord.Status))
assert(gateway.countReceipt(receiptExpense) == 1, "retry should not charge again")
assert(gateway.countReceipt(receiptIncome) == 1, "retry should grant reward once")
fallbackReward, err := enableFallbackReward(ctx, db)
must(err, "enable fallback reward")
fallbackResp, err := service.Draw(ctx, common.AuthUser{UserID: localOtherUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
Category: localCategory,
Times: 1,
})
must(err, "draw after high reward exhausted")
assert(!fallbackResp.GrantFailed, "fallback draw should be user-transparent")
assert(len(fallbackResp.Records) == 1, "fallback draw record count mismatch")
fallbackRecord := loadRecord(ctx, db, fallbackResp.Records[0].ID)
assert(fallbackRecord.Status == statusSuccess, fmt.Sprintf("fallback record status = %s", fallbackRecord.Status))
assert(fallbackRecord.RewardConfigID == fallbackReward.ID, "fallback draw did not hit fallback reward")
assert(fallbackRecord.RewardConfigID != reward.ID, "fallback draw hit exhausted high reward")
assert(gateway.countReceipt(receiptExpense) == 2, "fallback draw should charge once")
assert(gateway.countReceipt(receiptIncome) == 2, "fallback draw should grant reward once")
finalReward := loadReward(ctx, db, reward.ID)
finalFallbackReward := loadReward(ctx, db, fallbackReward.ID)
assert(finalReward.IssuedCount == 1, fmt.Sprintf("high reward issued count = %d, want 1", finalReward.IssuedCount))
assert(finalFallbackReward.IssuedCount == 1, fmt.Sprintf("fallback issued count = %d, want 1", finalFallbackReward.IssuedCount))
page, err := service.PageRecords(ctx, localSysOrigin, localCategory, 0, "", 1, 20)
must(err, "page records")
balances, err := gateway.MapGoldBalance(ctx, []int64{localUserID, localOtherUserID})
must(err, "map balance")
summary := verifySummary{
DSNHost: dsnLabel(dsn),
SysOrigin: localSysOrigin,
UserID: localUserID,
OtherUserID: localOtherUserID,
ProbabilityTotal: configAfterSeed.ProbabilityTotal,
HighRewardConfigID: finalReward.ID,
HighRewardProbability: firstPayload.Probability,
ProbabilityPercent: firstPayload.ProbabilityPercent,
PayFailedRecords: payFailedCount,
FailedDrawNo: drawResp.DrawNo,
FailedRecordID: failedRecord.ID,
FailedRecordStatus: failedRecord.Status,
GrantFailedInDraw: drawResp.GrantFailed,
RetryStatus: retriedRecord.Status,
RetryCount: retriedRecord.RetryCount,
FallbackDrawNo: fallbackResp.DrawNo,
FallbackRecordID: fallbackRecord.ID,
FallbackRewardConfigID: fallbackRecord.RewardConfigID,
FallbackGoldAmount: fallbackRecord.GoldAmount,
FallbackStatus: fallbackRecord.Status,
FallbackGrantFailed: fallbackResp.GrantFailed,
HighRewardIssuedCount: finalReward.IssuedCount,
FallbackIssuedCount: finalFallbackReward.IssuedCount,
PaymentEvents: gateway.countReceipt(receiptExpense),
IncomeEvents: gateway.countReceipt(receiptIncome),
RecordPageTotal: page.Total,
UserBalance: balances[localUserID],
OtherUserBalance: balances[localOtherUserID],
}
out, err := json.MarshalIndent(summary, "", " ")
must(err, "marshal summary")
fmt.Println(string(out))
}
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
candidates := uniqueStrings([]string{
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
})
var errs []string
for _, dsn := range candidates {
if dsn == "" {
continue
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
sqlDB, err := db.DB()
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
_ = sqlDB.Close()
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
return db, dsn, nil
}
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
}
func ensureWheelSchema(db *gorm.DB) error {
return db.AutoMigrate(
&model.WheelConfig{},
&model.WheelPoolConfig{},
&model.WheelRewardConfig{},
&model.WheelDrawRecord{},
&model.ActivityIdempotency{},
)
}
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var configIDs []int64
if err := tx.Model(&model.WheelConfig{}).
Where("sys_origin = ?", sysOrigin).
Pluck("id", &configIDs).Error; err != nil {
return err
}
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
return err
}
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
return err
}
if len(configIDs) == 0 {
return nil
}
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
return err
}
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
return err
}
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
})
}
func seedConfig(ctx context.Context, service *wheel.Service) (*wheel.ConfigResponse, error) {
probabilities := []int{999993, 1, 1, 1, 1, 1, 1, 1}
rewards := make([]wheel.RewardConfigInput, 0, len(probabilities))
for index, probability := range probabilities {
totalLimit := int64(0)
userLimit := int64(0)
if index == 0 {
totalLimit = 1
userLimit = 1
}
rewards = append(rewards, wheel.RewardConfigInput{
Enabled: true,
Sort: index + 1,
RewardType: "GOLD",
GoldAmount: int64(7 + index),
Probability: probability,
TotalLimit: totalLimit,
UserLimit: userLimit,
})
}
return service.SaveCategoryConfig(ctx, wheel.SaveCategoryConfigRequest{
SysOrigin: localSysOrigin,
Timezone: localTimezone,
Category: wheel.CategoryConfigInput{
Category: localCategory,
Enabled: true,
PriceOneGold: 3,
PriceTenGold: 30,
PriceFiftyGold: 150,
Rewards: rewards,
},
})
}
func makeDrawDeterministic(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
var reward model.WheelRewardConfig
if err := db.WithContext(ctx).
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 1).
First(&reward).Error; err != nil {
return model.WheelRewardConfig{}, err
}
return reward, db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WheelRewardConfig{}).
Where("sys_origin = ? AND category = ?", localSysOrigin, localCategory).
Updates(map[string]any{
"probability": 0,
"total_limit": 0,
"user_limit": 0,
"issued_count": 0,
"update_time": time.Now(),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.WheelRewardConfig{}).
Where("id = ?", reward.ID).
Updates(map[string]any{
"probability": probabilityTotal,
"total_limit": 1,
"user_limit": 1,
"issued_count": 0,
"update_time": time.Now(),
}).Error; err != nil {
return err
}
return tx.Where("id = ?", reward.ID).First(&reward).Error
})
}
func enableFallbackReward(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
var reward model.WheelRewardConfig
if err := db.WithContext(ctx).
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 2).
First(&reward).Error; err != nil {
return model.WheelRewardConfig{}, err
}
err := db.WithContext(ctx).Model(&model.WheelRewardConfig{}).
Where("id = ?", reward.ID).
Updates(map[string]any{
"probability": probabilityTotal,
"total_limit": 0,
"user_limit": 0,
"issued_count": 0,
"update_time": time.Now(),
}).Error
if err != nil {
return model.WheelRewardConfig{}, err
}
return reward, db.WithContext(ctx).Where("id = ?", reward.ID).First(&reward).Error
}
func firstCategoryReward(resp *wheel.ConfigResponse) wheel.RewardConfigPayload {
for _, category := range resp.Categories {
if category.Category == localCategory && len(category.Rewards) > 0 {
return category.Rewards[0]
}
}
log.Fatal("missing first category reward")
return wheel.RewardConfigPayload{}
}
func countRecords(ctx context.Context, db *gorm.DB, status string) int64 {
var count int64
must(db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
Where("sys_origin = ? AND status = ?", localSysOrigin, status).
Count(&count).Error, "count records")
return count
}
func loadReward(ctx context.Context, db *gorm.DB, id int64) model.WheelRewardConfig {
var reward model.WheelRewardConfig
must(db.WithContext(ctx).Where("id = ?", id).First(&reward).Error, "load reward")
return reward
}
func loadRecord(ctx context.Context, db *gorm.DB, id int64) model.WheelDrawRecord {
var record model.WheelDrawRecord
must(db.WithContext(ctx).Where("id = ?", id).First(&record).Error, "load record")
return record
}
func appErrorCode(err error) string {
var appErr *common.AppError
if errors.As(err, &appErr) {
return appErr.Code
}
return ""
}
func dsnLabel(dsn string) string {
if at := strings.Index(dsn, "@tcp("); at >= 0 {
rest := dsn[at+len("@tcp("):]
hostEnd := strings.Index(rest, ")")
if hostEnd >= 0 {
host := rest[:hostEnd]
dbName := ""
after := rest[hostEnd+1:]
if strings.HasPrefix(after, "/") {
dbPart := strings.TrimPrefix(after, "/")
if question := strings.Index(dbPart, "?"); question >= 0 {
dbPart = dbPart[:question]
}
dbName = dbPart
}
if dbName != "" {
return host + "/" + dbName
}
return host
}
}
return "custom-dsn"
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func must(err error, step string) {
if err != nil {
log.Fatalf("%s: %v", step, err)
}
}
func assert(ok bool, message string) {
if !ok {
log.Fatal(message)
}
}

View File

@ -0,0 +1,388 @@
//go:build ignore
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/service/prizepool"
)
const (
probabilityTotal = 1000000
defaultTarget = 100000
defaultUsers = 5000
)
type categorySpec struct {
Category string
RewardCount int
GoldBase int64
}
type rewardConfig struct {
ID int64
Category string
Sort int
GoldAmount int64
Probability int
TotalLimit int64
UserLimit int64
IssuedCount int64
}
type rewardSummary struct {
Category string `json:"category"`
Sort int `json:"sort"`
RewardID int64 `json:"rewardId"`
GoldAmount int64 `json:"goldAmount"`
Probability int `json:"probability"`
TotalLimit int64 `json:"totalLimit,omitempty"`
UserLimit int64 `json:"userLimit,omitempty"`
IssuedCount int64 `json:"issuedCount"`
}
type summary struct {
Seed int64 `json:"seed"`
TargetRewardItems int `json:"targetRewardItems"`
ActualRewardItems int64 `json:"actualRewardItems"`
DrawRequests int64 `json:"drawRequests"`
RandomUsers int `json:"randomUsers"`
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
LimitedRewards int `json:"limitedRewards"`
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
TotalLimitViolations int `json:"totalLimitViolations"`
UserLimitViolations int `json:"userLimitViolations"`
NoAvailableHits int64 `json:"noAvailableHits"`
LimitedRewardDetails []rewardSummary `json:"limitedRewardDetails"`
TopRewards []rewardSummary `json:"topRewards"`
ElapsedMillis int64 `json:"elapsedMillis"`
}
type userRewardKey struct {
UserID int64
RewardID int64
}
func main() {
ctx := context.Background()
startedAt := time.Now()
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
target := envInt("WHEEL_SIM_TARGET_REWARDS", defaultTarget)
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
rng := rand.New(rand.NewSource(seed))
rewardsByCategory, allRewards := buildRandomConfig(rng)
userRewardCounts := map[userRewardKey]int64{}
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
categoryRewardItems := map[string]int64{}
uniqueUsers := map[int64]struct{}{}
var drawRequests int64
var rewardItems int64
var noAvailableHits int64
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
return rng.Intn(max), nil
}}
for rewardItems < int64(target) {
userID := int64(1 + rng.Intn(userCount))
category := specs()[rng.Intn(len(specs()))].Category
categoryRewards := rewardsByCategory[category]
times := randomDrawTimes(rng)
drawRequests++
uniqueUsers[userID] = struct{}{}
timesKey := strconv.Itoa(times)
drawTimesRequests[timesKey]++
for i := 0; i < times; i++ {
picked, err := picker.PickAvailable(ctx, prizePoolItems(categoryRewards), userID, prizepool.UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
return userRewardCounts[userRewardKey{UserID: userID, RewardID: itemID}], nil
}))
if errors.Is(err, prizepool.ErrNoAvailableItem) {
noAvailableHits++
continue
}
if err != nil {
log.Fatal(err)
}
reward := categoryRewards[picked.Index]
reward.IssuedCount++
if reward.UserLimit > 0 {
userRewardCounts[userRewardKey{UserID: userID, RewardID: reward.ID}]++
}
rewardItems++
drawTimesRewardItems[timesKey]++
categoryRewardItems[category]++
}
}
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(allRewards)
totalLimitViolations := countTotalLimitViolations(allRewards)
userLimitViolations := countUserLimitViolations(allRewards, userRewardCounts)
limitedRewardDetails := limitedRewardSummaries(allRewards)
topRewards := topRewardSummaries(allRewards, 14)
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
assert(noAvailableHits == 0, fmt.Sprintf("no available hits = %d", noAvailableHits))
result := summary{
Seed: seed,
TargetRewardItems: target,
ActualRewardItems: rewardItems,
DrawRequests: drawRequests,
RandomUsers: userCount,
UniqueUsersDrawn: len(uniqueUsers),
DrawTimesRequests: drawTimesRequests,
DrawTimesRewardItems: drawTimesRewardItems,
CategoryRewardItems: categoryRewardItems,
LimitedRewards: limitedRewards,
ExhaustedLimitedRewards: exhaustedLimitedRewards,
TotalLimitViolations: totalLimitViolations,
UserLimitViolations: userLimitViolations,
NoAvailableHits: noAvailableHits,
LimitedRewardDetails: limitedRewardDetails,
TopRewards: topRewards,
ElapsedMillis: time.Since(startedAt).Milliseconds(),
}
out, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
}
func specs() []categorySpec {
return []categorySpec{
{Category: "CLASSIC", RewardCount: 8, GoldBase: 10},
{Category: "LUXURY", RewardCount: 8, GoldBase: 100},
{Category: "ADVANCED", RewardCount: 12, GoldBase: 500},
}
}
func buildRandomConfig(rng *rand.Rand) (map[string][]*rewardConfig, []*rewardConfig) {
byCategory := map[string][]*rewardConfig{}
all := make([]*rewardConfig, 0, 28)
var rewardID int64 = 1
for _, spec := range specs() {
probabilities := randomProbabilities(spec.RewardCount, rng)
rewards := make([]*rewardConfig, 0, spec.RewardCount)
for index := 0; index < spec.RewardCount; index++ {
totalLimit := int64(0)
userLimit := int64(0)
if index == 0 {
totalLimit = int64(120 + rng.Intn(81))
userLimit = 1
}
if index == 1 {
totalLimit = int64(260 + rng.Intn(121))
userLimit = 2
}
reward := &rewardConfig{
ID: rewardID,
Category: spec.Category,
Sort: index + 1,
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
Probability: probabilities[index],
TotalLimit: totalLimit,
UserLimit: userLimit,
}
rewardID++
rewards = append(rewards, reward)
all = append(all, reward)
}
byCategory[spec.Category] = rewards
}
return byCategory, all
}
func randomProbabilities(count int, rng *rand.Rand) []int {
raw := make([]int, count)
for i := 0; i < count; i++ {
raw[i] = rng.Intn(1000) + 1
}
raw[0] += 7000 + rng.Intn(3000)
raw[1] += 3500 + rng.Intn(2000)
rawTotal := 0
for _, value := range raw {
rawTotal += value
}
result := make([]int, count)
assigned := 0
for i, value := range raw {
probability := value * probabilityTotal / rawTotal
if probability <= 0 {
probability = 1
}
result[i] = probability
assigned += probability
}
result[0] += probabilityTotal - assigned
return result
}
func randomDrawTimes(rng *rand.Rand) int {
switch rng.Intn(3) {
case 0:
return 1
case 1:
return 10
default:
return 50
}
}
func prizePoolItems(rewards []*rewardConfig) []prizepool.Item {
items := make([]prizepool.Item, 0, len(rewards))
for _, reward := range rewards {
items = append(items, prizepool.Item{
ID: reward.ID,
Enabled: true,
Weight: reward.Probability,
TotalLimit: reward.TotalLimit,
UserLimit: reward.UserLimit,
IssuedCount: reward.IssuedCount,
})
}
return items
}
func countLimitedRewards(rewards []*rewardConfig) (int, int) {
limited := 0
exhausted := 0
for _, reward := range rewards {
if reward.TotalLimit <= 0 {
continue
}
limited++
if reward.IssuedCount >= reward.TotalLimit {
exhausted++
}
}
return limited, exhausted
}
func countTotalLimitViolations(rewards []*rewardConfig) int {
violations := 0
for _, reward := range rewards {
if reward.TotalLimit > 0 && reward.IssuedCount > reward.TotalLimit {
violations++
}
}
return violations
}
func countUserLimitViolations(rewards []*rewardConfig, userRewardCounts map[userRewardKey]int64) int {
userLimits := map[int64]int64{}
for _, reward := range rewards {
if reward.UserLimit > 0 {
userLimits[reward.ID] = reward.UserLimit
}
}
violations := 0
for key, count := range userRewardCounts {
if limit := userLimits[key.RewardID]; limit > 0 && count > limit {
violations++
}
}
return violations
}
func limitedRewardSummaries(rewards []*rewardConfig) []rewardSummary {
result := make([]rewardSummary, 0)
for _, reward := range rewards {
if reward.TotalLimit <= 0 {
continue
}
result = append(result, rewardSummary{
Category: reward.Category,
Sort: reward.Sort,
RewardID: reward.ID,
GoldAmount: reward.GoldAmount,
Probability: reward.Probability,
TotalLimit: reward.TotalLimit,
UserLimit: reward.UserLimit,
IssuedCount: reward.IssuedCount,
})
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Category == result[j].Category {
return result[i].Sort < result[j].Sort
}
return result[i].Category < result[j].Category
})
return result
}
func topRewardSummaries(rewards []*rewardConfig, limit int) []rewardSummary {
copied := append([]*rewardConfig(nil), rewards...)
sort.SliceStable(copied, func(i, j int) bool {
if copied[i].IssuedCount == copied[j].IssuedCount {
return copied[i].ID < copied[j].ID
}
return copied[i].IssuedCount > copied[j].IssuedCount
})
if len(copied) > limit {
copied = copied[:limit]
}
result := make([]rewardSummary, 0, len(copied))
for _, reward := range copied {
result = append(result, rewardSummary{
Category: reward.Category,
Sort: reward.Sort,
RewardID: reward.ID,
GoldAmount: reward.GoldAmount,
Probability: reward.Probability,
TotalLimit: reward.TotalLimit,
UserLimit: reward.UserLimit,
IssuedCount: reward.IssuedCount,
})
}
return result
}
func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envInt64(name string, fallback int64) int64 {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func assert(ok bool, message string) {
if !ok {
log.Fatal(message)
}
}

View File

@ -0,0 +1,638 @@
//go:build ignore
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/wheel"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
simSysOrigin = "LOCAL_WHEEL_RANDOM_SIM"
simRoomID = int64(9002001)
simUserIDBase = int64(2042274349300000000)
probabilityTotal = 1000000
targetRewardCount = 100000
receiptIncome = "INCOME"
receiptExpense = "EXPENDITURE"
)
type categorySpec struct {
Category string
RewardCount int
PriceOneGold int64
PriceTenGold int64
PriceFiftyGold int64
GoldBase int64
}
type simGateway struct {
mu sync.Mutex
events map[string]integration.GoldReceiptCommand
changes []integration.GoldReceiptCommand
balance map[int64]int64
}
type topRewardSummary struct {
Category string `json:"category"`
Sort int `json:"sort"`
RewardID int64 `json:"rewardId,string"`
GoldAmount int64 `json:"goldAmount"`
Probability int `json:"probability"`
TotalLimit int64 `json:"totalLimit,omitempty"`
UserLimit int64 `json:"userLimit,omitempty"`
IssuedCount int64 `json:"issuedCount"`
ActualCount int64 `json:"actualCount"`
}
type simSummary struct {
DSNHost string `json:"dsnHost"`
SysOrigin string `json:"sysOrigin"`
Seed int64 `json:"seed"`
TargetRewardItems int `json:"targetRewardItems"`
ActualRewardItems int64 `json:"actualRewardItems"`
DrawRequests int64 `json:"drawRequests"`
RandomUsers int `json:"randomUsers"`
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
StatusCounts map[string]int64 `json:"statusCounts"`
PaymentEvents int `json:"paymentEvents"`
IncomeEvents int `json:"incomeEvents"`
LimitedRewards int `json:"limitedRewards"`
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
TotalLimitViolations int `json:"totalLimitViolations"`
UserLimitViolations int `json:"userLimitViolations"`
IssuedCountMismatches int `json:"issuedCountMismatches"`
TopRewards []topRewardSummary `json:"topRewards"`
ElapsedMillis int64 `json:"elapsedMillis"`
}
func (g *simGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.events == nil {
g.events = map[string]integration.GoldReceiptCommand{}
}
if g.balance == nil {
g.balance = map[int64]int64{}
}
if _, exists := g.events[cmd.EventID]; exists {
return nil
}
switch strings.ToUpper(cmd.ReceiptType) {
case receiptExpense:
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
case receiptIncome:
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
}
g.events[cmd.EventID] = cmd
g.changes = append(g.changes, cmd)
return nil
}
func (g *simGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
g.mu.Lock()
defer g.mu.Unlock()
_, exists := g.events[eventID]
return exists, nil
}
func (g *simGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
return nil
}
func (g *simGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
return nil
}
func (g *simGateway) SwitchUseProps(context.Context, int64, int64) error {
return nil
}
func (g *simGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
return nil
}
func (g *simGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
return nil
}
func (g *simGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
g.mu.Lock()
defer g.mu.Unlock()
result := make(map[int64]int64, len(userIDs))
for _, userID := range userIDs {
result[userID] = g.balance[userID]
}
return result, nil
}
func (g *simGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
return integration.RoomProfile{
ID: integration.Int64Value(simRoomID),
UserID: integration.Int64Value(userID),
RoomName: "local-wheel-random-sim-room",
SysOrigin: simSysOrigin,
}, nil
}
func (g *simGateway) countReceipt(receiptType string) int {
g.mu.Lock()
defer g.mu.Unlock()
count := 0
for _, change := range g.changes {
if strings.EqualFold(change.ReceiptType, receiptType) {
count++
}
}
return count
}
func main() {
ctx := context.Background()
startedAt := time.Now()
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
target := envInt("WHEEL_SIM_TARGET_REWARDS", targetRewardCount)
userCount := envInt("WHEEL_SIM_USERS", 5000)
rng := rand.New(rand.NewSource(seed))
db, dsn, err := connectMySQL(ctx)
must(err, "connect mysql")
sqlDB, err := db.DB()
must(err, "unwrap mysql")
defer sqlDB.Close()
must(ensureWheelSchema(db), "ensure wheel schema")
must(cleanup(ctx, db, simSysOrigin), "cleanup previous sim data")
gateway := &simGateway{
events: map[string]integration.GoldReceiptCommand{},
balance: make(map[int64]int64, userCount),
}
for i := 0; i < userCount; i++ {
gateway.balance[simUserIDBase+int64(i)] = 1_000_000_000
}
service := wheel.NewService(config.Config{}, db, gateway)
must(seedRandomConfig(ctx, service, rng), "seed random wheel config")
categorySpecs := specs()
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
categoryRewardItems := map[string]int64{}
uniqueUsers := map[int64]struct{}{}
var drawRequests int64
var rewardItems int64
for rewardItems < int64(target) {
userID := simUserIDBase + int64(rng.Intn(userCount))
category := categorySpecs[rng.Intn(len(categorySpecs))].Category
times := randomDrawTimes(rng)
resp, err := service.Draw(ctx, common.AuthUser{UserID: userID, SysOrigin: simSysOrigin}, wheel.DrawRequest{
Category: category,
Times: times,
})
if err != nil {
log.Fatalf("draw failed after requests=%d rewards=%d user=%d category=%s times=%d: %v", drawRequests, rewardItems, userID, category, times, err)
}
if resp.GrantFailed {
log.Fatalf("grant failed after requests=%d rewards=%d drawNo=%s", drawRequests, rewardItems, resp.DrawNo)
}
drawRequests++
uniqueUsers[userID] = struct{}{}
key := strconv.Itoa(times)
drawTimesRequests[key]++
drawTimesRewardItems[key] += int64(len(resp.Records))
for _, record := range resp.Records {
rewardItems++
categoryRewardItems[record.Category]++
}
if rewardItems > 0 && rewardItems%20000 < int64(times) {
log.Printf("progress rewards=%d requests=%d", rewardItems, drawRequests)
}
}
statusCounts := loadStatusCounts(ctx, db)
topRewards := loadTopRewards(ctx, db, 12)
totalLimitViolations := countTotalLimitViolations(ctx, db)
userLimitViolations := countUserLimitViolations(ctx, db)
issuedCountMismatches := countIssuedCountMismatches(ctx, db)
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(ctx, db)
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
assert(issuedCountMismatches == 0, fmt.Sprintf("issued count mismatches = %d", issuedCountMismatches))
assert(statusCounts["SUCCESS"] == rewardItems, fmt.Sprintf("success count = %d, rewards = %d", statusCounts["SUCCESS"], rewardItems))
summary := simSummary{
DSNHost: dsnLabel(dsn),
SysOrigin: simSysOrigin,
Seed: seed,
TargetRewardItems: target,
ActualRewardItems: rewardItems,
DrawRequests: drawRequests,
RandomUsers: userCount,
UniqueUsersDrawn: len(uniqueUsers),
DrawTimesRequests: drawTimesRequests,
DrawTimesRewardItems: drawTimesRewardItems,
CategoryRewardItems: categoryRewardItems,
StatusCounts: statusCounts,
PaymentEvents: gateway.countReceipt(receiptExpense),
IncomeEvents: gateway.countReceipt(receiptIncome),
LimitedRewards: limitedRewards,
ExhaustedLimitedRewards: exhaustedLimitedRewards,
TotalLimitViolations: totalLimitViolations,
UserLimitViolations: userLimitViolations,
IssuedCountMismatches: issuedCountMismatches,
TopRewards: topRewards,
ElapsedMillis: time.Since(startedAt).Milliseconds(),
}
out, err := json.MarshalIndent(summary, "", " ")
must(err, "marshal summary")
fmt.Println(string(out))
}
func specs() []categorySpec {
return []categorySpec{
{Category: "CLASSIC", RewardCount: 8, PriceOneGold: 3, PriceTenGold: 30, PriceFiftyGold: 150, GoldBase: 10},
{Category: "LUXURY", RewardCount: 8, PriceOneGold: 30, PriceTenGold: 300, PriceFiftyGold: 1500, GoldBase: 100},
{Category: "ADVANCED", RewardCount: 12, PriceOneGold: 100, PriceTenGold: 1000, PriceFiftyGold: 5000, GoldBase: 500},
}
}
func seedRandomConfig(ctx context.Context, service *wheel.Service, rng *rand.Rand) error {
categories := make([]wheel.CategoryConfigInput, 0, len(specs()))
for _, spec := range specs() {
probabilities := randomProbabilities(spec.RewardCount, rng)
rewards := make([]wheel.RewardConfigInput, 0, spec.RewardCount)
for index := 0; index < spec.RewardCount; index++ {
totalLimit := int64(0)
userLimit := int64(0)
if index == 0 {
totalLimit = int64(120 + rng.Intn(81))
userLimit = 1
}
if index == 1 {
totalLimit = int64(260 + rng.Intn(121))
userLimit = 2
}
rewards = append(rewards, wheel.RewardConfigInput{
Enabled: true,
Sort: index + 1,
RewardType: "GOLD",
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
Probability: probabilities[index],
TotalLimit: totalLimit,
UserLimit: userLimit,
})
}
categories = append(categories, wheel.CategoryConfigInput{
Category: spec.Category,
Enabled: true,
PriceOneGold: spec.PriceOneGold,
PriceTenGold: spec.PriceTenGold,
PriceFiftyGold: spec.PriceFiftyGold,
Rewards: rewards,
})
}
_, err := service.SaveConfig(ctx, wheel.SaveConfigRequest{
SysOrigin: simSysOrigin,
Enabled: true,
Timezone: "Asia/Riyadh",
Categories: categories,
})
return err
}
func randomProbabilities(count int, rng *rand.Rand) []int {
raw := make([]int, count)
for i := 0; i < count; i++ {
raw[i] = rng.Intn(1000) + 1
}
raw[0] += 7000 + rng.Intn(3000)
raw[1] += 3500 + rng.Intn(2000)
rawTotal := 0
for _, value := range raw {
rawTotal += value
}
result := make([]int, count)
assigned := 0
for i, value := range raw {
probability := value * probabilityTotal / rawTotal
if probability <= 0 {
probability = 1
}
result[i] = probability
assigned += probability
}
result[0] += probabilityTotal - assigned
return result
}
func randomDrawTimes(rng *rand.Rand) int {
switch rng.Intn(3) {
case 0:
return 1
case 1:
return 10
default:
return 50
}
}
func loadStatusCounts(ctx context.Context, db *gorm.DB) map[string]int64 {
var rows []struct {
Status string
Count int64
}
must(db.WithContext(ctx).Raw(`
SELECT status, COUNT(*) AS count
FROM wheel_draw_record
WHERE sys_origin = ?
GROUP BY status
`, simSysOrigin).Scan(&rows).Error, "load status counts")
result := map[string]int64{}
for _, row := range rows {
result[row.Status] = row.Count
}
return result
}
func loadTopRewards(ctx context.Context, db *gorm.DB, limit int) []topRewardSummary {
var rows []topRewardSummary
must(db.WithContext(ctx).Raw(`
SELECT
c.category AS category,
c.sort AS sort,
c.id AS reward_id,
c.gold_amount AS gold_amount,
c.probability AS probability,
c.total_limit AS total_limit,
c.user_limit AS user_limit,
c.issued_count AS issued_count,
COUNT(r.id) AS actual_count
FROM wheel_reward_config c
LEFT JOIN wheel_draw_record r
ON r.reward_config_id = c.id
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
WHERE c.sys_origin = ?
GROUP BY c.category, c.sort, c.id, c.gold_amount, c.probability, c.total_limit, c.user_limit, c.issued_count
ORDER BY actual_count DESC, c.category ASC, c.sort ASC
LIMIT ?
`, simSysOrigin, limit).Scan(&rows).Error, "load top rewards")
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ActualCount == rows[j].ActualCount {
return rows[i].RewardID < rows[j].RewardID
}
return rows[i].ActualCount > rows[j].ActualCount
})
return rows
}
func countTotalLimitViolations(ctx context.Context, db *gorm.DB) int {
var count int64
must(db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM (
SELECT c.id
FROM wheel_reward_config c
LEFT JOIN wheel_draw_record r
ON r.reward_config_id = c.id
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
WHERE c.sys_origin = ?
AND c.total_limit > 0
GROUP BY c.id, c.total_limit
HAVING COUNT(r.id) > c.total_limit
) t
`, simSysOrigin).Scan(&count).Error, "count total limit violations")
return int(count)
}
func countUserLimitViolations(ctx context.Context, db *gorm.DB) int {
var count int64
must(db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM (
SELECT r.reward_config_id, r.user_id
FROM wheel_draw_record r
JOIN wheel_reward_config c ON c.id = r.reward_config_id
WHERE r.sys_origin = ?
AND c.user_limit > 0
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
GROUP BY r.reward_config_id, r.user_id, c.user_limit
HAVING COUNT(*) > c.user_limit
) t
`, simSysOrigin).Scan(&count).Error, "count user limit violations")
return int(count)
}
func countIssuedCountMismatches(ctx context.Context, db *gorm.DB) int {
var count int64
must(db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM (
SELECT c.id
FROM wheel_reward_config c
LEFT JOIN wheel_draw_record r
ON r.reward_config_id = c.id
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
WHERE c.sys_origin = ?
GROUP BY c.id, c.issued_count
HAVING c.issued_count <> COUNT(r.id)
) t
`, simSysOrigin).Scan(&count).Error, "count issued count mismatches")
return int(count)
}
func countLimitedRewards(ctx context.Context, db *gorm.DB) (int, int) {
var rows []struct {
TotalLimit int64
IssuedCount int64
}
must(db.WithContext(ctx).Raw(`
SELECT total_limit AS total_limit, issued_count AS issued_count
FROM wheel_reward_config
WHERE sys_origin = ?
AND total_limit > 0
`, simSysOrigin).Scan(&rows).Error, "count limited rewards")
exhausted := 0
for _, row := range rows {
if row.IssuedCount >= row.TotalLimit {
exhausted++
}
}
return len(rows), exhausted
}
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
candidates := uniqueStrings([]string{
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
})
var errs []string
for _, dsn := range candidates {
if dsn == "" {
continue
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
sqlDB, err := db.DB()
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
_ = sqlDB.Close()
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
continue
}
return db, dsn, nil
}
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
}
func ensureWheelSchema(db *gorm.DB) error {
return db.AutoMigrate(
&model.WheelConfig{},
&model.WheelPoolConfig{},
&model.WheelRewardConfig{},
&model.WheelDrawRecord{},
&model.ActivityIdempotency{},
)
}
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var configIDs []int64
if err := tx.Model(&model.WheelConfig{}).
Where("sys_origin = ?", sysOrigin).
Pluck("id", &configIDs).Error; err != nil {
return err
}
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
return err
}
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
return err
}
if len(configIDs) == 0 {
return nil
}
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
return err
}
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
return err
}
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
})
}
func appErrorCode(err error) string {
var appErr *common.AppError
if errors.As(err, &appErr) {
return appErr.Code
}
return ""
}
func dsnLabel(dsn string) string {
if at := strings.Index(dsn, "@tcp("); at >= 0 {
rest := dsn[at+len("@tcp("):]
hostEnd := strings.Index(rest, ")")
if hostEnd >= 0 {
host := rest[:hostEnd]
dbName := ""
after := rest[hostEnd+1:]
if strings.HasPrefix(after, "/") {
dbPart := strings.TrimPrefix(after, "/")
if question := strings.Index(dbPart, "?"); question >= 0 {
dbPart = dbPart[:question]
}
dbName = dbPart
}
if dbName != "" {
return host + "/" + dbName
}
return host
}
}
return "custom-dsn"
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envInt64(name string, fallback int64) int64 {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func must(err error, step string) {
if err != nil {
log.Fatalf("%s: %v", step, err)
}
}
func assert(ok bool, message string) {
if !ok {
log.Fatal(message)
}
}

View File

@ -0,0 +1,534 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/service/prizepool"
)
const (
probabilityTotal = 1000000
defaultTarget = 1000000
defaultUsers = 10000
targetRTPBasis = 9800
)
type categoryConfig struct {
Category string
PricePerDraw int64
Rewards []*rewardConfig
}
type rewardConfig struct {
ID int64
Category string
Sort int
GoldAmount int64
Probability int
Hits int64
}
type categorySummary struct {
Category string `json:"category"`
DrawItems int64 `json:"drawItems"`
PaidGold int64 `json:"paidGold"`
PayoutGold int64 `json:"payoutGold"`
RTPPercent float64 `json:"rtpPercent"`
TheoryRTP float64 `json:"theoryRtpPercent"`
ExpectedPayout float64 `json:"expectedPayoutGold"`
DeviationGold float64 `json:"deviationGold"`
}
type rewardSummary struct {
Category string `json:"category"`
Sort int `json:"sort"`
RewardID int64 `json:"rewardId"`
GoldAmount int64 `json:"goldAmount"`
Probability int `json:"probability"`
TheoryRate float64 `json:"theoryRatePercent"`
Hits int64 `json:"hits"`
ActualRate float64 `json:"actualRatePercent"`
ExpectedHits float64 `json:"expectedHits"`
HitDeviation float64 `json:"hitDeviation"`
}
type userSummary struct {
ActiveUsers int `json:"activeUsers"`
PositiveUsers int `json:"positiveUsers"`
NegativeUsers int `json:"negativeUsers"`
BreakEvenUsers int `json:"breakEvenUsers"`
MinRTP float64 `json:"minRtpPercent"`
P50RTP float64 `json:"p50RtpPercent"`
P90RTP float64 `json:"p90RtpPercent"`
P99RTP float64 `json:"p99RtpPercent"`
MaxRTP float64 `json:"maxRtpPercent"`
MinNetGold int64 `json:"minNetGold"`
P50NetGold int64 `json:"p50NetGold"`
P90NetGold int64 `json:"p90NetGold"`
P99NetGold int64 `json:"p99NetGold"`
MaxNetGold int64 `json:"maxNetGold"`
AveragePaidGold float64 `json:"averagePaidGold"`
AverageNetGold float64 `json:"averageNetGold"`
}
type summary struct {
Seed int64 `json:"seed"`
TargetRTPPercent float64 `json:"targetRtpPercent"`
TargetDrawItems int `json:"targetDrawItems"`
ActualDrawItems int64 `json:"actualDrawItems"`
DrawRequests int64 `json:"drawRequests"`
RandomUsers int `json:"randomUsers"`
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
DrawTimesItems map[string]int64 `json:"drawTimesItems"`
PaidGold int64 `json:"paidGold"`
PayoutGold int64 `json:"payoutGold"`
NetGold int64 `json:"netGold"`
ActualRTPPercent float64 `json:"actualRtpPercent"`
ExpectedPayoutGold float64 `json:"expectedPayoutGold"`
DeviationGold float64 `json:"deviationGold"`
DeviationPercent float64 `json:"deviationPercent"`
StopLossEnabled bool `json:"stopLossEnabled"`
TotalLimitViolations int `json:"totalLimitViolations"`
UserLimitViolations int `json:"userLimitViolations"`
CategorySummaries []categorySummary `json:"categorySummaries"`
UserSummary userSummary `json:"userSummary"`
TopRewardsByHits []rewardSummary `json:"topRewardsByHits"`
RewardSummaries map[string][]rewardSummary `json:"rewardSummaries"`
ElapsedMillis int64 `json:"elapsedMillis"`
}
type userStat struct {
paid int64
payout int64
}
type categoryStat struct {
items int64
paid int64
payout int64
}
func main() {
startedAt := time.Now()
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
target := envInt("WHEEL_SIM_TARGET_DRAWS", defaultTarget)
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
rng := rand.New(rand.NewSource(seed))
categories := buildRTPConfig(rng)
itemsByCategory := map[string][]prizepool.PickResult{}
for _, category := range categories {
itemsByCategory[category.Category] = prizePoolItems(category.Rewards)
}
userStats := make([]userStat, userCount)
categoryStats := map[string]*categoryStat{}
for _, category := range categories {
categoryStats[category.Category] = &categoryStat{}
}
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
drawTimesItems := map[string]int64{"1": 0, "10": 0, "50": 0}
uniqueUsers := map[int]struct{}{}
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
return rng.Intn(max), nil
}}
var drawRequests int64
var drawItems int64
var paidGold int64
var payoutGold int64
for drawItems < int64(target) {
userIndex := rng.Intn(userCount)
category := categories[rng.Intn(len(categories))]
times := randomDrawTimes(rng)
timesKey := strconv.Itoa(times)
requestPaid := category.PricePerDraw * int64(times)
drawRequests++
drawTimesRequests[timesKey]++
drawTimesItems[timesKey] += int64(times)
uniqueUsers[userIndex] = struct{}{}
userStats[userIndex].paid += requestPaid
categoryStats[category.Category].paid += requestPaid
paidGold += requestPaid
for i := 0; i < times; i++ {
picked, err := picker.PickWeighted(itemsByCategory[category.Category])
if err != nil {
log.Fatal(err)
}
reward := category.Rewards[picked.Index]
reward.Hits++
drawItems++
payoutGold += reward.GoldAmount
userStats[userIndex].payout += reward.GoldAmount
categoryStats[category.Category].items++
categoryStats[category.Category].payout += reward.GoldAmount
}
}
expectedPayout := float64(paidGold) * float64(targetRTPBasis) / 10000
categorySummaries := buildCategorySummaries(categories, categoryStats)
rewardSummaries, topRewards := buildRewardSummaries(categories, categoryStats, 14)
result := summary{
Seed: seed,
TargetRTPPercent: float64(targetRTPBasis) / 100,
TargetDrawItems: target,
ActualDrawItems: drawItems,
DrawRequests: drawRequests,
RandomUsers: userCount,
UniqueUsersDrawn: len(uniqueUsers),
DrawTimesRequests: drawTimesRequests,
DrawTimesItems: drawTimesItems,
PaidGold: paidGold,
PayoutGold: payoutGold,
NetGold: payoutGold - paidGold,
ActualRTPPercent: percent(float64(payoutGold), float64(paidGold)),
ExpectedPayoutGold: expectedPayout,
DeviationGold: float64(payoutGold) - expectedPayout,
DeviationPercent: percent(float64(payoutGold)-expectedPayout, expectedPayout),
StopLossEnabled: false,
TotalLimitViolations: 0,
UserLimitViolations: 0,
CategorySummaries: categorySummaries,
UserSummary: buildUserSummary(userStats),
TopRewardsByHits: topRewards,
RewardSummaries: rewardSummaries,
ElapsedMillis: time.Since(startedAt).Milliseconds(),
}
out, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
}
func buildRTPConfig(rng *rand.Rand) []categoryConfig {
configs := []struct {
category string
price int64
values []int64
}{
{
category: "CLASSIC",
price: 100,
values: []int64{20, 40, 60, 80, 120, 200, 500, 2000},
},
{
category: "LUXURY",
price: 1000,
values: []int64{200, 400, 700, 1000, 1500, 3000, 10000, 50000},
},
{
category: "ADVANCED",
price: 10000,
values: []int64{2000, 4000, 7000, 10000, 15000, 30000, 80000, 200000, 500000, 1000000, 3000000, 10000000},
},
}
var rewardID int64 = 1
result := make([]categoryConfig, 0, len(configs))
for _, cfg := range configs {
probabilities := probabilitiesForTargetRTP(cfg.values, cfg.price, rng)
rewards := make([]*rewardConfig, 0, len(cfg.values))
for index, value := range cfg.values {
rewards = append(rewards, &rewardConfig{
ID: rewardID,
Category: cfg.category,
Sort: index + 1,
GoldAmount: value,
Probability: probabilities[index],
})
rewardID++
}
result = append(result, categoryConfig{
Category: cfg.category,
PricePerDraw: cfg.price,
Rewards: rewards,
})
}
return result
}
func probabilitiesForTargetRTP(values []int64, price int64, rng *rand.Rand) []int {
targetValueNumerator := price * int64(targetRTPBasis) * int64(probabilityTotal)
targetValueNumerator /= 10000
for attempt := 0; attempt < 200; attempt++ {
probabilities := make([]int, len(values))
noiseTotal := 50000
remainingNoise := noiseTotal
for i := range probabilities {
if i == len(probabilities)-1 {
probabilities[i] = remainingNoise
break
}
value := rng.Intn(remainingNoise / 2)
probabilities[i] = value
remainingNoise -= value
}
rng.Shuffle(len(probabilities), func(i, j int) {
probabilities[i], probabilities[j] = probabilities[j], probabilities[i]
})
usedProbability := 0
usedValueNumerator := int64(0)
for index, probability := range probabilities {
usedProbability += probability
usedValueNumerator += int64(probability) * values[index]
}
remainingProbability := probabilityTotal - usedProbability
remainingValueNumerator := targetValueNumerator - usedValueNumerator
if remainingProbability <= 0 || remainingValueNumerator <= 0 {
continue
}
remainingAverage := float64(remainingValueNumerator) / float64(remainingProbability)
lowIndex, highIndex := bracketIndexes(values, remainingAverage)
if lowIndex < 0 || highIndex < 0 || lowIndex == highIndex {
continue
}
low := values[lowIndex]
high := values[highIndex]
highProbability := int(math.Round(float64(remainingValueNumerator-int64(remainingProbability)*low) / float64(high-low)))
if highProbability < 0 || highProbability > remainingProbability {
continue
}
lowProbability := remainingProbability - highProbability
probabilities[lowIndex] += lowProbability
probabilities[highIndex] += highProbability
normalizeProbabilityTotal(probabilities)
return probabilities
}
log.Fatal("failed to build probability config for target RTP")
return nil
}
func bracketIndexes(values []int64, target float64) (int, int) {
lowIndex := -1
highIndex := -1
for index, value := range values {
if float64(value) <= target {
lowIndex = index
}
if float64(value) >= target {
highIndex = index
break
}
}
return lowIndex, highIndex
}
func normalizeProbabilityTotal(probabilities []int) {
total := 0
maxIndex := 0
for index, probability := range probabilities {
total += probability
if probability > probabilities[maxIndex] {
maxIndex = index
}
}
probabilities[maxIndex] += probabilityTotal - total
}
func prizePoolItems(rewards []*rewardConfig) []prizepool.PickResult {
items := make([]prizepool.PickResult, 0, len(rewards))
for index, reward := range rewards {
items = append(items, prizepool.PickResult{
Item: prizepool.Item{
ID: reward.ID,
Enabled: true,
Weight: reward.Probability,
},
Index: index,
})
}
return items
}
func randomDrawTimes(rng *rand.Rand) int {
switch rng.Intn(3) {
case 0:
return 1
case 1:
return 10
default:
return 50
}
}
func buildCategorySummaries(categories []categoryConfig, stats map[string]*categoryStat) []categorySummary {
result := make([]categorySummary, 0, len(categories))
for _, category := range categories {
stat := stats[category.Category]
theoryRTP := theoreticalRTP(category)
expectedPayout := float64(stat.paid) * theoryRTP / 100
result = append(result, categorySummary{
Category: category.Category,
DrawItems: stat.items,
PaidGold: stat.paid,
PayoutGold: stat.payout,
RTPPercent: percent(float64(stat.payout), float64(stat.paid)),
TheoryRTP: theoryRTP,
ExpectedPayout: expectedPayout,
DeviationGold: float64(stat.payout) - expectedPayout,
})
}
return result
}
func buildRewardSummaries(categories []categoryConfig, stats map[string]*categoryStat, topLimit int) (map[string][]rewardSummary, []rewardSummary) {
byCategory := map[string][]rewardSummary{}
all := make([]rewardSummary, 0)
for _, category := range categories {
stat := stats[category.Category]
rows := make([]rewardSummary, 0, len(category.Rewards))
for _, reward := range category.Rewards {
expectedHits := float64(stat.items) * float64(reward.Probability) / probabilityTotal
row := rewardSummary{
Category: reward.Category,
Sort: reward.Sort,
RewardID: reward.ID,
GoldAmount: reward.GoldAmount,
Probability: reward.Probability,
TheoryRate: float64(reward.Probability) / 10000,
Hits: reward.Hits,
ActualRate: percent(float64(reward.Hits), float64(stat.items)),
ExpectedHits: expectedHits,
HitDeviation: float64(reward.Hits) - expectedHits,
}
rows = append(rows, row)
all = append(all, row)
}
byCategory[category.Category] = rows
}
sort.SliceStable(all, func(i, j int) bool {
if all[i].Hits == all[j].Hits {
return all[i].RewardID < all[j].RewardID
}
return all[i].Hits > all[j].Hits
})
if len(all) > topLimit {
all = all[:topLimit]
}
return byCategory, all
}
func theoreticalRTP(category categoryConfig) float64 {
expected := float64(0)
for _, reward := range category.Rewards {
expected += float64(reward.Probability) / probabilityTotal * float64(reward.GoldAmount)
}
return percent(expected, float64(category.PricePerDraw))
}
func buildUserSummary(userStats []userStat) userSummary {
rtps := make([]float64, 0, len(userStats))
nets := make([]int64, 0, len(userStats))
active := 0
positive := 0
negative := 0
breakEven := 0
totalPaid := int64(0)
totalNet := int64(0)
for _, stat := range userStats {
if stat.paid <= 0 {
continue
}
active++
net := stat.payout - stat.paid
switch {
case net > 0:
positive++
case net < 0:
negative++
default:
breakEven++
}
rtps = append(rtps, percent(float64(stat.payout), float64(stat.paid)))
nets = append(nets, net)
totalPaid += stat.paid
totalNet += net
}
sort.Float64s(rtps)
sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] })
return userSummary{
ActiveUsers: active,
PositiveUsers: positive,
NegativeUsers: negative,
BreakEvenUsers: breakEven,
MinRTP: percentileFloat(rtps, 0),
P50RTP: percentileFloat(rtps, 0.50),
P90RTP: percentileFloat(rtps, 0.90),
P99RTP: percentileFloat(rtps, 0.99),
MaxRTP: percentileFloat(rtps, 1),
MinNetGold: percentileInt(nets, 0),
P50NetGold: percentileInt(nets, 0.50),
P90NetGold: percentileInt(nets, 0.90),
P99NetGold: percentileInt(nets, 0.99),
MaxNetGold: percentileInt(nets, 1),
AveragePaidGold: float64(totalPaid) / float64(active),
AverageNetGold: float64(totalNet) / float64(active),
}
}
func percentileFloat(values []float64, p float64) float64 {
if len(values) == 0 {
return 0
}
index := int(math.Round(p * float64(len(values)-1)))
return values[index]
}
func percentileInt(values []int64, p float64) int64 {
if len(values) == 0 {
return 0
}
index := int(math.Round(p * float64(len(values)-1)))
return values[index]
}
func percent(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return numerator / denominator * 100
}
func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envInt64(name string, fallback int64) int64 {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}