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

542 lines
18 KiB
Go

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