- 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>
85 lines
2.8 KiB
Go
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)
|
|
}
|
|
} |