完善抽奖池
This commit is contained in:
parent
6195544950
commit
886bc8998b
23
internal/model/activity_idempotency.go
Normal file
23
internal/model/activity_idempotency.go
Normal file
@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ActivityIdempotency stores client-side idempotency keys for paid activity draw
|
||||
// requests. The same activity/sysOrigin/user/key may only map to one request hash.
|
||||
type ActivityIdempotency struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Activity string `gorm:"column:activity;size:64;uniqueIndex:uk_activity_idempotency_key,priority:1"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_activity_idempotency_key,priority:2"`
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_activity_idempotency_key,priority:3"`
|
||||
IdempotencyKey string `gorm:"column:idempotency_key;size:128;uniqueIndex:uk_activity_idempotency_key,priority:4"`
|
||||
RequestHash string `gorm:"column:request_hash;size:64"`
|
||||
BusinessNo string `gorm:"column:business_no;size:64;index:idx_activity_idempotency_business"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_activity_idempotency_status"`
|
||||
ResponseJSON string `gorm:"column:response_json;type:longtext"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:1024"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
// TableName returns the activity idempotency table name.
|
||||
func (ActivityIdempotency) TableName() string { return "activity_idempotency" }
|
||||
@ -49,6 +49,9 @@ type WheelRewardConfig struct {
|
||||
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
|
||||
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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
157
internal/service/idempotency/store.go
Normal file
157
internal/service/idempotency/store.go
Normal file
@ -0,0 +1,157 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusProcessing = "PROCESSING"
|
||||
StatusSucceeded = "SUCCEEDED"
|
||||
StatusFailed = "FAILED"
|
||||
|
||||
MaxKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyKey = errors.New("idempotency: key is empty")
|
||||
ErrKeyTooLong = errors.New("idempotency: key is too long")
|
||||
ErrRequestMismatch = errors.New("idempotency: request hash mismatch")
|
||||
)
|
||||
|
||||
type DB interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db DB
|
||||
}
|
||||
|
||||
type BeginRequest struct {
|
||||
Activity string
|
||||
SysOrigin string
|
||||
UserID int64
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
}
|
||||
|
||||
type BeginResult struct {
|
||||
Record model.ActivityIdempotency
|
||||
Created bool
|
||||
}
|
||||
|
||||
func NewStore(db DB) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func NormalizeKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", ErrEmptyKey
|
||||
}
|
||||
if len(key) > MaxKeyLength {
|
||||
return "", ErrKeyTooLong
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func Hash(parts ...any) string {
|
||||
payload, _ := json.Marshal(parts)
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s Store) Begin(ctx context.Context, req BeginRequest) (BeginResult, error) {
|
||||
key, err := NormalizeKey(req.IdempotencyKey)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
row := model.ActivityIdempotency{
|
||||
ID: id,
|
||||
Activity: strings.ToUpper(strings.TrimSpace(req.Activity)),
|
||||
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
|
||||
UserID: req.UserID,
|
||||
IdempotencyKey: key,
|
||||
RequestHash: req.RequestHash,
|
||||
Status: StatusProcessing,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if result.Error != nil {
|
||||
return BeginResult{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return BeginResult{Record: row, Created: true}, nil
|
||||
}
|
||||
|
||||
var existing model.ActivityIdempotency
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("activity = ? AND sys_origin = ? AND user_id = ? AND idempotency_key = ?", row.Activity, row.SysOrigin, row.UserID, row.IdempotencyKey).
|
||||
First(&existing).Error
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
if existing.RequestHash != req.RequestHash {
|
||||
return BeginResult{}, ErrRequestMismatch
|
||||
}
|
||||
return BeginResult{Record: existing, Created: false}, nil
|
||||
}
|
||||
|
||||
func (s Store) MarkSucceeded(ctx context.Context, id int64, businessNo string, response any) error {
|
||||
responseJSON, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"business_no": strings.TrimSpace(businessNo),
|
||||
"status": StatusSucceeded,
|
||||
"response_json": string(responseJSON),
|
||||
"error_message": "",
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s Store) MarkFailed(ctx context.Context, id int64, businessNo string, err error) error {
|
||||
message := ""
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
if len(message) > 1024 {
|
||||
message = message[:1024]
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"business_no": strings.TrimSpace(businessNo),
|
||||
"status": StatusFailed,
|
||||
"error_message": message,
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DecodeResponse(record model.ActivityIdempotency, out any) error {
|
||||
if strings.TrimSpace(record.ResponseJSON) == "" {
|
||||
return fmt.Errorf("idempotency response is empty")
|
||||
}
|
||||
return json.Unmarshal([]byte(record.ResponseJSON), out)
|
||||
}
|
||||
141
internal/service/prizepool/pool.go
Normal file
141
internal/service/prizepool/pool.go
Normal file
@ -0,0 +1,141 @@
|
||||
package prizepool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoAvailableItem means every configured item was filtered out by switch,
|
||||
// weight, total limit, or user limit.
|
||||
ErrNoAvailableItem = errors.New("prizepool: no available item")
|
||||
|
||||
// ErrUserIssueCounterRequired means at least one item has a user limit, but no
|
||||
// counter was provided to check how many times the user has already received it.
|
||||
ErrUserIssueCounterRequired = errors.New("prizepool: user issue counter required")
|
||||
)
|
||||
|
||||
// Item is the activity-neutral shape used by the prize pool picker.
|
||||
// TotalLimit/UserLimit use 0 to mean unlimited.
|
||||
type Item struct {
|
||||
ID int64
|
||||
Enabled bool
|
||||
Weight int
|
||||
TotalLimit int64
|
||||
UserLimit int64
|
||||
IssuedCount int64
|
||||
}
|
||||
|
||||
// PickResult returns the selected item and its original index in the input slice.
|
||||
type PickResult struct {
|
||||
Item Item
|
||||
Index int
|
||||
}
|
||||
|
||||
// UserIssueCounter returns how many cap-occupying records one user already has
|
||||
// for a prize item.
|
||||
type UserIssueCounter interface {
|
||||
CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// UserIssueCounterFunc adapts a function to UserIssueCounter.
|
||||
type UserIssueCounterFunc func(ctx context.Context, itemID int64, userID int64) (int64, error)
|
||||
|
||||
func (f UserIssueCounterFunc) CountUserIssues(ctx context.Context, itemID int64, userID int64) (int64, error) {
|
||||
return f(ctx, itemID, userID)
|
||||
}
|
||||
|
||||
// RandomIntn returns a number in [0, max).
|
||||
type RandomIntn func(max int) (int, error)
|
||||
|
||||
// Picker owns the randomness used by the prize pool. A zero-value Picker uses
|
||||
// crypto/rand so production callers do not need to seed a global RNG.
|
||||
type Picker struct {
|
||||
RandomIntn RandomIntn
|
||||
}
|
||||
|
||||
// PickAvailable filters unavailable items and selects one by Weight.
|
||||
func (p Picker) PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
|
||||
available := make([]PickResult, 0, len(items))
|
||||
for index, item := range items {
|
||||
if !item.Enabled || item.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
if item.TotalLimit > 0 && item.IssuedCount >= item.TotalLimit {
|
||||
continue
|
||||
}
|
||||
if item.UserLimit > 0 {
|
||||
if counter == nil {
|
||||
return PickResult{}, ErrUserIssueCounterRequired
|
||||
}
|
||||
used, err := counter.CountUserIssues(ctx, item.ID, userID)
|
||||
if err != nil {
|
||||
return PickResult{}, err
|
||||
}
|
||||
if used >= item.UserLimit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
available = append(available, PickResult{Item: item, Index: index})
|
||||
}
|
||||
if len(available) == 0 {
|
||||
return PickResult{}, ErrNoAvailableItem
|
||||
}
|
||||
return p.PickWeighted(available)
|
||||
}
|
||||
|
||||
// PickWeighted selects one already-filtered item by Weight.
|
||||
func (p Picker) PickWeighted(items []PickResult) (PickResult, error) {
|
||||
total := 0
|
||||
for _, item := range items {
|
||||
if item.Item.Enabled && item.Item.Weight > 0 {
|
||||
total += item.Item.Weight
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return PickResult{}, ErrNoAvailableItem
|
||||
}
|
||||
|
||||
n, err := p.randomIntn(total)
|
||||
if err != nil {
|
||||
return PickResult{}, err
|
||||
}
|
||||
target := n + 1
|
||||
accumulated := 0
|
||||
for _, item := range items {
|
||||
if !item.Item.Enabled || item.Item.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
accumulated += item.Item.Weight
|
||||
if target <= accumulated {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return PickResult{}, ErrNoAvailableItem
|
||||
}
|
||||
|
||||
func (p Picker) randomIntn(max int) (int, error) {
|
||||
if max <= 0 {
|
||||
return 0, ErrNoAvailableItem
|
||||
}
|
||||
if p.RandomIntn != nil {
|
||||
return p.RandomIntn(max)
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n.Int64()), nil
|
||||
}
|
||||
|
||||
// PickAvailable uses a zero-value Picker.
|
||||
func PickAvailable(ctx context.Context, items []Item, userID int64, counter UserIssueCounter) (PickResult, error) {
|
||||
return Picker{}.PickAvailable(ctx, items, userID, counter)
|
||||
}
|
||||
|
||||
// PickWeighted uses a zero-value Picker.
|
||||
func PickWeighted(items []PickResult) (PickResult, error) {
|
||||
return Picker{}.PickWeighted(items)
|
||||
}
|
||||
74
internal/service/prizepool/pool_test.go
Normal file
74
internal/service/prizepool/pool_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
package prizepool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPickAvailableSkipsUnavailableItems(t *testing.T) {
|
||||
counter := UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
|
||||
if itemID == 4 && userID == 1001 {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
})
|
||||
picker := Picker{RandomIntn: func(max int) (int, error) {
|
||||
if max != 7 {
|
||||
t.Fatalf("max = %d, want 7", max)
|
||||
}
|
||||
return 6, nil
|
||||
}}
|
||||
|
||||
picked, err := picker.PickAvailable(context.Background(), []Item{
|
||||
{ID: 1, Enabled: false, Weight: 100},
|
||||
{ID: 2, Enabled: true, Weight: 0},
|
||||
{ID: 3, Enabled: true, Weight: 5, TotalLimit: 10, IssuedCount: 10},
|
||||
{ID: 4, Enabled: true, Weight: 8, UserLimit: 1},
|
||||
{ID: 5, Enabled: true, Weight: 7},
|
||||
}, 1001, counter)
|
||||
if err != nil {
|
||||
t.Fatalf("PickAvailable() error = %v", err)
|
||||
}
|
||||
if picked.Item.ID != 5 || picked.Index != 4 {
|
||||
t.Fatalf("picked = %+v, want item 5 at original index 4", picked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickAvailableNoAvailableItem(t *testing.T) {
|
||||
_, err := Picker{}.PickAvailable(context.Background(), []Item{
|
||||
{ID: 1, Enabled: true, Weight: 1, TotalLimit: 1, IssuedCount: 1},
|
||||
}, 1001, nil)
|
||||
if !errors.Is(err, ErrNoAvailableItem) {
|
||||
t.Fatalf("error = %v, want ErrNoAvailableItem", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickAvailableRequiresCounterForUserLimit(t *testing.T) {
|
||||
_, err := Picker{}.PickAvailable(context.Background(), []Item{
|
||||
{ID: 1, Enabled: true, Weight: 1, UserLimit: 1},
|
||||
}, 1001, nil)
|
||||
if !errors.Is(err, ErrUserIssueCounterRequired) {
|
||||
t.Fatalf("error = %v, want ErrUserIssueCounterRequired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickWeightedUsesWeights(t *testing.T) {
|
||||
picker := Picker{RandomIntn: func(max int) (int, error) {
|
||||
if max != 10 {
|
||||
t.Fatalf("max = %d, want 10", max)
|
||||
}
|
||||
return 3, nil
|
||||
}}
|
||||
|
||||
picked, err := picker.PickWeighted([]PickResult{
|
||||
{Item: Item{ID: 1, Enabled: true, Weight: 3}, Index: 0},
|
||||
{Item: Item{ID: 2, Enabled: true, Weight: 7}, Index: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PickWeighted() error = %v", err)
|
||||
}
|
||||
if picked.Item.ID != 2 {
|
||||
t.Fatalf("picked item = %d, want 2", picked.Item.ID)
|
||||
}
|
||||
}
|
||||
@ -2,15 +2,16 @@ package smashegg
|
||||
|
||||
import (
|
||||
"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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/service/wheel/locking.go
Normal file
18
internal/service/wheel/locking.go
Normal file
@ -0,0 +1,18 @@
|
||||
package wheel
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func withWriteLock(db *gorm.DB) *gorm.DB {
|
||||
if db == nil || db.Dialector == nil {
|
||||
return db
|
||||
}
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql", "postgres", "sqlserver":
|
||||
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
default:
|
||||
return db
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"math"
|
||||
"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,
|
||||
)
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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='常驻活动转盘抽奖记录';
|
||||
|
||||
116
migrations/045_wheel_limits_probability_retry.sql
Normal file
116
migrations/045_wheel_limits_probability_retry.sql
Normal file
@ -0,0 +1,116 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @add_wheel_reward_total_limit_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_reward_config'
|
||||
AND column_name = 'total_limit'
|
||||
),
|
||||
'ALTER TABLE `wheel_reward_config` ADD COLUMN `total_limit` bigint NOT NULL DEFAULT 0 COMMENT ''奖品总发放上限,0表示不限'' AFTER `probability`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_reward_total_limit_stmt FROM @add_wheel_reward_total_limit_sql;
|
||||
EXECUTE add_wheel_reward_total_limit_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_reward_total_limit_stmt;
|
||||
|
||||
SET @add_wheel_reward_user_limit_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_reward_config'
|
||||
AND column_name = 'user_limit'
|
||||
),
|
||||
'ALTER TABLE `wheel_reward_config` ADD COLUMN `user_limit` bigint NOT NULL DEFAULT 0 COMMENT ''单用户发放上限,0表示不限'' AFTER `total_limit`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_reward_user_limit_stmt FROM @add_wheel_reward_user_limit_sql;
|
||||
EXECUTE add_wheel_reward_user_limit_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_reward_user_limit_stmt;
|
||||
|
||||
SET @add_wheel_reward_issued_count_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_reward_config'
|
||||
AND column_name = 'issued_count'
|
||||
),
|
||||
'ALTER TABLE `wheel_reward_config` ADD COLUMN `issued_count` bigint NOT NULL DEFAULT 0 COMMENT ''已占用发放数量,PENDING/SUCCESS/FAILED发奖占用'' AFTER `user_limit`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_reward_issued_count_stmt FROM @add_wheel_reward_issued_count_sql;
|
||||
EXECUTE add_wheel_reward_issued_count_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_reward_issued_count_stmt;
|
||||
|
||||
SET @add_wheel_draw_reward_config_id_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_draw_record'
|
||||
AND column_name = 'reward_config_id'
|
||||
),
|
||||
'ALTER TABLE `wheel_draw_record` ADD COLUMN `reward_config_id` bigint NOT NULL DEFAULT 0 COMMENT ''命中奖项配置ID'' AFTER `paid_gold`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_draw_reward_config_id_stmt FROM @add_wheel_draw_reward_config_id_sql;
|
||||
EXECUTE add_wheel_draw_reward_config_id_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_draw_reward_config_id_stmt;
|
||||
|
||||
SET @add_wheel_draw_retry_count_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_draw_record'
|
||||
AND column_name = 'retry_count'
|
||||
),
|
||||
'ALTER TABLE `wheel_draw_record` ADD COLUMN `retry_count` int NOT NULL DEFAULT 0 COMMENT ''发奖尝试次数'' AFTER `status`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_draw_retry_count_stmt FROM @add_wheel_draw_retry_count_sql;
|
||||
EXECUTE add_wheel_draw_retry_count_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_draw_retry_count_stmt;
|
||||
|
||||
SET @add_wheel_draw_grant_time_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_draw_record'
|
||||
AND column_name = 'grant_time'
|
||||
),
|
||||
'ALTER TABLE `wheel_draw_record` ADD COLUMN `grant_time` datetime(3) DEFAULT NULL COMMENT ''发奖成功时间'' AFTER `error_message`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_draw_grant_time_stmt FROM @add_wheel_draw_grant_time_sql;
|
||||
EXECUTE add_wheel_draw_grant_time_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_draw_grant_time_stmt;
|
||||
|
||||
SET @add_wheel_draw_reward_user_idx_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'wheel_draw_record'
|
||||
AND index_name = 'idx_wheel_draw_reward_user'
|
||||
),
|
||||
'ALTER TABLE `wheel_draw_record` ADD INDEX `idx_wheel_draw_reward_user` (`reward_config_id`, `user_id`, `status`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_wheel_draw_reward_user_idx_stmt FROM @add_wheel_draw_reward_user_idx_sql;
|
||||
EXECUTE add_wheel_draw_reward_user_idx_stmt;
|
||||
DEALLOCATE PREPARE add_wheel_draw_reward_user_idx_stmt;
|
||||
|
||||
SET @wheel_reward_probability_max := (
|
||||
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_reward_config`
|
||||
);
|
||||
SET @wheel_record_probability_max := (
|
||||
SELECT COALESCE(MAX(`probability`), 0) FROM `wheel_draw_record`
|
||||
);
|
||||
|
||||
UPDATE `wheel_reward_config`
|
||||
SET `probability` = `probability` * 100
|
||||
WHERE @wheel_reward_probability_max > 0
|
||||
AND @wheel_reward_probability_max <= 10000;
|
||||
|
||||
UPDATE `wheel_draw_record`
|
||||
SET `probability` = `probability` * 100
|
||||
WHERE @wheel_record_probability_max > 0
|
||||
AND @wheel_record_probability_max <= 10000;
|
||||
18
migrations/046_activity_idempotency.sql
Normal file
18
migrations/046_activity_idempotency.sql
Normal file
@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS `activity_idempotency` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`activity` varchar(64) NOT NULL COMMENT '活动标识',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`idempotency_key` varchar(128) NOT NULL COMMENT '客户端幂等键',
|
||||
`request_hash` varchar(64) NOT NULL COMMENT '请求参数hash',
|
||||
`business_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务单号',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCEEDED/FAILED',
|
||||
`response_json` longtext COMMENT '成功响应快照',
|
||||
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||
`create_time` datetime(3) NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_idempotency_key` (`activity`, `sys_origin`, `user_id`, `idempotency_key`),
|
||||
KEY `idx_activity_idempotency_business` (`business_no`),
|
||||
KEY `idx_activity_idempotency_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动付费请求幂等表';
|
||||
541
scripts/wheel_local_verify.go
Normal file
541
scripts/wheel_local_verify.go
Normal file
@ -0,0 +1,541 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/wheel"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
localSysOrigin = "LOCAL_WHEEL_VERIFY"
|
||||
localCategory = "CLASSIC"
|
||||
localTimezone = "Asia/Riyadh"
|
||||
localUserID = int64(2042274349343506434)
|
||||
localOtherUserID = int64(2042274349343506435)
|
||||
localRoomID = int64(9001001)
|
||||
probabilityTotal = 1000000
|
||||
receiptIncome = "INCOME"
|
||||
receiptExpense = "EXPENDITURE"
|
||||
statusSuccess = "SUCCESS"
|
||||
statusFailed = "FAILED"
|
||||
statusPayFailed = "PAY_FAILED"
|
||||
)
|
||||
|
||||
type localWheelGateway struct {
|
||||
mu sync.Mutex
|
||||
events map[string]integration.GoldReceiptCommand
|
||||
changes []integration.GoldReceiptCommand
|
||||
balance map[int64]int64
|
||||
failNextIncome bool
|
||||
failNextExpenditure bool
|
||||
}
|
||||
|
||||
type verifySummary struct {
|
||||
DSNHost string `json:"dsnHost"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
OtherUserID int64 `json:"otherUserId,string"`
|
||||
ProbabilityTotal int `json:"probabilityTotal"`
|
||||
HighRewardConfigID int64 `json:"highRewardConfigId,string"`
|
||||
HighRewardProbability int `json:"highRewardProbability"`
|
||||
ProbabilityPercent string `json:"probabilityPercent"`
|
||||
PayFailedRecords int64 `json:"payFailedRecords"`
|
||||
FailedDrawNo string `json:"failedDrawNo"`
|
||||
FailedRecordID int64 `json:"failedRecordId,string"`
|
||||
FailedRecordStatus string `json:"failedRecordStatus"`
|
||||
GrantFailedInDraw bool `json:"grantFailedInDraw"`
|
||||
RetryStatus string `json:"retryStatus"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
FallbackDrawNo string `json:"fallbackDrawNo"`
|
||||
FallbackRecordID int64 `json:"fallbackRecordId,string"`
|
||||
FallbackRewardConfigID int64 `json:"fallbackRewardConfigId,string"`
|
||||
FallbackGoldAmount int64 `json:"fallbackGoldAmount"`
|
||||
FallbackStatus string `json:"fallbackStatus"`
|
||||
FallbackGrantFailed bool `json:"fallbackGrantFailed"`
|
||||
HighRewardIssuedCount int64 `json:"highRewardIssuedCount"`
|
||||
FallbackIssuedCount int64 `json:"fallbackIssuedCount"`
|
||||
PaymentEvents int `json:"paymentEvents"`
|
||||
IncomeEvents int `json:"incomeEvents"`
|
||||
RecordPageTotal int64 `json:"recordPageTotal"`
|
||||
UserBalance int64 `json:"userBalance"`
|
||||
OtherUserBalance int64 `json:"otherUserBalance"`
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.events == nil {
|
||||
g.events = map[string]integration.GoldReceiptCommand{}
|
||||
}
|
||||
if g.balance == nil {
|
||||
g.balance = map[int64]int64{}
|
||||
}
|
||||
if _, exists := g.events[cmd.EventID]; exists {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToUpper(cmd.ReceiptType) {
|
||||
case receiptExpense:
|
||||
if g.failNextExpenditure {
|
||||
g.failNextExpenditure = false
|
||||
return errors.New("local simulated payment failure")
|
||||
}
|
||||
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
|
||||
case receiptIncome:
|
||||
if g.failNextIncome {
|
||||
g.failNextIncome = false
|
||||
return errors.New("local simulated grant failure")
|
||||
}
|
||||
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
|
||||
}
|
||||
|
||||
g.events[cmd.EventID] = cmd
|
||||
g.changes = append(g.changes, cmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
_, exists := g.events[eventID]
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) SwitchUseProps(context.Context, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
result := make(map[int64]int64, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
result[userID] = g.balance[userID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
|
||||
return integration.RoomProfile{
|
||||
ID: integration.Int64Value(localRoomID),
|
||||
UserID: integration.Int64Value(userID),
|
||||
RoomName: "local-wheel-verify-room",
|
||||
SysOrigin: localSysOrigin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *localWheelGateway) countReceipt(receiptType string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
count := 0
|
||||
for _, change := range g.changes {
|
||||
if strings.EqualFold(change.ReceiptType, receiptType) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
db, dsn, err := connectMySQL(ctx)
|
||||
must(err, "connect mysql")
|
||||
sqlDB, err := db.DB()
|
||||
must(err, "unwrap mysql")
|
||||
defer sqlDB.Close()
|
||||
|
||||
must(ensureWheelSchema(db), "ensure wheel schema")
|
||||
must(cleanup(ctx, db, localSysOrigin), "cleanup local wheel data")
|
||||
|
||||
gateway := &localWheelGateway{
|
||||
events: map[string]integration.GoldReceiptCommand{},
|
||||
balance: map[int64]int64{localUserID: 1000, localOtherUserID: 1000},
|
||||
}
|
||||
service := wheel.NewService(config.Config{}, db, gateway)
|
||||
|
||||
saved, err := seedConfig(ctx, service)
|
||||
must(err, "save wheel config")
|
||||
assert(saved.ProbabilityTotal == probabilityTotal, "probability total mismatch")
|
||||
|
||||
reward, err := makeDrawDeterministic(ctx, db)
|
||||
must(err, "make deterministic reward")
|
||||
assert(reward.Probability == probabilityTotal && reward.TotalLimit == 1 && reward.UserLimit == 1, "deterministic reward mismatch")
|
||||
|
||||
configAfterSeed, err := service.GetConfig(ctx, localSysOrigin)
|
||||
must(err, "get wheel config")
|
||||
firstPayload := firstCategoryReward(configAfterSeed)
|
||||
assert(firstPayload.Probability == probabilityTotal, "config probability mismatch")
|
||||
|
||||
gateway.failNextExpenditure = true
|
||||
_, payErr := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||
Category: localCategory,
|
||||
Times: 1,
|
||||
})
|
||||
assert(payErr != nil, "payment failure draw should fail")
|
||||
assert(appErrorCode(payErr) == "wheel_wallet_deduct_failed", fmt.Sprintf("unexpected payment error code: %s", appErrorCode(payErr)))
|
||||
payFailedCount := countRecords(ctx, db, statusPayFailed)
|
||||
assert(payFailedCount == 1, fmt.Sprintf("pay failed records = %d, want 1", payFailedCount))
|
||||
rewardAfterPayFailure := loadReward(ctx, db, reward.ID)
|
||||
assert(rewardAfterPayFailure.IssuedCount == 0, fmt.Sprintf("issued count after pay failure = %d, want 0", rewardAfterPayFailure.IssuedCount))
|
||||
|
||||
gateway.failNextIncome = true
|
||||
drawResp, err := service.Draw(ctx, common.AuthUser{UserID: localUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||
Category: localCategory,
|
||||
Times: 1,
|
||||
})
|
||||
must(err, "draw with simulated grant failure")
|
||||
assert(drawResp.GrantFailed, "draw should expose grantFailed")
|
||||
assert(len(drawResp.Records) == 1, "draw record count mismatch")
|
||||
failedRecord := loadRecord(ctx, db, drawResp.Records[0].ID)
|
||||
assert(failedRecord.Status == statusFailed, fmt.Sprintf("failed record status = %s", failedRecord.Status))
|
||||
assert(gateway.countReceipt(receiptExpense) == 1, "payment should be charged once after grant failure")
|
||||
assert(gateway.countReceipt(receiptIncome) == 0, "income should not be granted before retry")
|
||||
|
||||
must(service.RetryDrawRecord(ctx, failedRecord.ID), "retry failed reward")
|
||||
retriedRecord := loadRecord(ctx, db, failedRecord.ID)
|
||||
assert(retriedRecord.Status == statusSuccess, fmt.Sprintf("retry status = %s", retriedRecord.Status))
|
||||
assert(gateway.countReceipt(receiptExpense) == 1, "retry should not charge again")
|
||||
assert(gateway.countReceipt(receiptIncome) == 1, "retry should grant reward once")
|
||||
|
||||
fallbackReward, err := enableFallbackReward(ctx, db)
|
||||
must(err, "enable fallback reward")
|
||||
fallbackResp, err := service.Draw(ctx, common.AuthUser{UserID: localOtherUserID, SysOrigin: localSysOrigin}, wheel.DrawRequest{
|
||||
Category: localCategory,
|
||||
Times: 1,
|
||||
})
|
||||
must(err, "draw after high reward exhausted")
|
||||
assert(!fallbackResp.GrantFailed, "fallback draw should be user-transparent")
|
||||
assert(len(fallbackResp.Records) == 1, "fallback draw record count mismatch")
|
||||
fallbackRecord := loadRecord(ctx, db, fallbackResp.Records[0].ID)
|
||||
assert(fallbackRecord.Status == statusSuccess, fmt.Sprintf("fallback record status = %s", fallbackRecord.Status))
|
||||
assert(fallbackRecord.RewardConfigID == fallbackReward.ID, "fallback draw did not hit fallback reward")
|
||||
assert(fallbackRecord.RewardConfigID != reward.ID, "fallback draw hit exhausted high reward")
|
||||
assert(gateway.countReceipt(receiptExpense) == 2, "fallback draw should charge once")
|
||||
assert(gateway.countReceipt(receiptIncome) == 2, "fallback draw should grant reward once")
|
||||
|
||||
finalReward := loadReward(ctx, db, reward.ID)
|
||||
finalFallbackReward := loadReward(ctx, db, fallbackReward.ID)
|
||||
assert(finalReward.IssuedCount == 1, fmt.Sprintf("high reward issued count = %d, want 1", finalReward.IssuedCount))
|
||||
assert(finalFallbackReward.IssuedCount == 1, fmt.Sprintf("fallback issued count = %d, want 1", finalFallbackReward.IssuedCount))
|
||||
page, err := service.PageRecords(ctx, localSysOrigin, localCategory, 0, "", 1, 20)
|
||||
must(err, "page records")
|
||||
balances, err := gateway.MapGoldBalance(ctx, []int64{localUserID, localOtherUserID})
|
||||
must(err, "map balance")
|
||||
|
||||
summary := verifySummary{
|
||||
DSNHost: dsnLabel(dsn),
|
||||
SysOrigin: localSysOrigin,
|
||||
UserID: localUserID,
|
||||
OtherUserID: localOtherUserID,
|
||||
ProbabilityTotal: configAfterSeed.ProbabilityTotal,
|
||||
HighRewardConfigID: finalReward.ID,
|
||||
HighRewardProbability: firstPayload.Probability,
|
||||
ProbabilityPercent: firstPayload.ProbabilityPercent,
|
||||
PayFailedRecords: payFailedCount,
|
||||
FailedDrawNo: drawResp.DrawNo,
|
||||
FailedRecordID: failedRecord.ID,
|
||||
FailedRecordStatus: failedRecord.Status,
|
||||
GrantFailedInDraw: drawResp.GrantFailed,
|
||||
RetryStatus: retriedRecord.Status,
|
||||
RetryCount: retriedRecord.RetryCount,
|
||||
FallbackDrawNo: fallbackResp.DrawNo,
|
||||
FallbackRecordID: fallbackRecord.ID,
|
||||
FallbackRewardConfigID: fallbackRecord.RewardConfigID,
|
||||
FallbackGoldAmount: fallbackRecord.GoldAmount,
|
||||
FallbackStatus: fallbackRecord.Status,
|
||||
FallbackGrantFailed: fallbackResp.GrantFailed,
|
||||
HighRewardIssuedCount: finalReward.IssuedCount,
|
||||
FallbackIssuedCount: finalFallbackReward.IssuedCount,
|
||||
PaymentEvents: gateway.countReceipt(receiptExpense),
|
||||
IncomeEvents: gateway.countReceipt(receiptIncome),
|
||||
RecordPageTotal: page.Total,
|
||||
UserBalance: balances[localUserID],
|
||||
OtherUserBalance: balances[localOtherUserID],
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(summary, "", " ")
|
||||
must(err, "marshal summary")
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
|
||||
candidates := uniqueStrings([]string{
|
||||
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
|
||||
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
|
||||
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
})
|
||||
var errs []string
|
||||
for _, dsn := range candidates {
|
||||
if dsn == "" {
|
||||
continue
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
return db, dsn, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func ensureWheelSchema(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.WheelConfig{},
|
||||
&model.WheelPoolConfig{},
|
||||
&model.WheelRewardConfig{},
|
||||
&model.WheelDrawRecord{},
|
||||
&model.ActivityIdempotency{},
|
||||
)
|
||||
}
|
||||
|
||||
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var configIDs []int64
|
||||
if err := tx.Model(&model.WheelConfig{}).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
Pluck("id", &configIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(configIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func seedConfig(ctx context.Context, service *wheel.Service) (*wheel.ConfigResponse, error) {
|
||||
probabilities := []int{999993, 1, 1, 1, 1, 1, 1, 1}
|
||||
rewards := make([]wheel.RewardConfigInput, 0, len(probabilities))
|
||||
for index, probability := range probabilities {
|
||||
totalLimit := int64(0)
|
||||
userLimit := int64(0)
|
||||
if index == 0 {
|
||||
totalLimit = 1
|
||||
userLimit = 1
|
||||
}
|
||||
rewards = append(rewards, wheel.RewardConfigInput{
|
||||
Enabled: true,
|
||||
Sort: index + 1,
|
||||
RewardType: "GOLD",
|
||||
GoldAmount: int64(7 + index),
|
||||
Probability: probability,
|
||||
TotalLimit: totalLimit,
|
||||
UserLimit: userLimit,
|
||||
})
|
||||
}
|
||||
return service.SaveCategoryConfig(ctx, wheel.SaveCategoryConfigRequest{
|
||||
SysOrigin: localSysOrigin,
|
||||
Timezone: localTimezone,
|
||||
Category: wheel.CategoryConfigInput{
|
||||
Category: localCategory,
|
||||
Enabled: true,
|
||||
PriceOneGold: 3,
|
||||
PriceTenGold: 30,
|
||||
PriceFiftyGold: 150,
|
||||
Rewards: rewards,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func makeDrawDeterministic(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
|
||||
var reward model.WheelRewardConfig
|
||||
if err := db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 1).
|
||||
First(&reward).Error; err != nil {
|
||||
return model.WheelRewardConfig{}, err
|
||||
}
|
||||
return reward, db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.WheelRewardConfig{}).
|
||||
Where("sys_origin = ? AND category = ?", localSysOrigin, localCategory).
|
||||
Updates(map[string]any{
|
||||
"probability": 0,
|
||||
"total_limit": 0,
|
||||
"user_limit": 0,
|
||||
"issued_count": 0,
|
||||
"update_time": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.WheelRewardConfig{}).
|
||||
Where("id = ?", reward.ID).
|
||||
Updates(map[string]any{
|
||||
"probability": probabilityTotal,
|
||||
"total_limit": 1,
|
||||
"user_limit": 1,
|
||||
"issued_count": 0,
|
||||
"update_time": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", reward.ID).First(&reward).Error
|
||||
})
|
||||
}
|
||||
|
||||
func enableFallbackReward(ctx context.Context, db *gorm.DB) (model.WheelRewardConfig, error) {
|
||||
var reward model.WheelRewardConfig
|
||||
if err := db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND category = ? AND sort = ?", localSysOrigin, localCategory, 2).
|
||||
First(&reward).Error; err != nil {
|
||||
return model.WheelRewardConfig{}, err
|
||||
}
|
||||
err := db.WithContext(ctx).Model(&model.WheelRewardConfig{}).
|
||||
Where("id = ?", reward.ID).
|
||||
Updates(map[string]any{
|
||||
"probability": probabilityTotal,
|
||||
"total_limit": 0,
|
||||
"user_limit": 0,
|
||||
"issued_count": 0,
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
if err != nil {
|
||||
return model.WheelRewardConfig{}, err
|
||||
}
|
||||
return reward, db.WithContext(ctx).Where("id = ?", reward.ID).First(&reward).Error
|
||||
}
|
||||
|
||||
func firstCategoryReward(resp *wheel.ConfigResponse) wheel.RewardConfigPayload {
|
||||
for _, category := range resp.Categories {
|
||||
if category.Category == localCategory && len(category.Rewards) > 0 {
|
||||
return category.Rewards[0]
|
||||
}
|
||||
}
|
||||
log.Fatal("missing first category reward")
|
||||
return wheel.RewardConfigPayload{}
|
||||
}
|
||||
|
||||
func countRecords(ctx context.Context, db *gorm.DB, status string) int64 {
|
||||
var count int64
|
||||
must(db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
|
||||
Where("sys_origin = ? AND status = ?", localSysOrigin, status).
|
||||
Count(&count).Error, "count records")
|
||||
return count
|
||||
}
|
||||
|
||||
func loadReward(ctx context.Context, db *gorm.DB, id int64) model.WheelRewardConfig {
|
||||
var reward model.WheelRewardConfig
|
||||
must(db.WithContext(ctx).Where("id = ?", id).First(&reward).Error, "load reward")
|
||||
return reward
|
||||
}
|
||||
|
||||
func loadRecord(ctx context.Context, db *gorm.DB, id int64) model.WheelDrawRecord {
|
||||
var record model.WheelDrawRecord
|
||||
must(db.WithContext(ctx).Where("id = ?", id).First(&record).Error, "load record")
|
||||
return record
|
||||
}
|
||||
|
||||
func appErrorCode(err error) string {
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dsnLabel(dsn string) string {
|
||||
if at := strings.Index(dsn, "@tcp("); at >= 0 {
|
||||
rest := dsn[at+len("@tcp("):]
|
||||
hostEnd := strings.Index(rest, ")")
|
||||
if hostEnd >= 0 {
|
||||
host := rest[:hostEnd]
|
||||
dbName := ""
|
||||
after := rest[hostEnd+1:]
|
||||
if strings.HasPrefix(after, "/") {
|
||||
dbPart := strings.TrimPrefix(after, "/")
|
||||
if question := strings.Index(dbPart, "?"); question >= 0 {
|
||||
dbPart = dbPart[:question]
|
||||
}
|
||||
dbName = dbPart
|
||||
}
|
||||
if dbName != "" {
|
||||
return host + "/" + dbName
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
return "custom-dsn"
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func must(err error, step string) {
|
||||
if err != nil {
|
||||
log.Fatalf("%s: %v", step, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assert(ok bool, message string) {
|
||||
if !ok {
|
||||
log.Fatal(message)
|
||||
}
|
||||
}
|
||||
388
scripts/wheel_pure_random_simulate.go
Normal file
388
scripts/wheel_pure_random_simulate.go
Normal file
@ -0,0 +1,388 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/service/prizepool"
|
||||
)
|
||||
|
||||
const (
|
||||
probabilityTotal = 1000000
|
||||
defaultTarget = 100000
|
||||
defaultUsers = 5000
|
||||
)
|
||||
|
||||
type categorySpec struct {
|
||||
Category string
|
||||
RewardCount int
|
||||
GoldBase int64
|
||||
}
|
||||
|
||||
type rewardConfig struct {
|
||||
ID int64
|
||||
Category string
|
||||
Sort int
|
||||
GoldAmount int64
|
||||
Probability int
|
||||
TotalLimit int64
|
||||
UserLimit int64
|
||||
IssuedCount int64
|
||||
}
|
||||
|
||||
type rewardSummary struct {
|
||||
Category string `json:"category"`
|
||||
Sort int `json:"sort"`
|
||||
RewardID int64 `json:"rewardId"`
|
||||
GoldAmount int64 `json:"goldAmount"`
|
||||
Probability int `json:"probability"`
|
||||
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||
UserLimit int64 `json:"userLimit,omitempty"`
|
||||
IssuedCount int64 `json:"issuedCount"`
|
||||
}
|
||||
|
||||
type summary struct {
|
||||
Seed int64 `json:"seed"`
|
||||
TargetRewardItems int `json:"targetRewardItems"`
|
||||
ActualRewardItems int64 `json:"actualRewardItems"`
|
||||
DrawRequests int64 `json:"drawRequests"`
|
||||
RandomUsers int `json:"randomUsers"`
|
||||
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
|
||||
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
|
||||
LimitedRewards int `json:"limitedRewards"`
|
||||
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
|
||||
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||
UserLimitViolations int `json:"userLimitViolations"`
|
||||
NoAvailableHits int64 `json:"noAvailableHits"`
|
||||
LimitedRewardDetails []rewardSummary `json:"limitedRewardDetails"`
|
||||
TopRewards []rewardSummary `json:"topRewards"`
|
||||
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||
}
|
||||
|
||||
type userRewardKey struct {
|
||||
UserID int64
|
||||
RewardID int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
startedAt := time.Now()
|
||||
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||
target := envInt("WHEEL_SIM_TARGET_REWARDS", defaultTarget)
|
||||
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
|
||||
rewardsByCategory, allRewards := buildRandomConfig(rng)
|
||||
userRewardCounts := map[userRewardKey]int64{}
|
||||
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
categoryRewardItems := map[string]int64{}
|
||||
uniqueUsers := map[int64]struct{}{}
|
||||
|
||||
var drawRequests int64
|
||||
var rewardItems int64
|
||||
var noAvailableHits int64
|
||||
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
|
||||
return rng.Intn(max), nil
|
||||
}}
|
||||
|
||||
for rewardItems < int64(target) {
|
||||
userID := int64(1 + rng.Intn(userCount))
|
||||
category := specs()[rng.Intn(len(specs()))].Category
|
||||
categoryRewards := rewardsByCategory[category]
|
||||
times := randomDrawTimes(rng)
|
||||
drawRequests++
|
||||
uniqueUsers[userID] = struct{}{}
|
||||
|
||||
timesKey := strconv.Itoa(times)
|
||||
drawTimesRequests[timesKey]++
|
||||
for i := 0; i < times; i++ {
|
||||
picked, err := picker.PickAvailable(ctx, prizePoolItems(categoryRewards), userID, prizepool.UserIssueCounterFunc(func(_ context.Context, itemID int64, userID int64) (int64, error) {
|
||||
return userRewardCounts[userRewardKey{UserID: userID, RewardID: itemID}], nil
|
||||
}))
|
||||
if errors.Is(err, prizepool.ErrNoAvailableItem) {
|
||||
noAvailableHits++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
reward := categoryRewards[picked.Index]
|
||||
reward.IssuedCount++
|
||||
if reward.UserLimit > 0 {
|
||||
userRewardCounts[userRewardKey{UserID: userID, RewardID: reward.ID}]++
|
||||
}
|
||||
rewardItems++
|
||||
drawTimesRewardItems[timesKey]++
|
||||
categoryRewardItems[category]++
|
||||
}
|
||||
}
|
||||
|
||||
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(allRewards)
|
||||
totalLimitViolations := countTotalLimitViolations(allRewards)
|
||||
userLimitViolations := countUserLimitViolations(allRewards, userRewardCounts)
|
||||
limitedRewardDetails := limitedRewardSummaries(allRewards)
|
||||
topRewards := topRewardSummaries(allRewards, 14)
|
||||
|
||||
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
|
||||
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
|
||||
assert(noAvailableHits == 0, fmt.Sprintf("no available hits = %d", noAvailableHits))
|
||||
|
||||
result := summary{
|
||||
Seed: seed,
|
||||
TargetRewardItems: target,
|
||||
ActualRewardItems: rewardItems,
|
||||
DrawRequests: drawRequests,
|
||||
RandomUsers: userCount,
|
||||
UniqueUsersDrawn: len(uniqueUsers),
|
||||
DrawTimesRequests: drawTimesRequests,
|
||||
DrawTimesRewardItems: drawTimesRewardItems,
|
||||
CategoryRewardItems: categoryRewardItems,
|
||||
LimitedRewards: limitedRewards,
|
||||
ExhaustedLimitedRewards: exhaustedLimitedRewards,
|
||||
TotalLimitViolations: totalLimitViolations,
|
||||
UserLimitViolations: userLimitViolations,
|
||||
NoAvailableHits: noAvailableHits,
|
||||
LimitedRewardDetails: limitedRewardDetails,
|
||||
TopRewards: topRewards,
|
||||
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
out, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func specs() []categorySpec {
|
||||
return []categorySpec{
|
||||
{Category: "CLASSIC", RewardCount: 8, GoldBase: 10},
|
||||
{Category: "LUXURY", RewardCount: 8, GoldBase: 100},
|
||||
{Category: "ADVANCED", RewardCount: 12, GoldBase: 500},
|
||||
}
|
||||
}
|
||||
|
||||
func buildRandomConfig(rng *rand.Rand) (map[string][]*rewardConfig, []*rewardConfig) {
|
||||
byCategory := map[string][]*rewardConfig{}
|
||||
all := make([]*rewardConfig, 0, 28)
|
||||
var rewardID int64 = 1
|
||||
for _, spec := range specs() {
|
||||
probabilities := randomProbabilities(spec.RewardCount, rng)
|
||||
rewards := make([]*rewardConfig, 0, spec.RewardCount)
|
||||
for index := 0; index < spec.RewardCount; index++ {
|
||||
totalLimit := int64(0)
|
||||
userLimit := int64(0)
|
||||
if index == 0 {
|
||||
totalLimit = int64(120 + rng.Intn(81))
|
||||
userLimit = 1
|
||||
}
|
||||
if index == 1 {
|
||||
totalLimit = int64(260 + rng.Intn(121))
|
||||
userLimit = 2
|
||||
}
|
||||
reward := &rewardConfig{
|
||||
ID: rewardID,
|
||||
Category: spec.Category,
|
||||
Sort: index + 1,
|
||||
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
|
||||
Probability: probabilities[index],
|
||||
TotalLimit: totalLimit,
|
||||
UserLimit: userLimit,
|
||||
}
|
||||
rewardID++
|
||||
rewards = append(rewards, reward)
|
||||
all = append(all, reward)
|
||||
}
|
||||
byCategory[spec.Category] = rewards
|
||||
}
|
||||
return byCategory, all
|
||||
}
|
||||
|
||||
func randomProbabilities(count int, rng *rand.Rand) []int {
|
||||
raw := make([]int, count)
|
||||
for i := 0; i < count; i++ {
|
||||
raw[i] = rng.Intn(1000) + 1
|
||||
}
|
||||
raw[0] += 7000 + rng.Intn(3000)
|
||||
raw[1] += 3500 + rng.Intn(2000)
|
||||
|
||||
rawTotal := 0
|
||||
for _, value := range raw {
|
||||
rawTotal += value
|
||||
}
|
||||
result := make([]int, count)
|
||||
assigned := 0
|
||||
for i, value := range raw {
|
||||
probability := value * probabilityTotal / rawTotal
|
||||
if probability <= 0 {
|
||||
probability = 1
|
||||
}
|
||||
result[i] = probability
|
||||
assigned += probability
|
||||
}
|
||||
result[0] += probabilityTotal - assigned
|
||||
return result
|
||||
}
|
||||
|
||||
func randomDrawTimes(rng *rand.Rand) int {
|
||||
switch rng.Intn(3) {
|
||||
case 0:
|
||||
return 1
|
||||
case 1:
|
||||
return 10
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func prizePoolItems(rewards []*rewardConfig) []prizepool.Item {
|
||||
items := make([]prizepool.Item, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
items = append(items, prizepool.Item{
|
||||
ID: reward.ID,
|
||||
Enabled: true,
|
||||
Weight: reward.Probability,
|
||||
TotalLimit: reward.TotalLimit,
|
||||
UserLimit: reward.UserLimit,
|
||||
IssuedCount: reward.IssuedCount,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func countLimitedRewards(rewards []*rewardConfig) (int, int) {
|
||||
limited := 0
|
||||
exhausted := 0
|
||||
for _, reward := range rewards {
|
||||
if reward.TotalLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
limited++
|
||||
if reward.IssuedCount >= reward.TotalLimit {
|
||||
exhausted++
|
||||
}
|
||||
}
|
||||
return limited, exhausted
|
||||
}
|
||||
|
||||
func countTotalLimitViolations(rewards []*rewardConfig) int {
|
||||
violations := 0
|
||||
for _, reward := range rewards {
|
||||
if reward.TotalLimit > 0 && reward.IssuedCount > reward.TotalLimit {
|
||||
violations++
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func countUserLimitViolations(rewards []*rewardConfig, userRewardCounts map[userRewardKey]int64) int {
|
||||
userLimits := map[int64]int64{}
|
||||
for _, reward := range rewards {
|
||||
if reward.UserLimit > 0 {
|
||||
userLimits[reward.ID] = reward.UserLimit
|
||||
}
|
||||
}
|
||||
violations := 0
|
||||
for key, count := range userRewardCounts {
|
||||
if limit := userLimits[key.RewardID]; limit > 0 && count > limit {
|
||||
violations++
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func limitedRewardSummaries(rewards []*rewardConfig) []rewardSummary {
|
||||
result := make([]rewardSummary, 0)
|
||||
for _, reward := range rewards {
|
||||
if reward.TotalLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, rewardSummary{
|
||||
Category: reward.Category,
|
||||
Sort: reward.Sort,
|
||||
RewardID: reward.ID,
|
||||
GoldAmount: reward.GoldAmount,
|
||||
Probability: reward.Probability,
|
||||
TotalLimit: reward.TotalLimit,
|
||||
UserLimit: reward.UserLimit,
|
||||
IssuedCount: reward.IssuedCount,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if result[i].Category == result[j].Category {
|
||||
return result[i].Sort < result[j].Sort
|
||||
}
|
||||
return result[i].Category < result[j].Category
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func topRewardSummaries(rewards []*rewardConfig, limit int) []rewardSummary {
|
||||
copied := append([]*rewardConfig(nil), rewards...)
|
||||
sort.SliceStable(copied, func(i, j int) bool {
|
||||
if copied[i].IssuedCount == copied[j].IssuedCount {
|
||||
return copied[i].ID < copied[j].ID
|
||||
}
|
||||
return copied[i].IssuedCount > copied[j].IssuedCount
|
||||
})
|
||||
if len(copied) > limit {
|
||||
copied = copied[:limit]
|
||||
}
|
||||
result := make([]rewardSummary, 0, len(copied))
|
||||
for _, reward := range copied {
|
||||
result = append(result, rewardSummary{
|
||||
Category: reward.Category,
|
||||
Sort: reward.Sort,
|
||||
RewardID: reward.ID,
|
||||
GoldAmount: reward.GoldAmount,
|
||||
Probability: reward.Probability,
|
||||
TotalLimit: reward.TotalLimit,
|
||||
UserLimit: reward.UserLimit,
|
||||
IssuedCount: reward.IssuedCount,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt64(name string, fallback int64) int64 {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func assert(ok bool, message string) {
|
||||
if !ok {
|
||||
log.Fatal(message)
|
||||
}
|
||||
}
|
||||
638
scripts/wheel_random_simulate.go
Normal file
638
scripts/wheel_random_simulate.go
Normal file
@ -0,0 +1,638 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/wheel"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
simSysOrigin = "LOCAL_WHEEL_RANDOM_SIM"
|
||||
simRoomID = int64(9002001)
|
||||
simUserIDBase = int64(2042274349300000000)
|
||||
probabilityTotal = 1000000
|
||||
targetRewardCount = 100000
|
||||
|
||||
receiptIncome = "INCOME"
|
||||
receiptExpense = "EXPENDITURE"
|
||||
)
|
||||
|
||||
type categorySpec struct {
|
||||
Category string
|
||||
RewardCount int
|
||||
PriceOneGold int64
|
||||
PriceTenGold int64
|
||||
PriceFiftyGold int64
|
||||
GoldBase int64
|
||||
}
|
||||
|
||||
type simGateway struct {
|
||||
mu sync.Mutex
|
||||
events map[string]integration.GoldReceiptCommand
|
||||
changes []integration.GoldReceiptCommand
|
||||
balance map[int64]int64
|
||||
}
|
||||
|
||||
type topRewardSummary struct {
|
||||
Category string `json:"category"`
|
||||
Sort int `json:"sort"`
|
||||
RewardID int64 `json:"rewardId,string"`
|
||||
GoldAmount int64 `json:"goldAmount"`
|
||||
Probability int `json:"probability"`
|
||||
TotalLimit int64 `json:"totalLimit,omitempty"`
|
||||
UserLimit int64 `json:"userLimit,omitempty"`
|
||||
IssuedCount int64 `json:"issuedCount"`
|
||||
ActualCount int64 `json:"actualCount"`
|
||||
}
|
||||
|
||||
type simSummary struct {
|
||||
DSNHost string `json:"dsnHost"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Seed int64 `json:"seed"`
|
||||
TargetRewardItems int `json:"targetRewardItems"`
|
||||
ActualRewardItems int64 `json:"actualRewardItems"`
|
||||
DrawRequests int64 `json:"drawRequests"`
|
||||
RandomUsers int `json:"randomUsers"`
|
||||
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||
DrawTimesRewardItems map[string]int64 `json:"drawTimesRewardItems"`
|
||||
CategoryRewardItems map[string]int64 `json:"categoryRewardItems"`
|
||||
StatusCounts map[string]int64 `json:"statusCounts"`
|
||||
PaymentEvents int `json:"paymentEvents"`
|
||||
IncomeEvents int `json:"incomeEvents"`
|
||||
LimitedRewards int `json:"limitedRewards"`
|
||||
ExhaustedLimitedRewards int `json:"exhaustedLimitedRewards"`
|
||||
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||
UserLimitViolations int `json:"userLimitViolations"`
|
||||
IssuedCountMismatches int `json:"issuedCountMismatches"`
|
||||
TopRewards []topRewardSummary `json:"topRewards"`
|
||||
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||
}
|
||||
|
||||
func (g *simGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.events == nil {
|
||||
g.events = map[string]integration.GoldReceiptCommand{}
|
||||
}
|
||||
if g.balance == nil {
|
||||
g.balance = map[int64]int64{}
|
||||
}
|
||||
if _, exists := g.events[cmd.EventID]; exists {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToUpper(cmd.ReceiptType) {
|
||||
case receiptExpense:
|
||||
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
|
||||
case receiptIncome:
|
||||
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
|
||||
}
|
||||
g.events[cmd.EventID] = cmd
|
||||
g.changes = append(g.changes, cmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
_, exists := g.events[eventID]
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (g *simGateway) GivePropsBackpack(context.Context, integration.GivePropsBackpackRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) IncrGiftBackpack(context.Context, int64, int64, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) SwitchUseProps(context.Context, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) ActivateTemporaryBadge(context.Context, int64, int64, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) RemoveUserProfileCacheAll(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *simGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
result := make(map[int64]int64, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
result[userID] = g.balance[userID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (g *simGateway) GetRoomProfileByUserID(_ context.Context, userID int64) (integration.RoomProfile, error) {
|
||||
return integration.RoomProfile{
|
||||
ID: integration.Int64Value(simRoomID),
|
||||
UserID: integration.Int64Value(userID),
|
||||
RoomName: "local-wheel-random-sim-room",
|
||||
SysOrigin: simSysOrigin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *simGateway) countReceipt(receiptType string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
count := 0
|
||||
for _, change := range g.changes {
|
||||
if strings.EqualFold(change.ReceiptType, receiptType) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
startedAt := time.Now()
|
||||
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||
target := envInt("WHEEL_SIM_TARGET_REWARDS", targetRewardCount)
|
||||
userCount := envInt("WHEEL_SIM_USERS", 5000)
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
|
||||
db, dsn, err := connectMySQL(ctx)
|
||||
must(err, "connect mysql")
|
||||
sqlDB, err := db.DB()
|
||||
must(err, "unwrap mysql")
|
||||
defer sqlDB.Close()
|
||||
|
||||
must(ensureWheelSchema(db), "ensure wheel schema")
|
||||
must(cleanup(ctx, db, simSysOrigin), "cleanup previous sim data")
|
||||
|
||||
gateway := &simGateway{
|
||||
events: map[string]integration.GoldReceiptCommand{},
|
||||
balance: make(map[int64]int64, userCount),
|
||||
}
|
||||
for i := 0; i < userCount; i++ {
|
||||
gateway.balance[simUserIDBase+int64(i)] = 1_000_000_000
|
||||
}
|
||||
service := wheel.NewService(config.Config{}, db, gateway)
|
||||
must(seedRandomConfig(ctx, service, rng), "seed random wheel config")
|
||||
|
||||
categorySpecs := specs()
|
||||
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
drawTimesRewardItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
categoryRewardItems := map[string]int64{}
|
||||
uniqueUsers := map[int64]struct{}{}
|
||||
var drawRequests int64
|
||||
var rewardItems int64
|
||||
|
||||
for rewardItems < int64(target) {
|
||||
userID := simUserIDBase + int64(rng.Intn(userCount))
|
||||
category := categorySpecs[rng.Intn(len(categorySpecs))].Category
|
||||
times := randomDrawTimes(rng)
|
||||
|
||||
resp, err := service.Draw(ctx, common.AuthUser{UserID: userID, SysOrigin: simSysOrigin}, wheel.DrawRequest{
|
||||
Category: category,
|
||||
Times: times,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("draw failed after requests=%d rewards=%d user=%d category=%s times=%d: %v", drawRequests, rewardItems, userID, category, times, err)
|
||||
}
|
||||
if resp.GrantFailed {
|
||||
log.Fatalf("grant failed after requests=%d rewards=%d drawNo=%s", drawRequests, rewardItems, resp.DrawNo)
|
||||
}
|
||||
|
||||
drawRequests++
|
||||
uniqueUsers[userID] = struct{}{}
|
||||
key := strconv.Itoa(times)
|
||||
drawTimesRequests[key]++
|
||||
drawTimesRewardItems[key] += int64(len(resp.Records))
|
||||
for _, record := range resp.Records {
|
||||
rewardItems++
|
||||
categoryRewardItems[record.Category]++
|
||||
}
|
||||
if rewardItems > 0 && rewardItems%20000 < int64(times) {
|
||||
log.Printf("progress rewards=%d requests=%d", rewardItems, drawRequests)
|
||||
}
|
||||
}
|
||||
|
||||
statusCounts := loadStatusCounts(ctx, db)
|
||||
topRewards := loadTopRewards(ctx, db, 12)
|
||||
totalLimitViolations := countTotalLimitViolations(ctx, db)
|
||||
userLimitViolations := countUserLimitViolations(ctx, db)
|
||||
issuedCountMismatches := countIssuedCountMismatches(ctx, db)
|
||||
limitedRewards, exhaustedLimitedRewards := countLimitedRewards(ctx, db)
|
||||
|
||||
assert(totalLimitViolations == 0, fmt.Sprintf("total limit violations = %d", totalLimitViolations))
|
||||
assert(userLimitViolations == 0, fmt.Sprintf("user limit violations = %d", userLimitViolations))
|
||||
assert(issuedCountMismatches == 0, fmt.Sprintf("issued count mismatches = %d", issuedCountMismatches))
|
||||
assert(statusCounts["SUCCESS"] == rewardItems, fmt.Sprintf("success count = %d, rewards = %d", statusCounts["SUCCESS"], rewardItems))
|
||||
|
||||
summary := simSummary{
|
||||
DSNHost: dsnLabel(dsn),
|
||||
SysOrigin: simSysOrigin,
|
||||
Seed: seed,
|
||||
TargetRewardItems: target,
|
||||
ActualRewardItems: rewardItems,
|
||||
DrawRequests: drawRequests,
|
||||
RandomUsers: userCount,
|
||||
UniqueUsersDrawn: len(uniqueUsers),
|
||||
DrawTimesRequests: drawTimesRequests,
|
||||
DrawTimesRewardItems: drawTimesRewardItems,
|
||||
CategoryRewardItems: categoryRewardItems,
|
||||
StatusCounts: statusCounts,
|
||||
PaymentEvents: gateway.countReceipt(receiptExpense),
|
||||
IncomeEvents: gateway.countReceipt(receiptIncome),
|
||||
LimitedRewards: limitedRewards,
|
||||
ExhaustedLimitedRewards: exhaustedLimitedRewards,
|
||||
TotalLimitViolations: totalLimitViolations,
|
||||
UserLimitViolations: userLimitViolations,
|
||||
IssuedCountMismatches: issuedCountMismatches,
|
||||
TopRewards: topRewards,
|
||||
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
out, err := json.MarshalIndent(summary, "", " ")
|
||||
must(err, "marshal summary")
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func specs() []categorySpec {
|
||||
return []categorySpec{
|
||||
{Category: "CLASSIC", RewardCount: 8, PriceOneGold: 3, PriceTenGold: 30, PriceFiftyGold: 150, GoldBase: 10},
|
||||
{Category: "LUXURY", RewardCount: 8, PriceOneGold: 30, PriceTenGold: 300, PriceFiftyGold: 1500, GoldBase: 100},
|
||||
{Category: "ADVANCED", RewardCount: 12, PriceOneGold: 100, PriceTenGold: 1000, PriceFiftyGold: 5000, GoldBase: 500},
|
||||
}
|
||||
}
|
||||
|
||||
func seedRandomConfig(ctx context.Context, service *wheel.Service, rng *rand.Rand) error {
|
||||
categories := make([]wheel.CategoryConfigInput, 0, len(specs()))
|
||||
for _, spec := range specs() {
|
||||
probabilities := randomProbabilities(spec.RewardCount, rng)
|
||||
rewards := make([]wheel.RewardConfigInput, 0, spec.RewardCount)
|
||||
for index := 0; index < spec.RewardCount; index++ {
|
||||
totalLimit := int64(0)
|
||||
userLimit := int64(0)
|
||||
if index == 0 {
|
||||
totalLimit = int64(120 + rng.Intn(81))
|
||||
userLimit = 1
|
||||
}
|
||||
if index == 1 {
|
||||
totalLimit = int64(260 + rng.Intn(121))
|
||||
userLimit = 2
|
||||
}
|
||||
rewards = append(rewards, wheel.RewardConfigInput{
|
||||
Enabled: true,
|
||||
Sort: index + 1,
|
||||
RewardType: "GOLD",
|
||||
GoldAmount: spec.GoldBase * int64(spec.RewardCount-index),
|
||||
Probability: probabilities[index],
|
||||
TotalLimit: totalLimit,
|
||||
UserLimit: userLimit,
|
||||
})
|
||||
}
|
||||
categories = append(categories, wheel.CategoryConfigInput{
|
||||
Category: spec.Category,
|
||||
Enabled: true,
|
||||
PriceOneGold: spec.PriceOneGold,
|
||||
PriceTenGold: spec.PriceTenGold,
|
||||
PriceFiftyGold: spec.PriceFiftyGold,
|
||||
Rewards: rewards,
|
||||
})
|
||||
}
|
||||
_, err := service.SaveConfig(ctx, wheel.SaveConfigRequest{
|
||||
SysOrigin: simSysOrigin,
|
||||
Enabled: true,
|
||||
Timezone: "Asia/Riyadh",
|
||||
Categories: categories,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func randomProbabilities(count int, rng *rand.Rand) []int {
|
||||
raw := make([]int, count)
|
||||
for i := 0; i < count; i++ {
|
||||
raw[i] = rng.Intn(1000) + 1
|
||||
}
|
||||
raw[0] += 7000 + rng.Intn(3000)
|
||||
raw[1] += 3500 + rng.Intn(2000)
|
||||
|
||||
rawTotal := 0
|
||||
for _, value := range raw {
|
||||
rawTotal += value
|
||||
}
|
||||
result := make([]int, count)
|
||||
assigned := 0
|
||||
for i, value := range raw {
|
||||
probability := value * probabilityTotal / rawTotal
|
||||
if probability <= 0 {
|
||||
probability = 1
|
||||
}
|
||||
result[i] = probability
|
||||
assigned += probability
|
||||
}
|
||||
result[0] += probabilityTotal - assigned
|
||||
return result
|
||||
}
|
||||
|
||||
func randomDrawTimes(rng *rand.Rand) int {
|
||||
switch rng.Intn(3) {
|
||||
case 0:
|
||||
return 1
|
||||
case 1:
|
||||
return 10
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func loadStatusCounts(ctx context.Context, db *gorm.DB) map[string]int64 {
|
||||
var rows []struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT status, COUNT(*) AS count
|
||||
FROM wheel_draw_record
|
||||
WHERE sys_origin = ?
|
||||
GROUP BY status
|
||||
`, simSysOrigin).Scan(&rows).Error, "load status counts")
|
||||
result := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
result[row.Status] = row.Count
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadTopRewards(ctx context.Context, db *gorm.DB, limit int) []topRewardSummary {
|
||||
var rows []topRewardSummary
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT
|
||||
c.category AS category,
|
||||
c.sort AS sort,
|
||||
c.id AS reward_id,
|
||||
c.gold_amount AS gold_amount,
|
||||
c.probability AS probability,
|
||||
c.total_limit AS total_limit,
|
||||
c.user_limit AS user_limit,
|
||||
c.issued_count AS issued_count,
|
||||
COUNT(r.id) AS actual_count
|
||||
FROM wheel_reward_config c
|
||||
LEFT JOIN wheel_draw_record r
|
||||
ON r.reward_config_id = c.id
|
||||
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||
WHERE c.sys_origin = ?
|
||||
GROUP BY c.category, c.sort, c.id, c.gold_amount, c.probability, c.total_limit, c.user_limit, c.issued_count
|
||||
ORDER BY actual_count DESC, c.category ASC, c.sort ASC
|
||||
LIMIT ?
|
||||
`, simSysOrigin, limit).Scan(&rows).Error, "load top rewards")
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].ActualCount == rows[j].ActualCount {
|
||||
return rows[i].RewardID < rows[j].RewardID
|
||||
}
|
||||
return rows[i].ActualCount > rows[j].ActualCount
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
func countTotalLimitViolations(ctx context.Context, db *gorm.DB) int {
|
||||
var count int64
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT c.id
|
||||
FROM wheel_reward_config c
|
||||
LEFT JOIN wheel_draw_record r
|
||||
ON r.reward_config_id = c.id
|
||||
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||
WHERE c.sys_origin = ?
|
||||
AND c.total_limit > 0
|
||||
GROUP BY c.id, c.total_limit
|
||||
HAVING COUNT(r.id) > c.total_limit
|
||||
) t
|
||||
`, simSysOrigin).Scan(&count).Error, "count total limit violations")
|
||||
return int(count)
|
||||
}
|
||||
|
||||
func countUserLimitViolations(ctx context.Context, db *gorm.DB) int {
|
||||
var count int64
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT r.reward_config_id, r.user_id
|
||||
FROM wheel_draw_record r
|
||||
JOIN wheel_reward_config c ON c.id = r.reward_config_id
|
||||
WHERE r.sys_origin = ?
|
||||
AND c.user_limit > 0
|
||||
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||
GROUP BY r.reward_config_id, r.user_id, c.user_limit
|
||||
HAVING COUNT(*) > c.user_limit
|
||||
) t
|
||||
`, simSysOrigin).Scan(&count).Error, "count user limit violations")
|
||||
return int(count)
|
||||
}
|
||||
|
||||
func countIssuedCountMismatches(ctx context.Context, db *gorm.DB) int {
|
||||
var count int64
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT c.id
|
||||
FROM wheel_reward_config c
|
||||
LEFT JOIN wheel_draw_record r
|
||||
ON r.reward_config_id = c.id
|
||||
AND r.status IN ('PENDING', 'GRANTING', 'SUCCESS', 'FAILED')
|
||||
WHERE c.sys_origin = ?
|
||||
GROUP BY c.id, c.issued_count
|
||||
HAVING c.issued_count <> COUNT(r.id)
|
||||
) t
|
||||
`, simSysOrigin).Scan(&count).Error, "count issued count mismatches")
|
||||
return int(count)
|
||||
}
|
||||
|
||||
func countLimitedRewards(ctx context.Context, db *gorm.DB) (int, int) {
|
||||
var rows []struct {
|
||||
TotalLimit int64
|
||||
IssuedCount int64
|
||||
}
|
||||
must(db.WithContext(ctx).Raw(`
|
||||
SELECT total_limit AS total_limit, issued_count AS issued_count
|
||||
FROM wheel_reward_config
|
||||
WHERE sys_origin = ?
|
||||
AND total_limit > 0
|
||||
`, simSysOrigin).Scan(&rows).Error, "count limited rewards")
|
||||
exhausted := 0
|
||||
for _, row := range rows {
|
||||
if row.IssuedCount >= row.TotalLimit {
|
||||
exhausted++
|
||||
}
|
||||
}
|
||||
return len(rows), exhausted
|
||||
}
|
||||
|
||||
func connectMySQL(ctx context.Context) (*gorm.DB, string, error) {
|
||||
candidates := uniqueStrings([]string{
|
||||
strings.TrimSpace(os.Getenv("CHATAPP_STORE_MYSQL_DSN")),
|
||||
"root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh",
|
||||
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
})
|
||||
var errs []string
|
||||
for _, dsn := range candidates {
|
||||
if dsn == "" {
|
||||
continue
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", dsnLabel(dsn), err))
|
||||
continue
|
||||
}
|
||||
return db, dsn, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("no local mysql dsn available: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func ensureWheelSchema(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.WheelConfig{},
|
||||
&model.WheelPoolConfig{},
|
||||
&model.WheelRewardConfig{},
|
||||
&model.WheelDrawRecord{},
|
||||
&model.ActivityIdempotency{},
|
||||
)
|
||||
}
|
||||
|
||||
func cleanup(ctx context.Context, db *gorm.DB, sysOrigin string) error {
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var configIDs []int64
|
||||
if err := tx.Model(&model.WheelConfig{}).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
Pluck("id", &configIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.WheelDrawRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_origin = ?", sysOrigin).Delete(&model.ActivityIdempotency{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(configIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelRewardConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("config_id IN ?", configIDs).Delete(&model.WheelPoolConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id IN ?", configIDs).Delete(&model.WheelConfig{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func appErrorCode(err error) string {
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dsnLabel(dsn string) string {
|
||||
if at := strings.Index(dsn, "@tcp("); at >= 0 {
|
||||
rest := dsn[at+len("@tcp("):]
|
||||
hostEnd := strings.Index(rest, ")")
|
||||
if hostEnd >= 0 {
|
||||
host := rest[:hostEnd]
|
||||
dbName := ""
|
||||
after := rest[hostEnd+1:]
|
||||
if strings.HasPrefix(after, "/") {
|
||||
dbPart := strings.TrimPrefix(after, "/")
|
||||
if question := strings.Index(dbPart, "?"); question >= 0 {
|
||||
dbPart = dbPart[:question]
|
||||
}
|
||||
dbName = dbPart
|
||||
}
|
||||
if dbName != "" {
|
||||
return host + "/" + dbName
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
return "custom-dsn"
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt64(name string, fallback int64) int64 {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func must(err error, step string) {
|
||||
if err != nil {
|
||||
log.Fatalf("%s: %v", step, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assert(ok bool, message string) {
|
||||
if !ok {
|
||||
log.Fatal(message)
|
||||
}
|
||||
}
|
||||
534
scripts/wheel_rtp_random_simulate.go
Normal file
534
scripts/wheel_rtp_random_simulate.go
Normal file
@ -0,0 +1,534 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/service/prizepool"
|
||||
)
|
||||
|
||||
const (
|
||||
probabilityTotal = 1000000
|
||||
defaultTarget = 1000000
|
||||
defaultUsers = 10000
|
||||
targetRTPBasis = 9800
|
||||
)
|
||||
|
||||
type categoryConfig struct {
|
||||
Category string
|
||||
PricePerDraw int64
|
||||
Rewards []*rewardConfig
|
||||
}
|
||||
|
||||
type rewardConfig struct {
|
||||
ID int64
|
||||
Category string
|
||||
Sort int
|
||||
GoldAmount int64
|
||||
Probability int
|
||||
Hits int64
|
||||
}
|
||||
|
||||
type categorySummary struct {
|
||||
Category string `json:"category"`
|
||||
DrawItems int64 `json:"drawItems"`
|
||||
PaidGold int64 `json:"paidGold"`
|
||||
PayoutGold int64 `json:"payoutGold"`
|
||||
RTPPercent float64 `json:"rtpPercent"`
|
||||
TheoryRTP float64 `json:"theoryRtpPercent"`
|
||||
ExpectedPayout float64 `json:"expectedPayoutGold"`
|
||||
DeviationGold float64 `json:"deviationGold"`
|
||||
}
|
||||
|
||||
type rewardSummary struct {
|
||||
Category string `json:"category"`
|
||||
Sort int `json:"sort"`
|
||||
RewardID int64 `json:"rewardId"`
|
||||
GoldAmount int64 `json:"goldAmount"`
|
||||
Probability int `json:"probability"`
|
||||
TheoryRate float64 `json:"theoryRatePercent"`
|
||||
Hits int64 `json:"hits"`
|
||||
ActualRate float64 `json:"actualRatePercent"`
|
||||
ExpectedHits float64 `json:"expectedHits"`
|
||||
HitDeviation float64 `json:"hitDeviation"`
|
||||
}
|
||||
|
||||
type userSummary struct {
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
PositiveUsers int `json:"positiveUsers"`
|
||||
NegativeUsers int `json:"negativeUsers"`
|
||||
BreakEvenUsers int `json:"breakEvenUsers"`
|
||||
MinRTP float64 `json:"minRtpPercent"`
|
||||
P50RTP float64 `json:"p50RtpPercent"`
|
||||
P90RTP float64 `json:"p90RtpPercent"`
|
||||
P99RTP float64 `json:"p99RtpPercent"`
|
||||
MaxRTP float64 `json:"maxRtpPercent"`
|
||||
MinNetGold int64 `json:"minNetGold"`
|
||||
P50NetGold int64 `json:"p50NetGold"`
|
||||
P90NetGold int64 `json:"p90NetGold"`
|
||||
P99NetGold int64 `json:"p99NetGold"`
|
||||
MaxNetGold int64 `json:"maxNetGold"`
|
||||
AveragePaidGold float64 `json:"averagePaidGold"`
|
||||
AverageNetGold float64 `json:"averageNetGold"`
|
||||
}
|
||||
|
||||
type summary struct {
|
||||
Seed int64 `json:"seed"`
|
||||
TargetRTPPercent float64 `json:"targetRtpPercent"`
|
||||
TargetDrawItems int `json:"targetDrawItems"`
|
||||
ActualDrawItems int64 `json:"actualDrawItems"`
|
||||
DrawRequests int64 `json:"drawRequests"`
|
||||
RandomUsers int `json:"randomUsers"`
|
||||
UniqueUsersDrawn int `json:"uniqueUsersDrawn"`
|
||||
DrawTimesRequests map[string]int64 `json:"drawTimesRequests"`
|
||||
DrawTimesItems map[string]int64 `json:"drawTimesItems"`
|
||||
PaidGold int64 `json:"paidGold"`
|
||||
PayoutGold int64 `json:"payoutGold"`
|
||||
NetGold int64 `json:"netGold"`
|
||||
ActualRTPPercent float64 `json:"actualRtpPercent"`
|
||||
ExpectedPayoutGold float64 `json:"expectedPayoutGold"`
|
||||
DeviationGold float64 `json:"deviationGold"`
|
||||
DeviationPercent float64 `json:"deviationPercent"`
|
||||
StopLossEnabled bool `json:"stopLossEnabled"`
|
||||
TotalLimitViolations int `json:"totalLimitViolations"`
|
||||
UserLimitViolations int `json:"userLimitViolations"`
|
||||
CategorySummaries []categorySummary `json:"categorySummaries"`
|
||||
UserSummary userSummary `json:"userSummary"`
|
||||
TopRewardsByHits []rewardSummary `json:"topRewardsByHits"`
|
||||
RewardSummaries map[string][]rewardSummary `json:"rewardSummaries"`
|
||||
ElapsedMillis int64 `json:"elapsedMillis"`
|
||||
}
|
||||
|
||||
type userStat struct {
|
||||
paid int64
|
||||
payout int64
|
||||
}
|
||||
|
||||
type categoryStat struct {
|
||||
items int64
|
||||
paid int64
|
||||
payout int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
startedAt := time.Now()
|
||||
seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano())
|
||||
target := envInt("WHEEL_SIM_TARGET_DRAWS", defaultTarget)
|
||||
userCount := envInt("WHEEL_SIM_USERS", defaultUsers)
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
|
||||
categories := buildRTPConfig(rng)
|
||||
itemsByCategory := map[string][]prizepool.PickResult{}
|
||||
for _, category := range categories {
|
||||
itemsByCategory[category.Category] = prizePoolItems(category.Rewards)
|
||||
}
|
||||
|
||||
userStats := make([]userStat, userCount)
|
||||
categoryStats := map[string]*categoryStat{}
|
||||
for _, category := range categories {
|
||||
categoryStats[category.Category] = &categoryStat{}
|
||||
}
|
||||
drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
drawTimesItems := map[string]int64{"1": 0, "10": 0, "50": 0}
|
||||
uniqueUsers := map[int]struct{}{}
|
||||
picker := prizepool.Picker{RandomIntn: func(max int) (int, error) {
|
||||
return rng.Intn(max), nil
|
||||
}}
|
||||
|
||||
var drawRequests int64
|
||||
var drawItems int64
|
||||
var paidGold int64
|
||||
var payoutGold int64
|
||||
|
||||
for drawItems < int64(target) {
|
||||
userIndex := rng.Intn(userCount)
|
||||
category := categories[rng.Intn(len(categories))]
|
||||
times := randomDrawTimes(rng)
|
||||
timesKey := strconv.Itoa(times)
|
||||
requestPaid := category.PricePerDraw * int64(times)
|
||||
|
||||
drawRequests++
|
||||
drawTimesRequests[timesKey]++
|
||||
drawTimesItems[timesKey] += int64(times)
|
||||
uniqueUsers[userIndex] = struct{}{}
|
||||
userStats[userIndex].paid += requestPaid
|
||||
categoryStats[category.Category].paid += requestPaid
|
||||
paidGold += requestPaid
|
||||
|
||||
for i := 0; i < times; i++ {
|
||||
picked, err := picker.PickWeighted(itemsByCategory[category.Category])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
reward := category.Rewards[picked.Index]
|
||||
reward.Hits++
|
||||
drawItems++
|
||||
payoutGold += reward.GoldAmount
|
||||
userStats[userIndex].payout += reward.GoldAmount
|
||||
categoryStats[category.Category].items++
|
||||
categoryStats[category.Category].payout += reward.GoldAmount
|
||||
}
|
||||
}
|
||||
|
||||
expectedPayout := float64(paidGold) * float64(targetRTPBasis) / 10000
|
||||
categorySummaries := buildCategorySummaries(categories, categoryStats)
|
||||
rewardSummaries, topRewards := buildRewardSummaries(categories, categoryStats, 14)
|
||||
result := summary{
|
||||
Seed: seed,
|
||||
TargetRTPPercent: float64(targetRTPBasis) / 100,
|
||||
TargetDrawItems: target,
|
||||
ActualDrawItems: drawItems,
|
||||
DrawRequests: drawRequests,
|
||||
RandomUsers: userCount,
|
||||
UniqueUsersDrawn: len(uniqueUsers),
|
||||
DrawTimesRequests: drawTimesRequests,
|
||||
DrawTimesItems: drawTimesItems,
|
||||
PaidGold: paidGold,
|
||||
PayoutGold: payoutGold,
|
||||
NetGold: payoutGold - paidGold,
|
||||
ActualRTPPercent: percent(float64(payoutGold), float64(paidGold)),
|
||||
ExpectedPayoutGold: expectedPayout,
|
||||
DeviationGold: float64(payoutGold) - expectedPayout,
|
||||
DeviationPercent: percent(float64(payoutGold)-expectedPayout, expectedPayout),
|
||||
StopLossEnabled: false,
|
||||
TotalLimitViolations: 0,
|
||||
UserLimitViolations: 0,
|
||||
CategorySummaries: categorySummaries,
|
||||
UserSummary: buildUserSummary(userStats),
|
||||
TopRewardsByHits: topRewards,
|
||||
RewardSummaries: rewardSummaries,
|
||||
ElapsedMillis: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func buildRTPConfig(rng *rand.Rand) []categoryConfig {
|
||||
configs := []struct {
|
||||
category string
|
||||
price int64
|
||||
values []int64
|
||||
}{
|
||||
{
|
||||
category: "CLASSIC",
|
||||
price: 100,
|
||||
values: []int64{20, 40, 60, 80, 120, 200, 500, 2000},
|
||||
},
|
||||
{
|
||||
category: "LUXURY",
|
||||
price: 1000,
|
||||
values: []int64{200, 400, 700, 1000, 1500, 3000, 10000, 50000},
|
||||
},
|
||||
{
|
||||
category: "ADVANCED",
|
||||
price: 10000,
|
||||
values: []int64{2000, 4000, 7000, 10000, 15000, 30000, 80000, 200000, 500000, 1000000, 3000000, 10000000},
|
||||
},
|
||||
}
|
||||
|
||||
var rewardID int64 = 1
|
||||
result := make([]categoryConfig, 0, len(configs))
|
||||
for _, cfg := range configs {
|
||||
probabilities := probabilitiesForTargetRTP(cfg.values, cfg.price, rng)
|
||||
rewards := make([]*rewardConfig, 0, len(cfg.values))
|
||||
for index, value := range cfg.values {
|
||||
rewards = append(rewards, &rewardConfig{
|
||||
ID: rewardID,
|
||||
Category: cfg.category,
|
||||
Sort: index + 1,
|
||||
GoldAmount: value,
|
||||
Probability: probabilities[index],
|
||||
})
|
||||
rewardID++
|
||||
}
|
||||
result = append(result, categoryConfig{
|
||||
Category: cfg.category,
|
||||
PricePerDraw: cfg.price,
|
||||
Rewards: rewards,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func probabilitiesForTargetRTP(values []int64, price int64, rng *rand.Rand) []int {
|
||||
targetValueNumerator := price * int64(targetRTPBasis) * int64(probabilityTotal)
|
||||
targetValueNumerator /= 10000
|
||||
|
||||
for attempt := 0; attempt < 200; attempt++ {
|
||||
probabilities := make([]int, len(values))
|
||||
noiseTotal := 50000
|
||||
remainingNoise := noiseTotal
|
||||
for i := range probabilities {
|
||||
if i == len(probabilities)-1 {
|
||||
probabilities[i] = remainingNoise
|
||||
break
|
||||
}
|
||||
value := rng.Intn(remainingNoise / 2)
|
||||
probabilities[i] = value
|
||||
remainingNoise -= value
|
||||
}
|
||||
rng.Shuffle(len(probabilities), func(i, j int) {
|
||||
probabilities[i], probabilities[j] = probabilities[j], probabilities[i]
|
||||
})
|
||||
|
||||
usedProbability := 0
|
||||
usedValueNumerator := int64(0)
|
||||
for index, probability := range probabilities {
|
||||
usedProbability += probability
|
||||
usedValueNumerator += int64(probability) * values[index]
|
||||
}
|
||||
remainingProbability := probabilityTotal - usedProbability
|
||||
remainingValueNumerator := targetValueNumerator - usedValueNumerator
|
||||
if remainingProbability <= 0 || remainingValueNumerator <= 0 {
|
||||
continue
|
||||
}
|
||||
remainingAverage := float64(remainingValueNumerator) / float64(remainingProbability)
|
||||
lowIndex, highIndex := bracketIndexes(values, remainingAverage)
|
||||
if lowIndex < 0 || highIndex < 0 || lowIndex == highIndex {
|
||||
continue
|
||||
}
|
||||
|
||||
low := values[lowIndex]
|
||||
high := values[highIndex]
|
||||
highProbability := int(math.Round(float64(remainingValueNumerator-int64(remainingProbability)*low) / float64(high-low)))
|
||||
if highProbability < 0 || highProbability > remainingProbability {
|
||||
continue
|
||||
}
|
||||
lowProbability := remainingProbability - highProbability
|
||||
probabilities[lowIndex] += lowProbability
|
||||
probabilities[highIndex] += highProbability
|
||||
normalizeProbabilityTotal(probabilities)
|
||||
return probabilities
|
||||
}
|
||||
log.Fatal("failed to build probability config for target RTP")
|
||||
return nil
|
||||
}
|
||||
|
||||
func bracketIndexes(values []int64, target float64) (int, int) {
|
||||
lowIndex := -1
|
||||
highIndex := -1
|
||||
for index, value := range values {
|
||||
if float64(value) <= target {
|
||||
lowIndex = index
|
||||
}
|
||||
if float64(value) >= target {
|
||||
highIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
return lowIndex, highIndex
|
||||
}
|
||||
|
||||
func normalizeProbabilityTotal(probabilities []int) {
|
||||
total := 0
|
||||
maxIndex := 0
|
||||
for index, probability := range probabilities {
|
||||
total += probability
|
||||
if probability > probabilities[maxIndex] {
|
||||
maxIndex = index
|
||||
}
|
||||
}
|
||||
probabilities[maxIndex] += probabilityTotal - total
|
||||
}
|
||||
|
||||
func prizePoolItems(rewards []*rewardConfig) []prizepool.PickResult {
|
||||
items := make([]prizepool.PickResult, 0, len(rewards))
|
||||
for index, reward := range rewards {
|
||||
items = append(items, prizepool.PickResult{
|
||||
Item: prizepool.Item{
|
||||
ID: reward.ID,
|
||||
Enabled: true,
|
||||
Weight: reward.Probability,
|
||||
},
|
||||
Index: index,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func randomDrawTimes(rng *rand.Rand) int {
|
||||
switch rng.Intn(3) {
|
||||
case 0:
|
||||
return 1
|
||||
case 1:
|
||||
return 10
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func buildCategorySummaries(categories []categoryConfig, stats map[string]*categoryStat) []categorySummary {
|
||||
result := make([]categorySummary, 0, len(categories))
|
||||
for _, category := range categories {
|
||||
stat := stats[category.Category]
|
||||
theoryRTP := theoreticalRTP(category)
|
||||
expectedPayout := float64(stat.paid) * theoryRTP / 100
|
||||
result = append(result, categorySummary{
|
||||
Category: category.Category,
|
||||
DrawItems: stat.items,
|
||||
PaidGold: stat.paid,
|
||||
PayoutGold: stat.payout,
|
||||
RTPPercent: percent(float64(stat.payout), float64(stat.paid)),
|
||||
TheoryRTP: theoryRTP,
|
||||
ExpectedPayout: expectedPayout,
|
||||
DeviationGold: float64(stat.payout) - expectedPayout,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildRewardSummaries(categories []categoryConfig, stats map[string]*categoryStat, topLimit int) (map[string][]rewardSummary, []rewardSummary) {
|
||||
byCategory := map[string][]rewardSummary{}
|
||||
all := make([]rewardSummary, 0)
|
||||
for _, category := range categories {
|
||||
stat := stats[category.Category]
|
||||
rows := make([]rewardSummary, 0, len(category.Rewards))
|
||||
for _, reward := range category.Rewards {
|
||||
expectedHits := float64(stat.items) * float64(reward.Probability) / probabilityTotal
|
||||
row := rewardSummary{
|
||||
Category: reward.Category,
|
||||
Sort: reward.Sort,
|
||||
RewardID: reward.ID,
|
||||
GoldAmount: reward.GoldAmount,
|
||||
Probability: reward.Probability,
|
||||
TheoryRate: float64(reward.Probability) / 10000,
|
||||
Hits: reward.Hits,
|
||||
ActualRate: percent(float64(reward.Hits), float64(stat.items)),
|
||||
ExpectedHits: expectedHits,
|
||||
HitDeviation: float64(reward.Hits) - expectedHits,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
all = append(all, row)
|
||||
}
|
||||
byCategory[category.Category] = rows
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
if all[i].Hits == all[j].Hits {
|
||||
return all[i].RewardID < all[j].RewardID
|
||||
}
|
||||
return all[i].Hits > all[j].Hits
|
||||
})
|
||||
if len(all) > topLimit {
|
||||
all = all[:topLimit]
|
||||
}
|
||||
return byCategory, all
|
||||
}
|
||||
|
||||
func theoreticalRTP(category categoryConfig) float64 {
|
||||
expected := float64(0)
|
||||
for _, reward := range category.Rewards {
|
||||
expected += float64(reward.Probability) / probabilityTotal * float64(reward.GoldAmount)
|
||||
}
|
||||
return percent(expected, float64(category.PricePerDraw))
|
||||
}
|
||||
|
||||
func buildUserSummary(userStats []userStat) userSummary {
|
||||
rtps := make([]float64, 0, len(userStats))
|
||||
nets := make([]int64, 0, len(userStats))
|
||||
active := 0
|
||||
positive := 0
|
||||
negative := 0
|
||||
breakEven := 0
|
||||
totalPaid := int64(0)
|
||||
totalNet := int64(0)
|
||||
for _, stat := range userStats {
|
||||
if stat.paid <= 0 {
|
||||
continue
|
||||
}
|
||||
active++
|
||||
net := stat.payout - stat.paid
|
||||
switch {
|
||||
case net > 0:
|
||||
positive++
|
||||
case net < 0:
|
||||
negative++
|
||||
default:
|
||||
breakEven++
|
||||
}
|
||||
rtps = append(rtps, percent(float64(stat.payout), float64(stat.paid)))
|
||||
nets = append(nets, net)
|
||||
totalPaid += stat.paid
|
||||
totalNet += net
|
||||
}
|
||||
sort.Float64s(rtps)
|
||||
sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] })
|
||||
return userSummary{
|
||||
ActiveUsers: active,
|
||||
PositiveUsers: positive,
|
||||
NegativeUsers: negative,
|
||||
BreakEvenUsers: breakEven,
|
||||
MinRTP: percentileFloat(rtps, 0),
|
||||
P50RTP: percentileFloat(rtps, 0.50),
|
||||
P90RTP: percentileFloat(rtps, 0.90),
|
||||
P99RTP: percentileFloat(rtps, 0.99),
|
||||
MaxRTP: percentileFloat(rtps, 1),
|
||||
MinNetGold: percentileInt(nets, 0),
|
||||
P50NetGold: percentileInt(nets, 0.50),
|
||||
P90NetGold: percentileInt(nets, 0.90),
|
||||
P99NetGold: percentileInt(nets, 0.99),
|
||||
MaxNetGold: percentileInt(nets, 1),
|
||||
AveragePaidGold: float64(totalPaid) / float64(active),
|
||||
AverageNetGold: float64(totalNet) / float64(active),
|
||||
}
|
||||
}
|
||||
|
||||
func percentileFloat(values []float64, p float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(math.Round(p * float64(len(values)-1)))
|
||||
return values[index]
|
||||
}
|
||||
|
||||
func percentileInt(values []int64, p float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(math.Round(p * float64(len(values)-1)))
|
||||
return values[index]
|
||||
}
|
||||
|
||||
func percent(numerator, denominator float64) float64 {
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
}
|
||||
return numerator / denominator * 100
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt64(name string, fallback int64) int64 {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user