topfans/docs/superpowers/plans/2026-07-21-mint-correctness.md
2026-07-21 21:14:22 +08:00

47 KiB
Raw Blame History

铸造正确性 (批次 1.4 + 1.5 + 1.6) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修复铸造链路三类正确性问题:(1) 扣费跨服务 gRPC 嵌在 DB 事务内 (P0-2)(2) CreateMintOrder 非幂等、userService UpdateCrystalBalance 未按 (source_id, change_type) 去重;(3) 保底概率用 time.Now().UnixNano()%100 可预测、mockTxHash 全可观测输入可伪造;(4) peripheral_service.doMint 限流 count/insert TOCTOU 可绕过。

Architecture:

  • 幂等基石DB 层):crystal_transaction_recordsUNIQUE(source_id, change_type) WHERE source_id<>''userService.UpdateCrystalBalance 先查重,命中即返回当前余额、不二次扣费。
  • 事务重构(批次 1.4CreateMintOrder 拆为 txn_local(写 PROCESSING 占位订单)→ 事务外 RPC UpdateCrystalBalance(依赖上面幂等性防重)→ txn_local(建 asset/registry/订单 SUCCESS失败走补偿标记 FAILED+error_message不自动退水晶(避免引入新的回滚漏洞;后续由对账任务处理)。
  • 入口幂等(批次 1.4CreateMintOrder 入口按 order_id 查已存在 SUCCESS 订单直接返回;同 PreCreateMintOrder 现有的「先查后插」双层防护风格。
  • 随机性(批次 1.5):保底概率改 crypto/rand.Intn(100)mockTxHashcrypto/rand 32B hex + 前缀 MOCK-NOT-ON-CHAIN:不写入 assets.tx_hash(仅作日志/调试使用),并把字段从响应里显式标注 mock_tx_hash,让前端不会误以为已上链。
  • 限流原子化(批次 1.6peripheral_service.doMint 限频改 Redis Lua INCR + EXPIRE 原子自增key=periph:mint:{owner_uid}:{yyyymmdd}DB 路径保留 asset_registry 唯一约束作兜底(已存在)。

Tech Stack: Go 1.25 (go.work 多模块)、GORM、PostgreSQLcrystal_transaction_records/mint_orders/asset_registry、Dubbo RPC、crypto/rand、Redisgo-redis,项目已在 gateway 使用)。

Global Constraints

  • Go 组合式;不改 RPC 协议(不引入新 proto 字段)。
  • migration 放 backend/migrations/,破坏性 SQL 前 dry-run + 备份;末尾按 CLAUDE.md 规范 setval 同步序列(涉及 crystal_transaction_records)。
  • 时间戳统一毫秒time.Now().UnixMilli())。
  • 不自动 git commit(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。
  • 每个任务结束 go build ./...(在 backend/ 目录)通过。
  • 不引入新第三方依赖(crypto/rand 标准库、go-redis 已在 gateway 使用,复用既有 Redis client
  • 涉及 PII 不进 INFO 日志(沿用 mint_service.go 现状user_id/star_id 已 OK不打印 order_id/asset_id 完整值到生产日志DEBUG 才打印)。

File Structure

  • backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql新建crystal_transaction_records 加部分唯一索引 (source_id, change_type) WHERE source_id<>'';序列同步。
  • backend/services/userService/repository/fan_profile_repository.goUpdateCrystalBalance 入口先按 (source_id, change_type) 查重;命中直接返当前余额,不二次扣。
  • backend/services/userService/repository/fan_profile_repository_test.go(或新增 _idempotency_test.go)。补"同 source_id 第二次调用不改余额"用例。
  • backend/services/assetService/service/mint_service.goCreateMintOrder 拆事务;入口幂等查重;保底概率换 crypto/randmockTxHash 移除出 assets.tx_hash
  • backend/services/assetService/service/mint_service_test.go新建。TDD 用例:幂等命中 / RPC 失败标 FAILED / 保底概率分布不恒定。
  • backend/services/assetService/service/peripheral_service.godoMint 限流改 Redis Lua 原子自增;保留 DB 唯一约束兜底。
  • backend/services/assetService/service/peripheral_service_test.go。补"并发双击 11 次仍有 1 次被 50012 拒"用例(不依赖真 Redis用 miniredis
  • backend/services/assetService/repository/peripheral_repo.go不改DB 唯一约束已存在,ErrDuplicateRegistry sentinel 已存在)。
  • backend/pkg/redisx/redis_client.gobackend/services/assetService/.../redis.go新建。封装 mintRateLimiter Lua 脚本与 key 生成。

Task 1: 幂等基石 — crystal_transaction_records 部分唯一约束 + 用户侧查重

Files:

  • Create: backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql
  • Modify: backend/services/userService/repository/fan_profile_repository.go:397-466UpdateCrystalBalance
  • Modify: backend/services/userService/repository/fan_profile_repository_test.go(追加 _idempotency_test.go 同包)
  • Test: backend/services/userService/repository/crystal_idempotency_test.go(新增,便于隔离)

Interfaces:

  • Produces: crystal_transaction_records(source_id, change_type) 部分唯一索引(source_id <> ''UpdateCrystalBalance(userID, starID, delta, changeType, sourceID, description)sourceID != ""重复调用返回首次结果(相同 newBalance不二次写入流水

  • Step 1: 写 migration含 dry-run 注释块)

-- 2026_07_21_002_crystal_tx_source_id_unique.sql
-- 批次1.4 幂等基石: crystal_transaction_records 同 (source_id, change_type) 只允许一条
-- 执行前请先备份:
--   pg_dump -h <host> -U postgres -t crystal_transaction_records <db> > backup_1_4.sql

BEGIN;

-- (a) 历史去重: 同一 (source_id, change_type) 保留 id 最小的一条(其他标 NULL,不入新流水)
UPDATE crystal_transaction_records a
SET source_id = ''
FROM crystal_transaction_records b
WHERE a.source_id = b.source_id
  AND a.change_type = b.change_type
  AND a.source_id <> ''
  AND a.id > b.id;

-- (b) 部分唯一索引(source_id 非空时强制唯一)
CREATE UNIQUE INDEX IF NOT EXISTS uk_crystal_tx_source_change
  ON crystal_transaction_records (source_id, change_type)
  WHERE source_id <> '';

-- (c) 序列同步(本表 BIGSERIAL,手动删改后必须 setval)
SELECT setval(
  pg_get_serial_sequence('crystal_transaction_records', 'id'),
  (SELECT COALESCE(MAX(id), 1) FROM crystal_transaction_records)
);

COMMIT;
  • Step 2: dry-run 预检

Run本地 top-fans:

PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT
  (SELECT count(*) FROM crystal_transaction_records
    WHERE source_id <> '' AND id NOT IN (
      SELECT MIN(id) FROM crystal_transaction_records
      WHERE source_id <> '' GROUP BY source_id, change_type
    )) AS dup_to_blank;"

Expected: 一个数字(如 0 或几位数);人工确认合理后再执行 Step 3。

  • Step 3: 执行 migration

Run:

PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \
  -f backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql

Expected: BEGIN ... UPDATE ... CREATE INDEX ... COMMIT,无 error。

  • Step 4: 验证约束

Run:

PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT
  (SELECT count(*) - count(*) FROM pg_indexes WHERE indexname='uk_crystal_tx_source_change') AS idx_exists,
  (SELECT count(*) FROM crystal_transaction_records
    WHERE source_id <> '' GROUP BY source_id, change_type HAVING count(*) > 1) AS dup_groups;"

Expected: idx_exists=1dup_groups=(空)。

  • Step 5: 写失败测试 — 同 source_id 第二次调用不改余额

Create backend/services/userService/repository/crystal_idempotency_test.go:

package repository

import (
	"testing"

	"github.com/topfans/backend/pkg/models"
)

// TestUpdateCrystalBalance_IdempotentBySourceID 验证同 (source_id, change_type) 第二次调用
// 不二次扣费、不二次写流水,newBalance 与首次一致。
func TestUpdateCrystalBalance_IdempotentBySourceID(t *testing.T) {
	db := setupTestDB(t)
	defer cleanupTestDB(t, db)

	// 准备 user + star + fan_profile
	userRepo := NewUserRepository()
	user := &models.User{Mobile: "19900088001", PasswordHash: "x", IsActive: true}
	if err := userRepo.Create(user); err != nil { t.Fatal(err) }
	star := &models.Star{Name: "s", IdentityID: "idemp_star_1", IsActive: true}
	db.Create(star)
	profile := &models.FanProfile{
		UserID: user.ID, StarID: star.StarID, Nickname: "n", Level: 1, IsActive: true,
		CrystalBalance: 1000,
	}
	db.Create(profile)

	repo := NewFanProfileRepository()
	const sourceID = "mint-order-test-001"
	const changeType = "mint_cost"

	// 第一次调用
	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 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 newBalance want 900 (idempotent), got %d", bal2) }

	// 校验: 只写了一条流水
	var n int64
	db.Model(&models.CrystalTransactionRecord{}).
		Where("user_id=? AND star_id=? AND source_id=? AND change_type=?", user.ID, star.StarID, sourceID, changeType).
		Count(&n)
	if n != 1 { t.Errorf("tx rows want 1, got %d", n) }
}

Run: cd backend && go test ./services/userService/repository/ -run TestUpdateCrystalBalance_IdempotentBySourceID -v Expected: FAIL当前 UpdateCrystalBalance 没有 source_id 查重,第二次会再扣 100 → 余额 800n=2

  • Step 6: 实现 UpdateCrystalBalance 入口查重

Modify backend/services/userService/repository/fan_profile_repository.goUpdateCrystalBalance 函数体顶部(约 L403 if userID <= 0 { ... } 后),插入:

	// ★ 批次1.4 幂等: 同 (source_id, change_type) 已落账则直接返回当前余额。
	//   防止上游 RPC 重试或事务回滚后重放导致的重复扣费。
	if sourceID != "" {
		var existing models.CrystalTransactionRecord
		err := r.db.Where("source_id = ? AND change_type = ?", sourceID, changeType).
			Order("id DESC").First(&existing).Error
		if err == nil {
			// 已落账 — 取该 user 当前余额返,不二次扣。
			var profile models.FanProfile
			if err := r.db.Where("user_id=? AND star_id=?", userID, starID).
				First(&profile).Error; err != nil {
				return 0, err
			}
			logger.Logger.Info("UpdateCrystalBalance idempotent hit",
				zap.String("source_id", sourceID),
				zap.String("change_type", changeType),
				zap.Int64("user_id", userID),
				zap.Int64("balance", profile.CrystalBalance),
			)
			return profile.CrystalBalance, nil
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return 0, err
		}
	}

(保持函数其余逻辑不变;确保 import models 已在文件头)。

  • Step 7: 跑测试通过

Run: cd backend && go test ./services/userService/repository/ -run TestUpdateCrystalBalance_IdempotentBySourceID -v Expected: PASS。

  • Step 8: 跑全量回归

Run: cd backend && go build ./... Expected: 无 error。

  • Step 9: Commit用户批准后
git add backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql \
        backend/services/userService/repository/fan_profile_repository.go \
        backend/services/userService/repository/crystal_idempotency_test.go
git commit -m "fix(userService): crystal_transaction_records (source_id, change_type) idempotent"

Task 2: 入口幂等 — CreateMintOrder 命中已 SUCCESS 订单直接返回

Files:

  • Modify: backend/services/assetService/service/mint_service.go:209-258CreateMintOrder 函数体)
  • Test: backend/services/assetService/service/mint_service_idempotency_test.go(新增)

Interfaces:

  • 行为:CreateMintOrder(req, userID, starID) 收到已存在的 req.OrderIdstatus=SUCCESS → 直接重放响应asset/order/cost_crystal/balance_after不重复扣费不重复创建 asset

  • Step 1: 写失败测试 — 重复创建 SUCCESS 订单不二次扣费

Create backend/services/assetService/service/mint_service_idempotency_test.go:

package service

import (
	"context"
	"testing"

	"github.com/topfans/backend/pkg/models"
	"github.com/topfans/backend/services/assetService/repository"
)

// TestCreateMintOrder_IdempotentOnSuccessOrder 验证: 同 order_id 第二次调用,
// 状态已是 SUCCESS → 直接返回原 asset,不再调 UpdateCrystalBalance。
func TestCreateMintOrder_IdempotentOnSuccessOrder(t *testing.T) {
	db := setupServiceTestDB(t)
	defer cleanupServiceTestDB(t, db)

	// 准备 user + star + fan_profile(余额 1000)
	star := createServiceTestStar(t, db, "mint_idem_star")
	user := createServiceTestUser(t, db, "19900077001")
	db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at)
		VALUES (?, ?, 'n', 1, true, 1000, 1, 1)`, user.ID, star.StarID)

	mintRepo := repository.NewMintOrderRepository(db)
	const orderID = "idem-order-uuid-001"
	// 预置一个 SUCCESS 订单
	originalAsset := &models.Asset{
		OwnerUID: user.ID, StarID: star.StarID, Name: "existing",
		CoverURL: "http://x/a.jpg", Status: models.AssetStatusActive, IsActive: true,
	}
	db.Create(originalAsset)
	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)
	db.Create(&models.MintOrder{
		OrderID: orderID, UserID: user.ID, StarID: star.StarID,
		Status: models.MintOrderStatusSuccess, CostCrystal: 100,
	})
	db.Create(&models.MintOrder{OrderID: "fake-real-asset-link"}) // 占位避免 lint

	// mock userClient: 重复调用时 UpdateCrystalBalance 不应被触发
	uc := &mockUserClient{balance: 900}
	svc := NewMintService(
		repository.NewAssetRepository(db),
		mintRepo,
		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.Order.OrderId != orderID { t.Errorf("want orderID=%s, got %s", orderID, resp.Order.OrderId) }
	if uc.updateCrystalCalls != 0 {
		t.Errorf("UpdateCrystalBalance must not be called on idempotent hit, got %d calls", uc.updateCrystalCalls)
	}
	// 余额没被二次扣
	if uc.balance != 900 { t.Errorf("balance want 900, got %d", uc.balance) }
}

需要补一个本地 mock mockUserClient 满足 client.UserServiceClient 接口(只实现 UpdateCrystalBalance/GetFanProfile/UpdateAssetsCount),并写文件顶部的辅助 pbCreateMintReq

// 在同一 _test.go 文件顶部(import 之后)
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
}

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",
	}
}

(实际写时按 client.UserServiceClient 完整接口补齐所有方法 — 现有接口是 3 个方法;导入 pbUser "github.com/topfans/backend/pkg/proto/user"pb "github.com/topfans/backend/pkg/proto/asset")。

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_IdempotentOnSuccessOrder -v Expected: FAIL当前 CreateMintOrder 入口不查 SUCCESS 状态,会走到事务里 → 触发第二次扣费或状态错乱)。

  • Step 2: 在 CreateMintOrder 入口加幂等短路

Modify backend/services/assetService/service/mint_service.go 在 L226 if req.OrderId == "" { ... } 之后、L231 // 2. 获取当前累计铸爱次数 之前,插入:

	// ★ 批次1.4 幂等: 已 SUCCESS 的同 order_id 直接返回,不再走扣费/建档流程。
	existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
	if err == nil && existing != nil && existing.Status == models.MintOrderStatusSuccess {
		logger.Logger.Info("CreateMintOrder idempotent hit",
			zap.String("order_id", existing.OrderID),
			zap.Int64("user_id", userID),
		)
		// 重放响应: 用既有 asset / 既有成本,不再二次扣。
		var assetProto *pb.Asset
		if existing.AssetID != nil {
			if a, err := s.assetRepo.GetByID(*existing.AssetID); err == nil && a != nil {
				assetProto = ModelToProtoAssetDetail(a, "", "", false, 0, 0, 0, 0, getInt32Value(a.Grade))
			}
		}
		return &pb.CreateMintOrderResponse{
			Base: &pbCommon.BaseResponse{
				Code: uint32(codes.OK), Message: "idempotent", Timestamp: time.Now().UnixMilli(),
			},
			Order:        ModelToProtoMintOrder(existing),
			Asset:        assetProto,
			CostCrystal:  existing.CostCrystal,
			BalanceAfter: 0, // 余额不再重算(避免对 userService 二次调用)
		}, nil
	}

注意:s.assetRepo.GetByID 是现有方法(已在 GetMintOrder 用过 s.assetRepo.GetByID(*order.AssetID)),无需新接口。

  • Step 3: 跑测试通过

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_IdempotentOnSuccessOrder -v Expected: PASS。

  • Step 4: 跑全量回归

Run: cd backend && go build ./... Expected: 无 error。

  • Step 5: Commit用户批准后
git add backend/services/assetService/service/mint_service.go \
        backend/services/assetService/service/mint_service_idempotency_test.go
git commit -m "fix(assetService): CreateMintOrder idempotent short-circuit on SUCCESS order"

Task 3: 事务重构 — CreateMintOrder 把 RPC 移出事务commit/失败标记补偿)

Files:

  • Modify: backend/services/assetService/service/mint_service.go:240-516CreateMintOrder 函数体 — 仅保留 RPC 移出事务的拆解;幂等短路已在 Task 2 加好)

Interfaces:

  • 行为:拆 CreateMintOrder 为三段:

    1. txn_local#1PENDING → PROCESSING + 校验、cost 计算、mint_count 自增、序列同步;不调 RPC、不写 asset
    2. 事务外 RPCs.userClient.UpdateCrystalBalance(ctx, userID, starID, -cost, "mint_cost", req.OrderId, ...);依赖 Task 1 的幂等防护
    3. txn_local#2:写 asset + asset_registry + mint_order 推进 SUCCESS + minted_at
    • 任一步失败 → txn_local#3(单独事务)把 mint_order 标 FAILED + error_message不回滚水晶(已在事务外,且 mint_cost 流水是审计记录)
    • 状态机:PENDING → PROCESSING → SUCCESS|FAILEDFAILED 订单不再二次扣(用户可重试新建 PreCreateMintOrder
  • Step 1: 写失败测试 — RPC 失败时 mint_order 落 FAILED 且水晶已扣

Append to backend/services/assetService/service/mint_service_idempotency_test.go:

// TestCreateMintOrder_RPCFailure_MarksOrderFailed 验证:
//  RPC UpdateCrystalBalance 返回错误 → mint_order 落 FAILED 状态 + 错误信息;
//  不会泄漏 PROCESSING 状态的"僵尸"订单。
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")
	db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at)
		VALUES (?, ?, 'n', 1, true, 100, 1, 1)`, user.ID, star.StarID)

	mintRepo := repository.NewMintOrderRepository(db)
	uc := &mockUserClient{balance: 100, failOnUpdate: true}
	svc := NewMintService(
		repository.NewAssetRepository(db), mintRepo, uc,
		db, nil, nil, nil, nil, nil,
	)
	// mint cost config: 用最小配置(没有本地配置 repo 时跳过价格校验的精确值,只验证状态机)
	// — 桩实现仅校验 cost > 0 且 UpdateCrystalBalance 入参含负数。
	orderID := "rpc-fail-uuid-001"
	db.Create(&models.MintOrder{
		OrderID: orderID, UserID: user.ID, StarID: star.StarID,
		Status: models.MintOrderStatusPending,
	})

	// 注: 此用例只在 mock 配置足够注入成本时验证状态机;若 GetMintCost 走真实配置,
	// 需先用 mint_cost_configs fixture 写入一行(参考 test helpers)。
	_, 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") }

	final, _ := mintRepo.GetByOrderID(orderID)
	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 populated")
	}
}

需在 mockUserClient 上加 failOnUpdate bool 字段,并在 UpdateCrystalBalance 实现里返回 fmt.Errorf("simulated RPC failure")failOnUpdate==true

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_RPCFailure_MarksOrderFailed -v Expected: FAIL当前 RPC 在事务内,失败会事务回滚 → 订单仍是 PENDING状态机未推进

  • Step 2: 在 CreateMintOrder 替换事务结构

Modify backend/services/assetService/service/mint_service.go,把 L240-516 的 db.Transaction(...) 拆为三段。关键改动(保留原注释/日志):

// === 阶段 1: txn_local#1 — 订单 PROCESSING + 校验 + 费用/序列 ===
var costCrystal int64
err = s.db.Transaction(func(tx *gorm.DB) error {
	// ... 保留原 L249-292 的"3.0 取出阶段一订单 + 覆盖字段 + 必填校验"逻辑 ...
	// ... 保留原 L298-306 的"3.1 获取铸造消耗配置 + capturedCostCrystal" ...
	costCrystal = localMintCost.CostCrystal
	// mint_count 自增(不调 RPC,纯 DB
	if err := s.UpdateMintCountAndBoost(ctx, tx, userID, starID, 0); err != nil {
		return err
	}
	// 序列同步(沿用 syncAssetsIDSequence)
	if err := syncAssetsIDSequence(tx); err != nil { return err }
	// 推进订单到 PROCESSING
	if err := tx.Model(&models.MintOrder{}).
		Where("order_id = ?", req.OrderId).
		Updates(map[string]interface{}{
			"status": models.MintOrderStatusProcessing,
			"cost_crystal": costCrystal,
			"updated_at": time.Now().UnixMilli(),
		}).Error; err != nil {
		return err
	}
	mintOrder = existing
	return nil
})
if err != nil {
	// 阶段1失败: 订单回 PENDING 或 FAILED(本例原状态本就是 PENDING,无需二次操作)
	return nil, fmt.Errorf("phase1 prepare: %w", err)
}

// === 阶段 2: 事务外 RPC — 扣水晶(依赖 userService source_id 幂等,Task 1) ===
var newBalance int64
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
newBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal,
	"mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId))
if err != nil {
	// 阶段2失败: txn_local#3 把订单标 FAILED
	s.markMintOrderFailed(req.OrderId, err)
	return nil, fmt.Errorf("水晶扣费失败: %w", err)
}

// === 阶段 3: txn_local#2 — 建 asset/registry, 订单 SUCCESS ===
var asset *models.Asset
err = s.db.Transaction(func(tx *gorm.DB) error {
	// ... 保留原 L345-415 的"3.5 创建 asset + registry" ...
	// ... 保留原 L417-446 的"3.5 推进到 SUCCESS" ...
	return nil
})
if err != nil {
	// 阶段3失败: 订单标 FAILED(水晶已扣 — 走对账任务回退,不在本路径处理)
	s.markMintOrderFailed(req.OrderId, err)
	return nil, fmt.Errorf("phase2 finalize: %w", err)
}

// ... 阶段4/5/6 沿用原 L455-513(资产等级初始化、owner 信息、statistic 事件) ...

并在文件里加私有 helper:

// markMintOrderFailed 在独立事务中标 FAILED,失败仅日志不阻塞主流程
func (s *mintService) markMintOrderFailed(orderID string, cause error) {
	now := time.Now().UnixMilli()
	err := s.db.Model(&models.MintOrder{}).
		Where("order_id = ?", orderID).
		Updates(map[string]interface{}{
			"status":        models.MintOrderStatusFailed,
			"error_message": cause.Error(),
			"updated_at":    now,
		}).Error
	if err != nil {
		logger.Logger.Error("markMintOrderFailed failed",
			zap.String("order_id", orderID),
			zap.Error(err),
		)
	}
}

⚠️ 删掉原 L309-318 的事务内 RPC 调用;删掉原 L322-336 的事务内保底概率计算(迁移到 Task 4 的 crypto/rand 实现,事务内仍是合法位置 — 见 Task 4 Step 1删掉原 L349 的 mockTxHashTask 5 替换)。boostBps 计算仍可在 txn_local#1 末尾做(不调 RPC,纯本地随机 + DB 写)。

  • Step 3: 跑测试通过

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_RPCFailure_MarksOrderFailed -v Expected: PASS。

  • Step 4: 跑全量回归

Run: cd backend && go build ./... Expected: 无 error。

  • Step 5: Commit用户批准后
git add backend/services/assetService/service/mint_service.go \
        backend/services/assetService/service/mint_service_idempotency_test.go
git commit -m "refactor(assetService): split CreateMintOrder txn, RPC moved out (P0-2)"

Task 4: 保底概率改 crypto/rand

Files:

  • Modify: backend/services/assetService/service/mint_service.goTask 3 已把保底计算放在 txn_local#1 末尾 — 仅替换随机源)

Interfaces:

  • 行为:localMintCost.Probability > 0 时用 crypto/rand.Int(rand.Reader, big.NewInt(100)) 取 [0,100);小于 Probability 触发保底。不再使用 time.Now().UnixNano()%100

  • Step 1: 写失败测试 — 1000 次采样不应恒等于同一分布

Append to backend/services/assetService/service/mint_service_idempotency_test.go:

import (
	"crypto/rand"
	"math/big"
	"testing"
)

// TestMintGuaranteeProbability_NonPredictable 验证随机源已从 time.Now() 切到 crypto/rand:
//  1000 次连续采样,100% 命中(Probability=100)与 0% 命中(Probability=0)必须分别全命中/全不命中;
//  且单次返回落在 [0,100) 区间。
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") }
}

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
}

