- 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>
500 lines
18 KiB
Go
500 lines
18 KiB
Go
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 } |