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