topfans/backend/services/assetService/service/mint_service_idempotency_test.go
zerosaturation 3407e30395 refactor(assetService): CreateMintOrder 3-phase txn + orphan reconciliation (P0-2)
- CreateMintOrder 拆三段: txn1(PENDING→PROCESSING,不调RPC/不写asset)
  → 事务外 UpdateCrystalBalance 扣水晶 → txn2(建asset+registry→SUCCESS);
  消除 DB 事务内嵌跨服务 gRPC(连接池占用+跨服务事务风险)。
- 任一步失败 markMintOrderFailed 独立事务标 FAILED(不回滚已扣水晶=审计流水,
  重试靠 Task1 source_id 幂等 + Task2 入口短路防双扣); nil-cause 防御。
- 孤儿订单对账 ReconcileStuckMintOrders: 扫陈旧 PROCESSING 单,查 mint_cost 流水—
  已扣未建→FOR UPDATE 行锁下幂等补完 SUCCESS(串行化并发防双建),未扣→FAILED,绝不退款。
- main.go 接线周期 worker(MINT_RECONCILE_INTERVAL_SEC 默认600s,0关,优雅退出)。
- 测试: RPC失败落FAILED / 对账未扣→FAILED / 已扣未建→补完(含幂等二次跑)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 12:28:57 +08:00

