topfans/backend/services/userService/repository/crystal_idempotency_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

85 lines
2.8 KiB
Go

package repository
import (
"testing"
"github.com/topfans/backend/pkg/models"
)
// TestUpdateCrystalBalance_IdempotentBySourceID 验证同 (source_id, change_type) 第二次调用
// 不二次扣费、不二次写流水,newBalance 与首次一致。
//
// 防止上游 RPC 重试或事务回滚后重放导致的重复扣费。
// 仅清理本测试 sentinel 行,不调共享 cleanupTestDB 的大范围清理。
func TestUpdateCrystalBalance_IdempotentBySourceID(t *testing.T) {
db := openExhibitionHoursTestDB(t)
userRepo := NewUserRepository()
hashedPassword, err := HashPassword("password123")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &models.User{Mobile: "13800088001", PasswordHash: hashedPassword, IsActive: true}
if err := userRepo.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
star := &models.Star{Name: "test_star_idem_crystal_1", IdentityID: "test_star_idem_crystal_1", IsActive: true}
if err := db.Create(star).Error; err != nil {
t.Fatalf("create star: %v", err)
}
repo := NewFanProfileRepository()
profile := &models.FanProfile{
UserID: user.ID,
StarID: star.StarID,
Nickname: "test_nickname_idem_crystal_1",
Level: 1,
IsActive: true,
CrystalBalance: 1000,
}
if err := repo.Create(profile); err != nil {
t.Fatalf("create fan profile: %v", err)
}
const sourceID = "test_crystal_idem_mint_001"
const changeType = "mint_cost"
// 清理本次测试可能残留
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", sourceID)
defer func() {
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", sourceID)
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID)
db.Exec("DELETE FROM users WHERE id = ?", user.ID)
db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_idem_crystal_1")
}()
// 第一次调用:-100,余额 1000 → 900
bal1, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint")
if err != nil {
t.Fatalf("first call: %v", err)
}
if bal1 != 900 {
t.Errorf("first call: newBalance want 900, got %d", bal1)
}
// 第二次同 sourceID+changeType —— 必须幂等,余额仍 900
bal2, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint retry")
if err != nil {
t.Fatalf("second call: %v", err)
}
if bal2 != 900 {
t.Errorf("second call: newBalance want 900 (idempotent), got %d", bal2)
}
// 校验: 只写了一条流水
var n int64
if err := db.Model(&models.CrystalTransactionRecord{}).
Where("user_id=? AND star_id=? AND source_id=? AND change_type=?", user.ID, star.StarID, sourceID, changeType).
Count(&n).Error; err != nil {
t.Fatalf("count crystal tx: %v", err)
}
if n != 1 {
t.Errorf("tx rows want 1, got %d", n)
}
}