47 KiB
铸造正确性 (批次 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_records加UNIQUE(source_id, change_type) WHERE source_id<>'';userService.UpdateCrystalBalance先查重,命中即返回当前余额、不二次扣费。 - 事务重构(批次 1.4):
CreateMintOrder拆为txn_local(写 PROCESSING 占位订单)→ 事务外 RPCUpdateCrystalBalance(依赖上面幂等性防重)→txn_local(建 asset/registry/订单 SUCCESS);失败走补偿标记FAILED+error_message,不自动退水晶(避免引入新的回滚漏洞;后续由对账任务处理)。 - 入口幂等(批次 1.4):
CreateMintOrder入口按order_id查已存在SUCCESS订单直接返回;同PreCreateMintOrder现有的「先查后插」双层防护风格。 - 随机性(批次 1.5):保底概率改
crypto/rand.Intn(100);mockTxHash改crypto/rand32B hex + 前缀MOCK-NOT-ON-CHAIN:,不写入assets.tx_hash(仅作日志/调试使用),并把字段从响应里显式标注mock_tx_hash,让前端不会误以为已上链。 - 限流原子化(批次 1.6):
peripheral_service.doMint限频改 Redis LuaINCR + EXPIRE原子自增(key=periph:mint:{owner_uid}:{yyyymmdd});DB 路径保留asset_registry唯一约束作兜底(已存在)。
Tech Stack: Go 1.25 (go.work 多模块)、GORM、PostgreSQL(crystal_transaction_records/mint_orders/asset_registry)、Dubbo RPC、crypto/rand、Redis(go-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.go— 改。UpdateCrystalBalance入口先按(source_id, change_type)查重;命中直接返当前余额,不二次扣。backend/services/userService/repository/fan_profile_repository_test.go— 改(或新增_idempotency_test.go)。补"同 source_id 第二次调用不改余额"用例。backend/services/assetService/service/mint_service.go— 改。CreateMintOrder拆事务;入口幂等查重;保底概率换crypto/rand;mockTxHash移除出assets.tx_hash。backend/services/assetService/service/mint_service_test.go— 新建。TDD 用例:幂等命中 / RPC 失败标 FAILED / 保底概率分布不恒定。backend/services/assetService/service/peripheral_service.go— 改。doMint限流改 Redis Lua 原子自增;保留 DB 唯一约束兜底。backend/services/assetService/service/peripheral_service_test.go— 改。补"并发双击 11 次仍有 1 次被 50012 拒"用例(不依赖真 Redis,用 miniredis)。backend/services/assetService/repository/peripheral_repo.go— 不改(DB 唯一约束已存在,ErrDuplicateRegistrysentinel 已存在)。backend/pkg/redisx/redis_client.go或backend/services/assetService/.../redis.go— 新建。封装mintRateLimiterLua 脚本与 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-466(UpdateCrystalBalance) - 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=1、dup_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 → 余额 800,n=2)。
- Step 6: 实现
UpdateCrystalBalance入口查重
Modify backend/services/userService/repository/fan_profile_repository.go 在 UpdateCrystalBalance 函数体顶部(约 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-258(CreateMintOrder函数体) - Test:
backend/services/assetService/service/mint_service_idempotency_test.go(新增)
Interfaces:
-
行为:
CreateMintOrder(req, userID, starID)收到已存在的req.OrderId且status=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-516(CreateMintOrder函数体 — 仅保留 RPC 移出事务的拆解;幂等短路已在 Task 2 加好)
Interfaces:
-
行为:拆
CreateMintOrder为三段:txn_local#1(PENDING → PROCESSING + 校验、cost 计算、mint_count 自增、序列同步;不调 RPC、不写 asset)- 事务外 RPC:
s.userClient.UpdateCrystalBalance(ctx, userID, starID, -cost, "mint_cost", req.OrderId, ...);依赖 Task 1 的幂等防护 txn_local#2:写 asset + asset_registry + mint_order 推进 SUCCESS + minted_at
- 任一步失败 →
txn_local#3(单独事务)把 mint_order 标 FAILED + error_message,不回滚水晶(已在事务外,且 mint_cost 流水是审计记录) - 状态机:
PENDING → PROCESSING → SUCCESS|FAILED;FAILED 订单不再二次扣(用户可重试新建 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 的 mockTxHash(Task 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.go(Task 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-364(mint 事务内的 mockTxHash + asset.TxHash 写入)
Interfaces:
-
行为:删除
assets.tx_hash与assets.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 的
mockTxHash与BlockNumber写入
Modify backend/services/assetService/service/mint_service.go 在 txn_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-239(doMint的限频段) - 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);底层走 RedisEVALLua:INCR + 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/v2、github.com/redis/go-redis/v9、sync、sync/atomic、fmt。
需要新增的构造函数 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=0,FAIL)。
- 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/v9;miniredis 是测试用(需 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 doMint(L217-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 骨架含三段切分 + 失败标记 helper;Task 6 给 Redis Lua 完整脚本。
- ✅ 没有引用未定义类型/方法:
mockUserClient在测试文件内自实现;NewPeripheralServiceWithLimiter在 Step 3 定义;syncAssetsIDSequence是 mint_service.go 现有 helper;UpdateMintCountAndBoost(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 现有数据允许为 NULL(proto 也是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 5(mockTxHash)、Task 6(TOCTOU) — 安全/稳定性,并行
6. 后续项(不在本 plan,列出供后续 plan)
- 对账任务:扫
mint_orders.status='PROCESSING' AND updated_at < now() - interval '5 minutes'的孤儿订单标 FAILED 并对账 RPC 流水(如已扣水晶但无 asset,需触发 userService 退款 — 当前 RPC 是单向扣,需 userService 加ReverseCrystalBalance或对账脚本直接 UPDATEfan_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 降级路径。