500 lines
18 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"
"fmt"
"math/big"
"testing"
"time"
"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"
"gorm.io/gorm"
)
// mockUserClient 服务层测试用的 UserServiceClient mock。
//
// 跟踪 UpdateCrystalBalance 调用次数,确保幂等短路路径不会重复扣费。
// GetFanProfile 返回当前 balance(测试中只检查不被二次扣即可)。
//
// ★ mint-task-3: 新增 failOnUpdate 字段用于模拟"事务外 RPC 失败"路径,
// 验证 CreateMintOrder 在 RPC 失败时仍把 mint_order 推进到 FAILED 状态,
// 而不是回滚成 PENDING 留下僵尸订单。
type mockUserClient struct {
balance int64
updateCrystalCalls int
failOnUpdate bool
}
func (m *mockUserClient) UpdateCrystalBalance(_ context.Context, _, _ int64, delta int64, _, _, _ string) (int64, error) {
m.updateCrystalCalls++
if m.failOnUpdate {
// 模拟 userService 侧的真实失败(余额不足/服务降级/超时等)
return m.balance, fmt.Errorf("simulated RPC failure")
}
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)
}
}
// TestCreateMintOrder_RPCFailure_MarksOrderFailed 验证 ★ mint-task-3 重构:
//
// 当事务外 RPC (UpdateCrystalBalance) 失败时,mint_order 必须落到 FAILED 状态
// 并写入 error_message,而不是回滚成 PENDING 形成"僵尸订单"。
//
// 旧实现: RPC 在事务内,失败 → 事务回滚 → 订单仍是 PENDING (水/状态不对齐,
// 用户看到订单还在 PENDING,但实际钱没扣,容易被重放造成状态机错位)。
// 新实现: 阶段1 PENDING→PROCESSING; 阶段2 RPC 失败 → 独立事务标 FAILED;
// 阶段3 不再走(订单已 FAILED,用户必须 PreCreateMintOrder 新建)。
//
// 期望:
// - CreateMintOrder 返回 error
// - mint_order.Status = FAILED
// - mint_order.error_message 非空(记录失败原因)
// - mint_orders 表里残留 PROCESSING 状态订单 = 0
func TestCreateMintOrder_RPCFailure_MarksOrderFailed(t *testing.T) {
db := setupServiceTestDB(t)
defer cleanupServiceTestDB(t, db)
star := createServiceTestStar(t, db, "mint_rpc_fail_star")
user := createServiceTestUser(t, db, "19900077002")
// ★ RPC 失败路径:failOnUpdate=true 模拟 userService 报余额不足 / 超时
uc := &mockUserClient{balance: 100, failOnUpdate: true}
// localMintCostRepo / userMintCountRepo 用真 repo 走 DB(都只做只读查询,不会脏写)
svc := NewMintService(
repository.NewAssetRepository(db),
repository.NewMintOrderRepository(db),
uc,
db, nil,
nil,
repository.NewMintCostRepository(),
repository.NewUserMintCountRepository(),
nil,
)
// 阶段一:预创建 PENDING 订单(由调用方先调 PreCreateMintOrder 拿 order_id)
const orderID = "rpc-fail-uuid-001"
if err := db.Create(&models.MintOrder{
OrderID: orderID,
UserID: user.ID,
StarID: star.StarID,
Status: models.MintOrderStatusPending,
}).Error; err != nil {
t.Fatalf("seed mint_order PENDING: %v", err)
}
// 阶段二:CreateMintOrder 应该因 RPC 失败返回 error,并把订单落 FAILED
_, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID)
if err == nil {
t.Fatal("expected error from RPC failure, got nil")
}
// ★ 关键断言:订单状态机必须推进到 FAILED(不是 PENDING 也不是 PROCESSING)
finalOrder, ferr := repository.NewMintOrderRepository(db).GetByOrderID(orderID)
if ferr != nil {
t.Fatalf("failed to reload mint_order: %v", ferr)
}
if finalOrder.Status != models.MintOrderStatusFailed {
t.Errorf("want status=FAILED after RPC failure, got %s", finalOrder.Status)
}
if finalOrder.ErrorMessage == nil || *finalOrder.ErrorMessage == "" {
t.Error("want error_message populated on FAILED order, got nil/empty")
}
// ★ 关键断言:不能有残留的 PROCESSING 僵尸订单(同一 order_id)
var stuckCount int64
if err := db.Model(&models.MintOrder{}).
Where("order_id = ? AND status = ?", orderID, models.MintOrderStatusProcessing).
Count(&stuckCount).Error; err != nil {
t.Fatalf("count stuck: %v", err)
}
if stuckCount != 0 {
t.Errorf("want 0 stuck PROCESSING orders, got %d", stuckCount)
}
// ★ 幂等补充:已 FAILED 的订单不应被"二次 RPC 扣费"
// (FAILED 状态会从入口拒绝二次 CreateMintOrder,但这里只验证当前流程不重复扣)
if uc.updateCrystalCalls != 1 {
t.Errorf("want exactly 1 UpdateCrystalBalance call (the failing one), got %d", uc.updateCrystalCalls)
}
}
// TestReconcileStuckMintOrders_NoCrystalCharged_MarksFailed 验证对账场景 1:
//
// PROCESSING 超时订单 + crystal_transaction_records 中无 mint_cost 流水
// → 安全标 FAILED (钱没扣,直接结束状态机)
//
// 适用于: 阶段2 RPC 调用失败但 markMintOrderFailed 也失败的极端边缘情况,
// 或者是迁移/历史遗留的 PROCESSING 订单。
func TestReconcileStuckMintOrders_NoCrystalCharged_MarksFailed(t *testing.T) {
db := setupServiceTestDB(t)
defer cleanupServiceTestDB(t, db)
star := createServiceTestStar(t, db, "reconcile_dry_star")
user := createServiceTestUser(t, db, "19900077003")
// 造一个"陈旧" PROCESSING 订单:updated_at 调到阈值之前
// 注意:MintOrder.BeforeCreate hook 会强制把 UpdatedAt 设为 now,
// 必须 Create 后再 Update 一次 raw。
const orderID = "reconcile-dry-uuid-001"
if err := db.Create(&models.MintOrder{
OrderID: orderID,
UserID: user.ID,
StarID: star.StarID,
Status: models.MintOrderStatusProcessing,
CostCrystal: 50,
}).Error; err != nil {
t.Fatalf("seed stuck order: %v", err)
}
staleTime := time.Now().Add(-20 * time.Minute).UnixMilli()
if err := db.Model(&models.MintOrder{}).
Where("order_id = ?", orderID).
Update("updated_at", staleTime).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
svc := &mintService{
db: db,
mintOrderRepo: repository.NewMintOrderRepository(db),
}
// 阈值 10 分钟,订单已 20 分钟未更新,应该被对账命中
if err := svc.ReconcileStuckMintOrders(context.Background(), 10*time.Minute); err != nil {
t.Fatalf("ReconcileStuckMintOrders failed: %v", err)
}
final, err := repository.NewMintOrderRepository(db).GetByOrderID(orderID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if final.Status != models.MintOrderStatusFailed {
t.Errorf("want status=FAILED, got %s", final.Status)
}
if final.ErrorMessage == nil || *final.ErrorMessage == "" {
t.Error("want error_message set on reconciled order")
}
}
// TestReconcileStuckMintOrders_CrystalChargedButNoAsset_Recovers 验证对账场景 2:
//
// PROCESSING 超时订单 + crystal_transaction_records 中有 mint_cost 流水
// → 幂等补完 phase3 (建 asset + registry + 订单→SUCCESS)
//
// 这是对账最关键的"反向补单"场景: 用户钱已扣,但 asset 因崩溃未建。
// 必须靠 asset_registry 或 mint_order.asset_id 判断是否已建,绝不二次重建。
func TestReconcileStuckMintOrders_CrystalChargedButNoAsset_Recovers(t *testing.T) {
db := setupServiceTestDB(t)
defer cleanupServiceTestDB(t, db)
star := createServiceTestStar(t, db, "reconcile_recover_star")
user := createServiceTestUser(t, db, "19900077004")
// 造一个"陈旧" PROCESSING 订单:有 cost_crystal,有 material_url/name/info
// 等(说明阶段一已经把订单填全了)
const orderID = "reconcile-recover-uuid-001"
matURL := "http://x/r.jpg"
name := "test-asset"
desc := "test-desc"
info := "test-info"
if err := db.Create(&models.MintOrder{
OrderID: orderID,
UserID: user.ID,
StarID: star.StarID,
Status: models.MintOrderStatusProcessing,
CostCrystal: 50,
MaterialURL: &matURL,
Name: &name,
Description: &desc,
Info: &info,
MaterialType: stringPtr("new"),
}).Error; err != nil {
t.Fatalf("seed stuck order: %v", err)
}
// backdate updated_at(BeforeCreate hook 会覆写)
staleTime := time.Now().Add(-20 * time.Minute).UnixMilli()
if err := db.Model(&models.MintOrder{}).
Where("order_id = ?", orderID).
Update("updated_at", staleTime).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
// 模拟"水晶已扣"的流水记录(source_id = order_id, change_type = mint_cost)
if err := db.Exec(`
INSERT INTO crystal_transaction_records
(user_id, star_id, change_type, delta, balance_before, balance_after, source_id, description, created_at)
VALUES (?, ?, 'mint_cost', -50, 100, 50, ?, '铸造藏品 #reconcile-recover-uuid-001', ?)
`, user.ID, star.StarID, orderID, time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("seed crystal tx: %v", err)
}
// ★ 对账不应该再调 RPC(钱已扣过);但 mintService 需要非 nil userClient 占位
uc := &mockUserClient{balance: 50}
svc := &mintService{
db: db,
assetRepo: repository.NewAssetRepository(db),
mintOrderRepo: repository.NewMintOrderRepository(db),
userClient: uc,
registryRepo: nil, // 不强依赖 registry,补单流程会 skip
}
if err := svc.ReconcileStuckMintOrders(context.Background(), 10*time.Minute); err != nil {
t.Fatalf("ReconcileStuckMintOrders failed: %v", err)
}
final, err := repository.NewMintOrderRepository(db).GetByOrderID(orderID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if final.Status != models.MintOrderStatusSuccess {
t.Errorf("want status=SUCCESS after recovery, got %s", final.Status)
}
if final.AssetID == nil {
t.Error("want asset_id backfilled on recovery, got nil")
}
// ★ 关键:不该再调 UpdateCrystalBalance(钱已扣过)
if uc.updateCrystalCalls != 0 {
t.Errorf("reconcile must not re-charge crystal, got %d calls", uc.updateCrystalCalls)
}
// ★ 幂等:再跑一次对账,不应再创建第二个 asset
preAssetCount := countAssetsForUser(t, db, user.ID)
if err := svc.ReconcileStuckMintOrders(context.Background(), 10*time.Minute); err != nil {
t.Fatalf("second reconcile: %v", err)
}
postAssetCount := countAssetsForUser(t, db, user.ID)
if preAssetCount != postAssetCount {
t.Errorf("reconcile must be idempotent (no duplicate assets): pre=%d post=%d",
preAssetCount, postAssetCount)
}
if final.AssetID == nil {
t.Error("final order must keep asset_id")
}
}
// countAssetsForUser 计数测试用户的资产数(对账幂等性验证用)
func countAssetsForUser(t *testing.T, db *gorm.DB, userID int64) int64 {
t.Helper()
var n int64
if err := db.Model(&models.Asset{}).Where("owner_uid = ?", userID).Count(&n).Error; err != nil {
t.Fatalf("count assets: %v", err)
}
return n
}
// stringPtr helper(测试用)
func stringPtr(s string) *string { return &s }