Run: cd backend && go test ./services/assetService/service/ -run TestMintGuaranteeProbability_NonPredictable -v Expected: PASS这个 helper 测试本身不依赖 mint_service 内部代码,只验证 crypto/rand 行为符合规格)。真正失败测试见 Step 3

  • Step 2: 在 mint_service 加 helper rollGuarantee

Modify backend/services/assetService/service/mint_service.go 文件内(任意 helper 区),加:

import (
	"crypto/rand"
	"math/big"
)

// rollGuarantee 按 probability(0-100) 概率返回是否触发保底.
// ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100,消除并发同纳秒相同结果
//   与脚本卡点操纵风险。
func rollGuarantee(probability int) bool {
	if probability <= 0 {
		return false
	}
	if probability >= 100 {
		return true
	}
	v, err := rand.Int(rand.Reader, big.NewInt(100))
	if err != nil {
		// 熵源失败: 降级为不触发(避免伪随机劣化;线上极少出现)
		logger.Logger.Warn("crypto/rand.Int failed, falling back to no-guarantee", zap.Error(err))
		return false
	}
	return v.Int64() < int64(probability)
}
  • Step 3: 在 txn_local#1 末尾替换原 L322-336 概率计算

原 L322-336 (在事务外 / Task 3 拆完之后仍是同一个位置) 替换为:

		// 3.3 保底概率(★ 批次1.5: crypto/rand,非可预测)
		var boostBps int32 = 0
		if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 {
			if rollGuarantee(localMintCost.Probability) {
				boostBps = int32(localMintCost.RewardValue)
				logger.Logger.Info("Mint guarantee triggered",
					zap.Int64("user_id", userID),
					zap.Int64("star_id", starID),
					zap.Int32("mint_count", currentMintCount+1),
					zap.Int32("boost_bps", boostBps),
					zap.Int64("probability", localMintCost.Probability),
				)
			}
		}
  • Step 4: 跑全量回归

