- settlement(1.1): exhibition_revenue_records 加 UNIQUE(exhibition_id,cycle_start_time) + CreateRevenueRecord ON CONFLICT DO NOTHING; MQ 用 settled_at 替代复用 is_processed; 恢复扫描器过滤改 settled_at IS NULL; created_at 统一毫秒; 删死代码 cleanup_worker.go。 - hours(1.2): 新增 exhibition_hours_log/asset_exhibition_hours_log(source_id 唯一)幂等表; fan_profile/assetLevel 的 AddExhibitionHours 按 sourceID 幂等(事务包裹); 存量重算脚本。 - mint(1.4/1.5/1.6): crystal_transaction_records (source_id,change_type) 部分唯一索引 + UpdateCrystalBalance/CreateMintOrder 幂等; 保底改 crypto/rand; 下线伪 tx_hash; doMint Redis Lua 原子限流。 - migrations 001/002/003; 各服务单测(自包含, 缺 DB t.Skip)。 Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
5.2 KiB
Go
178 lines
5.2 KiB
Go
package service
|
|
|
|
import (
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/topfans/backend/pkg/database"
|
|
"github.com/topfans/backend/pkg/models"
|
|
"github.com/topfans/backend/services/assetService/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestCalculateBuff 纯函数,无需 DB,保留原有行为。
|
|
func TestCalculateBuff(t *testing.T) {
|
|
tests := []struct {
|
|
likeCount int
|
|
expected int
|
|
}{
|
|
{0, 0},
|
|
{4, 0},
|
|
{5, 10},
|
|
{9, 10},
|
|
{10, 20},
|
|
{29, 20},
|
|
{30, 30},
|
|
{100, 30},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := CalculateBuff(tt.likeCount)
|
|
if result != tt.expected {
|
|
t.Errorf("CalculateBuff(%d) = %d, want %d", tt.likeCount, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assetLevelTestDB 资产级幂等测试专用 DB 连接。
|
|
//
|
|
// 自包含:
|
|
// - 通过 TEST_DB_HOST/PORT/USER/PASSWORD/NAME 覆盖,默认 localhost:15432 / postgres / 123456 / top-fans。
|
|
// - 连不上 t.Skip(不污染全局 TestMain,避免影响其它测试)。
|
|
// - AutoMigrate asset_level_records / asset_exhibition_hours_log / season / asset_level(幂等所需)。
|
|
func assetLevelTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
if os.Getenv("SKIP_DB_TESTS") != "" {
|
|
t.Skip("SKIP_DB_TESTS set")
|
|
}
|
|
|
|
host := getEnvOrDefault("TEST_DB_HOST", "localhost")
|
|
portStr := getEnvOrDefault("TEST_DB_PORT", "15432")
|
|
port, _ := strconv.Atoi(portStr)
|
|
if port == 0 {
|
|
port = 15432
|
|
}
|
|
user := getEnvOrDefault("TEST_DB_USER", "postgres")
|
|
password := getEnvOrDefault("TEST_DB_PASSWORD", "123456")
|
|
dbname := getEnvOrDefault("TEST_DB_NAME", "top-fans")
|
|
|
|
if err := database.Init(database.Config{
|
|
Host: host,
|
|
Port: port,
|
|
User: user,
|
|
Password: password,
|
|
DBName: dbname,
|
|
SSLMode: "disable",
|
|
TimeZone: "Asia/Shanghai",
|
|
}); err != nil {
|
|
t.Skipf("Skipping: failed to connect to test database %s:%d/%s as %s: %v",
|
|
host, port, dbname, user, err)
|
|
}
|
|
|
|
db := database.GetDB()
|
|
|
|
// AutoMigrate 幂等所需最小表集(若已存在则跳过)。
|
|
if err := db.AutoMigrate(
|
|
&models.Season{},
|
|
&models.AssetLevel{},
|
|
&models.AssetLevelRecord{},
|
|
&models.AssetLevelChangeLog{},
|
|
&models.AssetExhibitionHoursLog{},
|
|
); err != nil {
|
|
t.Logf("Warning: AutoMigrate asset_level tables (may already exist): %v", err)
|
|
}
|
|
|
|
// 确保至少存在 N 等级(供 CheckUpgrade 不会因为没有 level 而 panic)。
|
|
var n int64
|
|
db.Model(&models.AssetLevel{}).Where("level = ?", models.LevelN).Count(&n)
|
|
if n == 0 {
|
|
db.Create(&models.AssetLevel{
|
|
Level: models.LevelN,
|
|
LevelOrder: 1,
|
|
RequireHours: 99999999,
|
|
RequireLikes: 99999999,
|
|
})
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func getEnvOrDefault(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// TestAddExhibitionHours_AssetIdempotent 资产级幂等测试。
|
|
//
|
|
// 验收:
|
|
// - 同一 sourceID 二次调用,不重复累加 SeasonExhibitionHours / LifetimeExhibitionHours。
|
|
// - 二次调用返回 upgraded=false。
|
|
// - asset_exhibition_hours_log 中恰好 1 条记录。
|
|
//
|
|
// 数据隔离:
|
|
// - 用 sentinel assetID + sourceID,只删自己 sentinel 行,不调用任何全局 cleanup helper。
|
|
func TestAddExhibitionHours_AssetIdempotent(t *testing.T) {
|
|
db := assetLevelTestDB(t)
|
|
|
|
levelRepo := repository.NewAssetLevelRepository(db)
|
|
seasonRepo := repository.NewSeasonRepository(db)
|
|
decayRepo := repository.NewSeasonDecayConfigRepository(db)
|
|
svc := NewAssetLevelService(levelRepo, seasonRepo, decayRepo)
|
|
|
|
assetID := int64(99900091)
|
|
srcID := "test_asset_exh_log_91_001"
|
|
|
|
// 先清 sentinel 行(包含上一次失败或并发残留),defer 再清一次确保退出干净。
|
|
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
|
|
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
|
|
defer func() {
|
|
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
|
|
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
|
|
}()
|
|
|
|
// 1) 首次:应该累加,新等级(sourceID 不同每次应该都能跑完 add 路径)。
|
|
lvl1, up1, err := svc.AddExhibitionHours(assetID, 5, srcID)
|
|
if err != nil {
|
|
t.Fatalf("first AddExhibitionHours err: %v", err)
|
|
}
|
|
_ = lvl1
|
|
_ = up1
|
|
|
|
// 2) 二次:同 sourceID,应该跳过累加、不触发升级。
|
|
lvl2, up2, err := svc.AddExhibitionHours(assetID, 5, srcID)
|
|
if err != nil {
|
|
t.Fatalf("second AddExhibitionHours err: %v", err)
|
|
}
|
|
if up2 {
|
|
t.Errorf("want upgraded=false on duplicate source_id, got true (level=%s)", lvl2)
|
|
}
|
|
if lvl2 == "" {
|
|
t.Errorf("want non-empty level on duplicate, got empty string")
|
|
}
|
|
|
|
// 3) 校验累加表:SeasonExhibitionHours 应仅为 5(一次生效)。
|
|
var rec models.AssetLevelRecord
|
|
if err := db.Where("asset_id = ?", assetID).First(&rec).Error; err != nil {
|
|
t.Fatalf("read asset level record: %v", err)
|
|
}
|
|
if rec.SeasonExhibitionHours != 5 {
|
|
t.Errorf("want SeasonExhibitionHours=5 (one apply), got %d", rec.SeasonExhibitionHours)
|
|
}
|
|
if rec.LifetimeExhibitionHours != 5 {
|
|
t.Errorf("want LifetimeExhibitionHours=5 (one apply), got %d", rec.LifetimeExhibitionHours)
|
|
}
|
|
|
|
// 4) 校验幂等 log 表:恰好 1 条。
|
|
var logCount int64
|
|
if err := db.Model(&models.AssetExhibitionHoursLog{}).
|
|
Where("source_id = ?", srcID).Count(&logCount).Error; err != nil {
|
|
t.Fatalf("count asset exhibition log: %v", err)
|
|
}
|
|
if logCount != 1 {
|
|
t.Errorf("want exactly 1 asset_exhibition_hours_log row, got %d", logCount)
|
|
}
|
|
}
|