topfans/backend/services/assetService/service/asset_level_service_test.go
zerosaturation 878bd46399 fix(backend): financial correctness — settlement/hours/mint idempotency (batch 1)
- 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>
2026-07-23 01:11:54 +08:00

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