Run: cd backend && go build ./... && go test ./services/assetService/service/ -run "TestMint|TestCreateMintOrder" -v Expected: 全部 PASS包含 Task 2/3 的用例)。

  • Step 5: Commit用户批准后
git add backend/services/assetService/service/mint_service.go \
        backend/services/assetService/service/mint_service_idempotency_test.go
git commit -m "fix(assetService): rollGuarantee uses crypto/rand, not UnixNano"

Task 5: mockTxHash 去可预测性 + 标注非链上凭证

Files:

  • Modify: backend/services/assetService/service/mint_service.go:349-364mint 事务内的 mockTxHash + asset.TxHash 写入)

Interfaces:

  • 行为:删除 assets.tx_hashassets.block_number 写入("非链上凭证"明确不写入 DB 字段,避免被前端当作真的链上 hash 展示);改为在响应 proto 字段(若已有)写 mock_tx_hash = "MOCK-NOT-ON-CHAIN:<crypto/rand hex 32B>";若 proto 字段不存在,仅在 DEBUG 日志输出不写 asset 表

  • Step 1: 写测试 — TxHash 字段不应写入

Append to backend/services/assetService/service/mint_service_idempotency_test.go:

// TestCreateMintOrder_NoOnChainTxHashWritten 验证 mint 完成后,
// 新建 asset 的 tx_hash / block_number 列为 NULL(明确非链上凭证)。
func TestCreateMintOrder_NoOnChainTxHashWritten(t *testing.T) {
	db := setupServiceTestDB(t)
	defer cleanupServiceTestDB(t, db)

	star := createServiceTestStar(t, db, "mock_tx_star")
	user := createServiceTestUser(t, db, "19900077003")
	db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at)
		VALUES (?, ?, 'n', 1, true, 1000, 1, 1)`, user.ID, star.StarID)

	mintRepo := repository.NewMintOrderRepository(db)
	uc := &mockUserClient{balance: 900}
	svc := NewMintService(
		repository.NewAssetRepository(db), mintRepo, uc,
		db, nil, nil, nil, nil, nil,
	)
	// mint_cost_configs fixture: 由 repository 桩提供,这里跳过(测试只关心 tx_hash 不写)

	orderID := "no-tx-hash-uuid-001"
	db.Create(&models.MintOrder{
		OrderID: orderID, UserID: user.ID, StarID: star.StarID,
		Status: models.MintOrderStatusPending,
	})

	resp, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID)
	if err != nil { t.Fatalf("unexpected: %v", err) }
	if resp.Asset == nil { t.Fatal("no asset returned") }
	if resp.Asset.TxHash != "" {
		t.Errorf("asset.tx_hash must be empty (non-chain), got %q", resp.Asset.TxHash)
	}
	// DB 验证
	var asset models.Asset
	db.First(&asset, resp.Asset.Id)
	if asset.TxHash != nil || asset.BlockNumber != nil {
		t.Errorf("DB tx_hash/block_number must be NULL, got %v/%v", asset.TxHash, asset.BlockNumber)
	}
}

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_NoOnChainTxHashWritten -v Expected: FAIL当前 L349 的 sha256(...) 会写入 assets.tx_hash)。

  • Step 2: 删除原 L349-364 的 mockTxHashBlockNumber 写入

Modify backend/services/assetService/service/mint_service.gotxn_local#2 创建 asset 的位置(约 L345-366删除以下整段:

mockTxHash := fmt.Sprintf("0x%x", sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%d-%d", mintOrder.OrderID, userID, starID, time.Now().UnixNano()))))
mockBlockNumber := int64(time.Now().Unix()) // 使用当前时间戳作为模拟区块号
...
TxHash:      &mockTxHash,
BlockNumber: &mockBlockNumber,

并把 "crypto/sha256" import 移除(已无引用)。mintedAt 字段保留(mintedAt := time.Now().UnixMilli()MintedAt: &mintedAt 仍写 — 表示"业务生效时间",非链上时间)。

  • Step 3: 跑测试通过

Run: cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_NoOnChainTxHashWritten -v Expected: PASS。

  • Step 4: 跑全量回归

Run: cd backend && go build ./... Expected: 无 error。

  • Step 5: Commit用户批准后
git add backend/services/assetService/service/mint_service.go \
        backend/services/assetService/service/mint_service_idempotency_test.go
git commit -m "fix(assetService): drop mockTxHash from assets.tx_hash (non-chain credential)"

Task 6: doMint 限流改 Redis Lua 原子自增

Files:

  • Modify: backend/services/assetService/service/peripheral_service.go:217-239doMint 的限频段)
  • Modify: backend/services/assetService/service/peripheral_service_test.go(加并发测试)
  • Create: backend/services/assetService/service/mint_rate_limiter.go(封装 Lua 脚本 + key 生成)

Interfaces:

  • 行为:限频入口从 s.repo.CountRecentMint 改为 s.rateLimiter.IncrAndCheck(ownerUID, "peripheral", 10, 24h);底层走 Redis EVAL LuaINCR + EXPIRE(首次设置 24h);原子返回 (count, allowed)count >= 10 → 返 BizCodeRateLimited;否则继续。DB 唯一约束仍作最终兜底(已有 ErrDuplicateRegistry 处理)。

  • Step 1: 写失败测试 — 并发 11 次请求只放过 10 次

Append to backend/services/assetService/service/peripheral_service_test.go:

import (
	"sync"
	"sync/atomic"
)

// TestPeripheralService_MintFromPeripheral_RateLimitAtomic 验证 Redis Lua 原子限频:
// 并发 11 次 mint(同一 owner_uid),恰有 10 次成功/已添加,1 次返 BizCodeRateLimited。
// 不依赖真 Redis: 用 miniredis(github.com/alicebob/miniredis/v2)内嵌。
func TestPeripheralService_MintFromPeripheral_RateLimitAtomic(t *testing.T) {
	db := setupServiceTestDB(t)
	defer cleanupServiceTestDB(t, db)

	// 启 miniredis
	s, _ := miniredis.Run()
	defer s.Close()
	rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
	limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour)

	repo := repository.NewPeripheralRepository(db)
	svc := NewPeripheralServiceWithLimiter(repo, limiter) // 新增构造函数,见 Step 3

	_, _ = setupAssetWithPeripheral(t, db)
	user := createServiceTestUser(t, db, "19900077010")
	ownerUID := user.ID
	// 准备 10 个不同 asset_id(绕开 UNIQUE) + 10 个 peripheral_info
	for i := 0; i < 10; i++ {
		asset := createServiceTestAsset(t, db, ownerUID, int64(87), fmt.Sprintf("p-%d", i))
		db.Exec(`INSERT INTO peripheral_info (asset_id, star_id, user_id, code, image, brand, company, hash, verifier, first_verified_at, created_at, updated_at)
			VALUES (?, 87, ?, ?, ?, 'B', 'C', '0xh', 'V', 1, 1, 1)`, asset.ID, ownerUID,
			fmt.Sprintf("PERI-%d", i), fmt.Sprintf("http://x/%d.jpg", i))
		db.Exec(`INSERT INTO peripheral_verify_code (code_hash, peripheral_info_id, created_at)
			SELECT 'h-' || ?, id, 1 FROM peripheral_info WHERE asset_id=?`, i, asset.ID)
	}

	// 并发 11 次: 最后一个必然 50012
	var wg sync.WaitGroup
	var ok, rateLimited, other int32
	for i := 0; i < 11; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			code := fmt.Sprintf("PERI-%d", i%10)
			_, err := svc.MintFromPeripheral(context.Background(), ownerUID, code)
			if err == nil { atomic.AddInt32(&ok, 1); return }
			var bizErr *BizError
			if errors.As(err, &bizErr) {
				switch bizErr.Code {
				case BizCodeRateLimited: atomic.AddInt32(&rateLimited, 1)
				case BizCodeAlreadyAdded: atomic.AddInt32(&ok, 1) // 也算"放过"
				default: atomic.AddInt32(&other, 1)
				}
				return
			}
			atomic.AddInt32(&other, 1)
		}(i)
	}
	wg.Wait()
	if ok != 10 || rateLimited != 1 || other != 0 {
		t.Errorf("want ok=10 rateLimited=1 other=0, got ok=%d rateLimited=%d other=%d", ok, rateLimited, other)
	}
}

需要新增的 import: github.com/alicebob/miniredis/v2github.com/redis/go-redis/v9syncsync/atomicfmt

需要新增的构造函数 NewPeripheralServiceWithLimiter(repo, limiter)Step 3 一并实现)。

Run: cd backend && go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral_RateLimitAtomic -v Expected: FAIL当前 CountRecentMint+InsertPeripheralRegistry 不在同一原子单元,并发 11 次可能 11 次都过 count 检查,再由 UNIQUE 兜底返 AlreadyAdded — 用例断言 ok=10 rateLimited=1,现状下 rateLimited=0FAIL

  • Step 2: 实现 Redis Lua 限频器

Create backend/services/assetService/service/mint_rate_limiter.go:

package service

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// mintRateLimitScript Redis Lua 脚本:
//   - INCR key; 若返回值为 1(刚创建),EXPIRE 24h.
//   - 返回 [count, allowed] (allowed = count <= limit).
// 脚本保证 INCR + EXPIRE 原子,避免"先 incr 后挂"导致 key 永驻。
var mintRateLimitScript = redis.NewScript(`
local n = redis.call("INCR", KEYS[1])
if n == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {n, n <= tonumber(ARGV[2]) and 1 or 0}
`)

// MintRateLimiter 铸造限频器(Redis Lua 原子自增).
type MintRateLimiter struct {
	rdb    *redis.Client
	limit  int64         // 单窗口允许次数
	window time.Duration // 窗口大小(用作 EXPIRE)
}

// NewMintRateLimiter 创建限频器.
func NewMintRateLimiter(rdb *redis.Client, limit int64, window time.Duration) *MintRateLimiter {
	return &MintRateLimiter{rdb: rdb, limit: limit, window: window}
}

// IncrAndCheck 原子自增并返回 (count, allowed).
//   - count: 当前窗口内累计次数
//   - allowed: count <= limit 时为 true
func (l *MintRateLimiter) IncrAndCheck(ctx context.Context, ownerUID int64, assetType string) (int64, bool, error) {
	key := l.key(ownerUID, assetType)
	windowSec := int64(l.window.Seconds())
	res, err := mintRateLimitScript.Run(ctx, l.rdb, []string{key}, windowSec, l.limit).Result()
	if err != nil {
		return 0, false, fmt.Errorf("redis EVAL mintRateLimit: %w", err)
	}
	arr, ok := res.([]interface{})
	if !ok || len(arr) != 2 {
		return 0, false, fmt.Errorf("redis EVAL returned unexpected shape: %v", res)
	}
	count, _ := arr[0].(int64)
	allowed, _ := arr[1].(int64)
	return count, allowed == 1, nil
}

func (l *MintRateLimiter) key(ownerUID int64, assetType string) string {
	// 按本地自然日切窗口(避免 24h 滑动导致跨日重置)
	return fmt.Sprintf("periph:mint:%s:%d:%s", assetType, ownerUID, time.Now().UTC().Format("20060102"))
}

注:go.mod 已在 gateway 使用 github.com/redis/go-redis/v9miniredis 是测试用(需 go get github.com/alicebob/miniredis/v2项目是否首次引入需确认 — 若未引入,本 plan 加 _test.go 时一并 go get)。若不愿引入新依赖,备选:走 DB 事务内 count + insert 原子化(见 Step 4 备选方案)。

  • Step 3: 修改 doMint 用限频器 + 新构造函数

Modify backend/services/assetService/service/peripheral_service.go:

// PeripheralService 周边验真 + 加入藏品的业务层
type PeripheralService struct {
	repo        *repository.PeripheralRepository
	rateLimiter *MintRateLimiter
}

// NewPeripheralService 原构造函数(无 Redis 限频 — 兼容旧测试)
func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService {
	return &PeripheralService{repo: repo}
}

// NewPeripheralServiceWithLimiter 含 Redis 限频的构造函数(Step 6+ 生产用)
func NewPeripheralServiceWithLimiter(repo *repository.PeripheralRepository, limiter *MintRateLimiter) *PeripheralService {
	return &PeripheralService{repo: repo, rateLimiter: limiter}
}

Modify doMintL217-239把 L233-239 的 CountRecentMint 段替换为:

	// 2. 限频: Redis Lua 原子自增(★ 批次1.6 修复原 CountRecentMint + InsertPeripheralRegistry TOCTOU)
	if s.rateLimiter != nil {
		count, allowed, err := s.rateLimiter.IncrAndCheck(ctx, ownerUID, "peripheral")
		if err != nil {
			// Redis 故障: 降级为 DB count(保守 — 放过少量请求,避免 Redis 挂了全站熔断)
			logger.Logger.Warn("MintRateLimiter failed, falling back to DB count",
				zap.Int64("owner_uid", ownerUID), zap.Error(err))
			dbCount, derr := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
			if derr != nil {
				return nil, fmt.Errorf("DB_COUNT_FALLBACK_FAILED: %w", derr)
			}
			if dbCount >= 10 {
				return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
			}
		} else if !allowed {
			logger.Logger.Info("Mint rate limited",
				zap.Int64("owner_uid", ownerUID), zap.Int64("count", count))
			return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
		}
	} else {
		// 兼容路径: 无 Redis 限频器时走 DB count(原行为)
		count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
		if err != nil {
			return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err)
		}
		if count >= 10 {
			return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
		}
	}
  • Step 4: 跑测试通过

Run: cd backend && go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral -v Expected: PASS包含新并发用例与原有 RateLimited / AlreadyAdded 用例)。

  • Step 5: 跑全量回归

Run: cd backend && go build ./... Expected: 无 error。

  • Step 6: Commit用户批准后
git add backend/services/assetService/service/peripheral_service.go \
        backend/services/assetService/service/mint_rate_limiter.go \
        backend/services/assetService/service/peripheral_service_test.go \
        backend/go.mod backend/go.sum
git commit -m "fix(assetService): doMint rate limit via Redis Lua atomic INCR (P1)"

Self-Review

1. Spec coverage批次 1.4 / 1.5 / 1.6 全部覆盖)

修复项 引用 任务
1.4 RPC 移出事务 mint_service.go:309-318 Task 3
1.4 mint_orders.order_id 幂等(重复请求直接返已 SUCCESS mint_service.go:CreateMintOrder Task 2
1.4 userService 侧 (source_id, change_type) 幂等 fan_profile_repository.go:UpdateCrystalBalance Task 1
1.5 保底概率改 crypto/rand mint_service.go:323 Task 4
1.5 mockTxHash 去可预测性 / 不作链上凭证 mint_service.go:349 Task 5
1.6 doMint count/insert 原子化 peripheral_service.go:233,271 Task 6

全部覆盖,无遗漏。

2. Placeholder scan

  • TODO/FIXME/类似待办(除原代码已有注释,保留)。
  • 无 "implement later" / "fill in details"。
  • 每步都有完整代码或命令SQL、Go 测试、Redis Lua、helper
  • Task 3 给的 RPC-out-of-txn 骨架含三段切分 + 失败标记 helperTask 6 给 Redis Lua 完整脚本。
  • 没有引用未定义类型/方法:mockUserClient 在测试文件内自实现;NewPeripheralServiceWithLimiter 在 Step 3 定义;syncAssetsIDSequence 是 mint_service.go 现有 helperUpdateMintCountAndBoost(ctx, tx, ...) 是现有方法(不传 RPC

3. Type / signature 一致性

名称 出现处 一致性
mint_service.go:UpdateMintCountAndBoost(ctx, tx, userID, starID, boostBps) Task 3 Step 2 沿用 已存在方法mint_service.go:926
syncAssetsIDSequence(tx) Task 3 Step 2 沿用 已存在mint_service.go:982
s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal, changeType, sourceID, description) Task 3 Step 2 已存在 client.UserServiceClient 接口
s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID) Task 2 Step 2 已存在mint_order_repository.go:101
s.assetRepo.GetByID(*existing.AssetID) Task 2 Step 2 已存在GetMintOrder 用过)
models.MintOrderStatusProcessing/Success/Failed Task 3 已存在常量
client.UserServiceClient.UpdateCrystalBalance 参数顺序 mockUserClient vs Step 2 一致ctx, userID, starID, delta, changeType, sourceID, description
BizCodeRateLimited = 50012 Task 6 已存在
repository.ErrDuplicateRegistry 兜底 已存在peripheral_repo.go:22

4. 全局回归提醒

CLAUDE.md 自审规则 — 修复后必须做整体回归:

  • Task 1: 用户侧查重影响所有调 UpdateCrystalBalance 的服务asset/activity/task/gallery仅在 sourceID != "" 时查重,旧调用方("" source_id 的)不受影响。
  • Task 2: CreateMintOrder 入口幂等短路只命中 SUCCESS 状态 — PENDING/FAILED/PROCESSING 订单继续走原路径。
  • Task 3: 状态机新增 PROCESSING 中间态;事务拆分后 PROCESSING 状态会持续到阶段3完成秒级。若进程崩溃在阶段2/3之间需对账任务扫 status=PROCESSING AND updated_at < now - 5min 的孤儿订单标 FAILED本 plan 不实现对账,记入批次后续项)。
  • Task 4: 随机源替换不影响业务字段;crypto/rand.Int 熵失败时降级为不触发保底(保守,不误发收益)。
  • Task 5: 删除 assets.tx_hash/block_number 写入是不兼容变更 — 但本字段本来就标注"模拟,后续引入区块链功能"DB 现有数据允许为 NULLproto 也是 optional)。前端若展示此字段需同步下线(通知前端组,本 plan 不在 backend 范围)。
  • Task 6: Redis Lua 限频器与 DB count 双轨Redis 故障降级为 DB count旧行为避免 Redis 挂时全站熔断)。

5. P0/P1 优先级

  • P0: Task 1幂等基石、Task 2入口幂等、Task 3事务拆 RPC财务正确性,先做
  • P1: Task 4随机性、Task 5mockTxHash、Task 6TOCTOU安全/稳定性,并行

6. 后续项(不在本 plan列出供后续 plan

  • 对账任务:扫 mint_orders.status='PROCESSING' AND updated_at < now() - interval '5 minutes' 的孤儿订单标 FAILED 并对账 RPC 流水(如已扣水晶但无 asset需触发 userService 退款 — 当前 RPC 是单向扣,需 userService 加 ReverseCrystalBalance 或对账脚本直接 UPDATE fan_profiles.crystal_balance + 写一条 change_type='mint_refund' 流水)。
  • 批次 1.4 配套admin/ops 后台给 mint_orders 加筛选 status=FAILED 的 UI + 一键重试(用户新建 PreCreateMintOrder 即可)。
  • 批次 1.6 配套Redis 部署到 assetService 进程;如不部署,沿用 Task 6 的 DB count 降级路径。