- 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>
239 lines
8.0 KiB
Go
239 lines
8.0 KiB
Go
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)
|
||
}
|
||
} |