yumi-golang/scripts/wheel_random_simulate.go
2026-05-19 19:50:50 +08:00

639 lines
18 KiB
Go

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