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