topfans/backend/services/assetService/service/mint_service_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

239 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"crypto/rand"
"math/big"
"testing"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/asset"
pbUser "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/repository"
)
// mockUserClient 服务层测试用的 UserServiceClient mock。
//
// 跟踪 UpdateCrystalBalance 调用次数,确保幂等短路路径不会重复扣费。
// GetFanProfile 返回当前 balance(测试中只检查不被二次扣即可)。
type mockUserClient struct {
balance int64
updateCrystalCalls int
}
func (m *mockUserClient) UpdateCrystalBalance(_ context.Context, _, _ int64, delta int64, _, _, _ string) (int64, error) {
m.updateCrystalCalls++
m.balance += delta
return m.balance, nil
}
func (m *mockUserClient) UpdateAssetsCount(_ context.Context, _, _ int64, delta int32) (int32, error) {
return 0, nil
}
func (m *mockUserClient) GetFanProfile(_ context.Context, _, _ int64) (*pbUser.FanProfile, error) {
return &pbUser.FanProfile{CrystalBalance: m.balance}, nil
}
// 编译期保证 mockUserClient 满足 client.UserServiceClient 接口
var _ client.UserServiceClient = (*mockUserClient)(nil)
// pbCreateMintReq 构造 CreateMintOrder 测试请求。
// 故意忽略 userID/starID 参数(参数名带下划线),因为请求结构里只有订单字段;
// 用户与明星身份由 service 方法签名参数传入。
func pbCreateMintReq(orderID string, _, _ int64) *pb.CreateMintOrderRequest {
return &pb.CreateMintOrderRequest{
OrderId: orderID,
MaterialUrl: "http://x/m.jpg",
Name: "n",
Description: "d",
Info: "i",
MaterialType: "new",
}
}
// TestCreateMintOrder_IdempotentOnSuccessOrder 验证幂等短路:
//
// 同 order_id 第二次调用,状态已是 SUCCESS → 直接返回原 asset/order/cost,
// 不再调 userClient.UpdateCrystalBalance。
//
// ★ 双层防护第二层(assetService 侧入口短路)。
// - 第一层(userService.UpdateCrystalBalance 的 ON CONFLICT)由 mint-task-1 覆盖。
// - 本测试确保"已 SUCCESS 的订单"在入口处就重放响应,避免重复进入扣费/建档流程。
func TestCreateMintOrder_IdempotentOnSuccessOrder(t *testing.T) {
db := setupServiceTestDB(t)
defer cleanupServiceTestDB(t, db)
// 准备 user + star(余额走 mock,无需 fan_profile)
star := createServiceTestStar(t, db, "mint_idem_star")
user := createServiceTestUser(t, db, "19900077001")
// 预置一个 SUCCESS 订单 + 关联 asset(模拟之前已铸造成功)
const orderID = "idem-order-uuid-001"
originalAsset := &models.Asset{
OwnerUID: user.ID,
StarID: star.StarID,
Name: "existing",
CoverURL: "http://x/a.jpg",
Status: models.AssetStatusActive,
IsActive: true,
}
if err := db.Create(originalAsset).Error; err != nil {
t.Fatalf("Failed to seed asset: %v", err)
}
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
VALUES (?, ?, ?, 'regular', 1, 1, 1)`, user.ID, originalAsset.ID, star.StarID).Error; err != nil {
t.Fatalf("Failed to seed asset_registry: %v", err)
}
if err := db.Create(&models.MintOrder{
OrderID: orderID,
UserID: user.ID,
StarID: star.StarID,
Status: models.MintOrderStatusSuccess,
CostCrystal: 100,
AssetID: &originalAsset.ID,
}).Error; err != nil {
t.Fatalf("Failed to seed mint_order: %v", err)
}
// mock userClient:UpdateCrystalBalance 不应被二次触发
uc := &mockUserClient{balance: 900}
svc := NewMintService(
repository.NewAssetRepository(db),
repository.NewMintOrderRepository(db),
uc,
db, nil,
nil, nil, nil,
nil,
)
resp, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected response, got nil")
}
if resp.Order == nil {
t.Fatal("expected order in response, got nil")
}
if resp.Order.OrderId != orderID {
t.Errorf("want orderID=%s, got %s", orderID, resp.Order.OrderId)
}
if resp.Order.Status != models.MintOrderStatusSuccess {
t.Errorf("want order status=SUCCESS, got %s", resp.Order.Status)
}
if resp.CostCrystal != 100 {
t.Errorf("want cost_crystal=100, got %d", resp.CostCrystal)
}
if resp.Asset == nil {
t.Error("expected asset replay in idempotent response, got nil")
} else if resp.Asset.AssetId != originalAsset.ID {
t.Errorf("want assetID=%d, got %d", originalAsset.ID, resp.Asset.AssetId)
}
// ★ 关键断言:幂等命中后不应再调用 UpdateCrystalBalance
if uc.updateCrystalCalls != 0 {
t.Errorf("UpdateCrystalBalance must not be called on idempotent hit, got %d calls", uc.updateCrystalCalls)
}
// 余额没被二次扣(保持 mock 初始值 900)
if uc.balance != 900 {
t.Errorf("balance want 900 (unchanged), got %d", uc.balance)
}
}
// TestMintGuaranteeProbability_NonPredictable 验证随机源已从 time.Now() 切到 crypto/rand:
//
// 1000 次连续采样,100% 命中(Probability=100)与 0% 命中(Probability=0)必须分别全命中/全不命中;
// 且单次返回落在 [0,100) 区间。
//
// ★ 批次1.5:helper 测试本身只验证 crypto/rand 行为符合规格(库内建)。
// 真正验证 rollGuarantee 行为见 TestRollGuarantee_Boundaries / _Distribution。
func TestMintGuaranteeProbability_NonPredictable(t *testing.T) {
// 抽 1000 个 [0,100) 整数,验证区间 + 100% 概率 vs 0% 概率两个极端。
for i := 0; i < 1000; i++ {
v, err := rand.Int(rand.Reader, big.NewInt(100))
if err != nil {
t.Fatal(err)
}
if v.Cmp(big.NewInt(100)) >= 0 || v.Sign() < 0 {
t.Fatalf("out of range: %v", v)
}
}
// 边界
if alwaysTriggers(100, 1000) != 1000 {
t.Error("P=100 should always trigger")
}
if alwaysTriggers(0, 1000) != 0 {
t.Error("P=0 should never trigger")
}
}
// alwaysTriggers 模拟 helper 内部判定,用于边界测试
func alwaysTriggers(probability, n int) int {
hits := 0
for i := 0; i < n; i++ {
v, _ := rand.Int(rand.Reader, big.NewInt(100))
if v.Int64() < int64(probability) {
hits++
}
}
return hits
}
// TestRollGuarantee_Boundaries 验证 rollGuarantee 在概率边界上的语义:
//
// probability <= 0 → 永不触发
// probability >= 100 → 总是触发
// 非法值(负数 / 远超 100)与 mint_service 现有 Probability 字段约定一致
// (Probability 在 DB 层是 int64,正常取值 0~100;这里显式保证超出范围时不会 panic / 误判)。
func TestRollGuarantee_Boundaries(t *testing.T) {
cases := []struct {
name string
probability int64
want bool
}{
{"zero never triggers", 0, false},
{"negative never triggers", -1, false},
{"deep negative never triggers", -1000, false},
{"hundred always triggers", 100, true},
{"over hundred always triggers", 101, true},
{"way over hundred always triggers", 9999, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 边界值是确定性的,跑 50 次保证没有"随机巧合"
for i := 0; i < 50; i++ {
if got := rollGuarantee(tc.probability); got != tc.want {
t.Fatalf("rollGuarantee(%d) = %v, want %v (iter %d)", tc.probability, got, tc.want, i)
}
}
})
}
}
// TestRollGuarantee_Distribution 验证 rollGuarantee 在 P=50 时采样 2000 次,
//
// 命中率落在 [800, 1200] 内(理论 1000 ± 期望误差 ~22)。
//
// 这是统计性测试:理论 2000 次伯努利试验 σ ≈ √(npq) = √500 ≈ 22,
// 取 ±9σ(800~1200)基本不可能(<10⁻¹⁸)误判;同时能捕捉到"退化成伪随机 / 写死"
// 这类回归。如果有人把 crypto/rand 换成 time.Now().UnixNano()%100,
// 在循环极快时同纳秒下分布会严重偏(全部 false / 全部 true),立刻 fail。
func TestRollGuarantee_Distribution(t *testing.T) {
const n = 2000
const p int64 = 50
hits := 0
for i := 0; i < n; i++ {
if rollGuarantee(p) {
hits++
}
}
const minHits = 800
const maxHits = 1200
if hits < minHits || hits > maxHits {
t.Errorf("rollGuarantee(50) over %d trials: hits=%d, want in [%d,%d] (≈50%%±9σ)",
n, hits, minHits, maxHits)
}
}