542 lines
18 KiB
Go
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)
|
|
}
|
|
}
|