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

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

1465 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aliyun/credentials-go/credentials"
"github.com/google/uuid"
eventPb "github.com/topfans/backend/pkg/proto/event"
"github.com/topfans/backend/pkg/statistic"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/config"
"github.com/topfans/backend/services/assetService/repository"
"github.com/topfans/backend/services/assetService/util"
starbookRepo "github.com/topfans/backend/services/starbookService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"google.golang.org/grpc/codes"
)
// MintService 铸造服务接口
type MintService interface {
// InitMintOrder 阶段一:初始化订单(仅落库 order_idstatus=PENDING幂等
InitMintOrder(orderID string, userID, starID int64) (*pb.InitMintOrderResponse, error)
// PreCreateMintOrder 阶段一:预创建订单(生成 order_id
PreCreateMintOrder(req *pb.PreCreateMintOrderRequest, userID, starID int64) (*pb.PreCreateMintOrderResponse, error)
// CreateMintOrder 创建铸造订单
CreateMintOrder(req *pb.CreateMintOrderRequest, userID, starID int64) (*pb.CreateMintOrderResponse, error)
// GetMintOrder 查询铸造订单状态
GetMintOrder(orderID string, userID, starID int64) (*pb.GetMintOrderResponse, error)
// CancelMintOrder 取消铸造订单
CancelMintOrder(orderID string, userID, starID int64) error
// GetMintCost 获取铸造消耗配置
GetMintCost(mintCount int32) (*models.MintCostConfig, error)
// GetUserMintCount 获取用户累计铸爱次数
GetUserMintCount(userID, starID int64) (int32, error)
// EstimateMintCost 估算铸造费用(不实际扣款)
EstimateMintCost(userID, starID int64) (*MintCostEstimate, error)
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error
// ReconcileStuckMintOrders 对账:扫描 status=PROCESSING 且 updated_at
// 早于 staleAfter 的订单,根据是否已扣水晶做"幂等补单"或"安全标 FAILED"。
// 调用方:可由 worker 周期触发,也可手动调用(管理后台/运维)。
ReconcileStuckMintOrders(ctx context.Context, staleAfter time.Duration) error
}
// mintService 铸造服务实现
type mintService struct {
assetRepo repository.AssetRepository
mintOrderRepo repository.MintOrderRepository
userClient client.UserServiceClient
db *gorm.DB
config *config.AssetConfig
registryRepo starbookRepo.AssetRegistryRepository // 资产索引仓库(用于星册体系)
localMintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库
assetLevelService AssetLevelService // 资产等级服务
}
// NewMintService 创建铸造服务实例
func NewMintService(
assetRepo repository.AssetRepository,
mintOrderRepo repository.MintOrderRepository,
userClient client.UserServiceClient,
db *gorm.DB,
cfg *config.AssetConfig,
registryRepo starbookRepo.AssetRegistryRepository,
localMintCostRepo repository.MintCostRepository,
userMintCountRepo repository.UserMintCountRepository,
assetLevelService AssetLevelService,
) MintService {
return &mintService{
assetRepo: assetRepo,
mintOrderRepo: mintOrderRepo,
userClient: userClient,
db: db,
config: cfg,
registryRepo: registryRepo,
localMintCostRepo: localMintCostRepo,
userMintCountRepo: userMintCountRepo,
assetLevelService: assetLevelService,
}
}
// InitMintOrder 阶段一:初始化订单(仅落库 order_idstatus=PENDING幂等
func (s *mintService) InitMintOrder(orderID string, userID, starID int64) (*pb.InitMintOrderResponse, error) {
if orderID == "" {
return nil, fmt.Errorf("order_id is required")
}
if !validator.ValidateUserID(userID) {
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
return nil, appErrors.ErrInvalidStarID
}
// 已存在则直接返回(幂等)
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID)
if err == nil && existing != nil {
return &pb.InitMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(existing),
}, nil
}
order := &models.MintOrder{
OrderID: orderID,
UserID: userID,
StarID: starID,
Status: models.MintOrderStatusPending,
CostCrystal: 0,
ErrorMessage: nil,
RetryCount: 0,
}
if err := s.mintOrderRepo.Create(order); err != nil {
// 处理并发下重复创建:再次查询返回
existing2, err2 := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID)
if err2 == nil && existing2 != nil {
return &pb.InitMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(existing2),
}, nil
}
return nil, fmt.Errorf("failed to init mint order: %w", err)
}
return &pb.InitMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(order),
}, nil
}
// PreCreateMintOrder 阶段一:预创建订单(生成 order_id状态=PENDING
func (s *mintService) PreCreateMintOrder(req *pb.PreCreateMintOrderRequest, userID, starID int64) (*pb.PreCreateMintOrderResponse, error) {
if !validator.ValidateUserID(userID) {
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
return nil, appErrors.ErrInvalidStarID
}
if req.MaterialUrl == "" {
return nil, fmt.Errorf("material_url is required")
}
orderID := uuid.New().String()
materialType := req.MaterialType
if materialType == "" {
materialType = "new"
}
order := &models.MintOrder{
OrderID: orderID,
UserID: userID,
StarID: starID,
Status: models.MintOrderStatusPending,
CostCrystal: 0,
ErrorMessage: nil,
RetryCount: 0,
MaterialURL: stringToPtr(req.MaterialUrl),
Name: stringToPtr(req.Name),
Description: stringToPtr(req.Description),
MaterialType: stringToPtr(materialType),
Event: stringToPtr(req.Event),
}
if err := s.db.Create(order).Error; err != nil {
return nil, fmt.Errorf("failed to create mint order draft: %w", err)
}
return &pb.PreCreateMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(order),
}, nil
}
// CreateMintOrder 创建铸造订单
//
// ★ mint-task-3 P0 重构:把 RPC 移出 DB 事务,消除连接池占用 + 跨服务事务风险,
// 并引入 PROCESSING 中间态,确保任何阶段失败都不会留下"状态机不对齐"的订单。
//
// 三段式事务:
// - txn_local#1: PENDING → PROCESSING + 校验 + cost 计算 + mint_count 自增 + 序列同步
// (不调 RPC、不写 asset)
// - 事务外 RPC: UpdateCrystalBalance 扣水晶(靠 mint-task-1 source_id 幂等保护)
// - txn_local#2: 建 asset/registry + 订单 → SUCCESS
//
// 失败补偿:任一阶段失败 → 独立事务 markMintOrderFailed(不回调水晶,不重试)。
//
// 状态机:PENDING → PROCESSING → SUCCESS | FAILED。FAILED 订单不再二次扣,
// 用户必须 PreCreateMintOrder 新建订单才能重试。
func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, starID int64) (*pb.CreateMintOrderResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
// 按新流程order_id 必填,所有后续操作以 order_id 为唯一标识
if req.OrderId == "" {
return nil, fmt.Errorf("order_id is required请先调用 /api/v1/assets/mints/precreate 获取)")
}
// ★ 幂等短路:已 SUCCESS 的同 order_id 直接返回,不再走扣费/建档流程。
// 这是铸造正确性 plan 双层防护的第二层(assetService 入口);
// 第一层在 userService.UpdateCrystalBalance(source_id + 唯一索引),由 mint-task-1 覆盖。
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, gerr := s.assetRepo.GetByID(*existing.AssetID); gerr == 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
}
// 2. 获取当前累计铸爱次数,用于计算阶梯费用
currentMintCount, err := s.GetUserMintCount(userID, starID)
if err != nil {
logger.Logger.Warn("Failed to get user mint count, using 0",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
currentMintCount = 0
}
// ====================================================================
// 阶段1 (txn_local#1):PENDING → PROCESSING + 校验 + 费用/序列/mint_count
// - 不调 RPC
// - 不写 asset
// - 失败回滚到 PENDING(订单本就是 PENDING,无副作用)
// ====================================================================
var costCrystal int64
var mintOrder *models.MintOrder
var localMintCost *models.MintCostConfig
var boostBps int32
err = s.db.Transaction(func(tx *gorm.DB) error {
// 3.0 取出阶段一订单,并校验状态/所有者
logger.Logger.Info("[MintOrder] Step 3.0: 获取订单",
zap.String("order_id", req.OrderId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID))
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
if err != nil {
logger.Logger.Error("[MintOrder] Step 3.0 失败: 获取订单错误",
zap.String("order_id", req.OrderId), zap.Error(err))
return fmt.Errorf("order not found: %w", err)
}
logger.Logger.Info("[MintOrder] Step 3.0 查询成功",
zap.String("order_id", existing.OrderID),
zap.String("status", existing.Status))
if existing.Status != models.MintOrderStatusPending {
return fmt.Errorf("订单状态为%s不能继续铸造", existing.Status)
}
mintOrder = existing
// 若阶段二传了字段,则覆盖阶段一的存储值(允许再次编辑元数据)
if req.MaterialUrl != "" {
mintOrder.MaterialURL = stringToPtr(req.MaterialUrl)
}
if req.Name != "" {
mintOrder.Name = stringToPtr(req.Name)
}
if req.Description != "" {
mintOrder.Description = stringToPtr(req.Description)
}
if req.MaterialType != "" {
mintOrder.MaterialType = stringToPtr(req.MaterialType)
} else {
mintOrder.MaterialType = stringToPtr("new")
}
if req.Info != "" {
mintOrder.Info = stringToPtr(req.Info)
}
// 继续铸造时必须有素材(可来自阶段一或阶段二)
if getStringValue(mintOrder.MaterialURL) == "" {
return fmt.Errorf("material_url is required")
}
if getStringValue(mintOrder.Info) == "" {
return fmt.Errorf("info is required")
}
// 如果没有提供名称,使用默认名称
if getStringValue(mintOrder.Name) == "" {
mintOrder.Name = stringToPtr("未命名藏品")
}
// 3.1 获取铸造消耗配置(阶梯计价)
localMintCost, err = s.GetMintCost(currentMintCount + 1)
if err != nil {
logger.Logger.Error("[MintOrder] Step 3.1 失败", zap.Error(err))
return fmt.Errorf("获取铸造消耗配置失败: %w", err)
}
costCrystal = localMintCost.CostCrystal
// 3.3 检查是否触发保底(纯本地随机 + DB 写,事务内合法;见 mint-task-4)
boostBps = 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))
}
}
// 3.4 更新用户铸爱次数和收益提升(纯 DB,无 RPC)
if err := s.UpdateMintCountAndBoost(context.Background(), tx, userID, starID, boostBps); err != nil {
logger.Logger.Error("[MintOrder] Step 3.4 失败",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
return fmt.Errorf("failed to update mint count: %w", err)
}
// 序列同步(后续会写 asset,提前对齐序列)
if err := syncAssetsIDSequence(tx); err != nil {
logger.Logger.Warn("[MintOrder] sync assets_id_seq failed", zap.Error(err))
return fmt.Errorf("failed to sync asset id sequence: %w", err)
}
// ★ 推进订单 PENDING → PROCESSING(写 cost_crystal 快照)
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 fmt.Errorf("failed to mark PROCESSING: %w", err)
}
mintOrder.Status = models.MintOrderStatusProcessing
mintOrder.CostCrystal = costCrystal
logger.Logger.Info("[MintOrder] Phase 1 done, status=PROCESSING",
zap.String("order_id", mintOrder.OrderID),
zap.Int64("cost_crystal", costCrystal))
return nil
})
if err != nil {
// 阶段1 失败:订单本就是 PENDING,自动回滚即正确状态。
// 用户可重新 PreCreateMintOrder + CreateMintOrder 重试。
logger.Logger.Error("[MintOrder] Phase 1 failed, order stays PENDING",
zap.String("order_id", req.OrderId), zap.Error(err))
return nil, fmt.Errorf("phase1 prepare: %w", err)
}
// ====================================================================
// 阶段2 (事务外 RPC):扣水晶
// - 依赖 mint-task-1 的 source_id 幂等保护,重试安全。
// - 失败 → markMintOrderFailed(独立事务,标 FAILED,不回调水晶)。
// - 已扣 → txn_local#2 继续。
// ====================================================================
var newBalance int64
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶(事务外)",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int64("delta", -costCrystal))
newBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal,
"mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId))
if err != nil {
logger.Logger.Error("[MintOrder] Step 3.2 失败: 扣除水晶错误",
zap.String("order_id", req.OrderId),
zap.Error(err))
// ★ 阶段2 失败:独立事务标 FAILED,不让订单卡在 PROCESSING 永远等
s.markMintOrderFailed(req.OrderId, fmt.Errorf("phase2 rpc: %w", err))
return nil, fmt.Errorf("水晶扣费失败: %w", err)
}
logger.Logger.Info("[MintOrder] Step 3.2 完成",
zap.Int64("new_balance", newBalance))
// ====================================================================
// 阶段3 (txn_local#2):建 asset + registry + mint_order → SUCCESS
// - 失败 → markMintOrderFailed(水晶已扣,走对账任务;不在本路径退款)
// ====================================================================
var asset *models.Asset
err = s.db.Transaction(func(tx *gorm.DB) error {
// 3.5 创建资产记录
logger.Logger.Info("[MintOrder] Step 3.5: 创建资产")
materialURLValue := getStringValue(mintOrder.MaterialURL)
mintedAt := time.Now().UnixMilli()
asset = &models.Asset{
OwnerUID: userID,
StarID: starID,
Name: getStringValue(mintOrder.Name),
CoverURL: materialURLValue,
MaterialURL: &materialURLValue,
Description: mintOrder.Description,
Visibility: models.AssetVisibilityPrivate,
Status: models.AssetStatusActive,
LikeCount: 0,
Info: getStringValue(mintOrder.Info),
MintedAt: &mintedAt,
}
if req.Grade != 0 {
asset.Grade = int32ToPtr(req.Grade)
}
if len(req.Tags) > 0 {
asset.Tags = models.StringArray(req.Tags)
}
if err := tx.Create(asset).Error; err != nil {
logger.Logger.Error("[MintOrder] Step 3.5 失败: 创建资产错误",
zap.String("material_url", materialURLValue), zap.Error(err))
return fmt.Errorf("failed to create asset: %w", err)
}
logger.Logger.Info("Asset created",
zap.Int64("asset_id", asset.ID), zap.Int64("user_id", userID))
grade := int32(1)
registry := &models.AssetRegistry{
AssetID: asset.ID,
AssetType: models.AssetTypeRegular,
OwnerUID: userID,
StarID: starID,
Grade: &grade,
Status: models.AssetRegistryStatusActive,
LikeCount: 0,
CreatedAt: time.Now().UnixMilli(),
UpdatedAt: time.Now().UnixMilli(),
}
if err := tx.Create(registry).Error; err != nil {
logger.Logger.Error("Failed to create asset registry",
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Error(err))
return fmt.Errorf("failed to create asset registry: %w", err)
}
logger.Logger.Info("Asset registry created",
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Int32("grade", grade))
// 推进订单 PROCESSING → SUCCESS,关联资产
mintedAt = time.Now().UnixMilli()
updates := map[string]interface{}{
"asset_id": asset.ID,
"status": models.MintOrderStatusSuccess,
"cost_crystal": localMintCost.CostCrystal,
"error_message": nil,
"material_url": getStringValue(mintOrder.MaterialURL),
"name": getStringValue(mintOrder.Name),
"description": getStringValue(mintOrder.Description),
"material_type": getStringValue(mintOrder.MaterialType),
"event": getStringValue(mintOrder.Event),
"info": getStringValue(mintOrder.Info),
"minted_at": mintedAt,
}
if err := tx.Model(&models.MintOrder{}).
Where("order_id = ? AND user_id = ? AND star_id = ?", mintOrder.OrderID, userID, starID).
Updates(updates).Error; err != nil {
return fmt.Errorf("failed to update mint order: %w", err)
}
assetID := asset.ID
mintOrder.AssetID = &assetID
mintOrder.Status = models.MintOrderStatusSuccess
mintOrder.CostCrystal = localMintCost.CostCrystal
logger.Logger.Info("Mint order created",
zap.String("order_id", mintOrder.OrderID),
zap.Int64("asset_id", asset.ID))
return nil
})
if err != nil {
// ★ 阶段3 失败:标 FAILED,水晶已扣由 ReconcileStuckMintOrders 补单
logger.Logger.Error("[MintOrder] Phase 3 failed, marking FAILED",
zap.String("order_id", req.OrderId), zap.Error(err))
s.markMintOrderFailed(req.OrderId, fmt.Errorf("phase3 finalize: %w", err))
return nil, fmt.Errorf("phase2 finalize: %w", err)
}
// 4. 初始化资产等级记录
if s.assetLevelService != nil && asset != nil {
if _, err := s.assetLevelService.GetOrCreateRecord(asset.ID); err != nil {
logger.Logger.Warn("Failed to create asset level record",
zap.Int64("asset_id", asset.ID),
zap.Error(err))
}
}
// 5. 获取所有者的昵称和头像(创建时所有者就是当前用户)
var ownerNickname string
var ownerAvatar string
profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID)
if err != nil {
logger.Logger.Warn("Failed to get owner fan profile, will return without nickname",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
ownerNickname = ""
} else {
ownerNickname = profile.Nickname
ownerAvatar = profile.AvatarUrl
}
// 6. 构建响应
response := &pb.CreateMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(mintOrder),
Asset: ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, false, 0, 0, 0, 0, getInt32Value(asset.Grade)),
CostCrystal: costCrystal,
BalanceAfter: newBalance,
}
logger.Logger.Info("Create mint order successful",
zap.String("order_id", mintOrder.OrderID),
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Int64("cost_crystal", costCrystal),
zap.Int64("balance_after", newBalance),
)
// 事件埋点:asset.mint(fire-and-forget)
statistic.Get().TrackEvent(context.Background(), &eventPb.Event{
EventType: "asset.mint",
UserId: userID,
StarId: starID,
OccurredAt: time.Now().UnixMilli(),
Properties: map[string]string{
"asset_id": strconv.FormatInt(asset.ID, 10),
"order_id": mintOrder.OrderID,
},
})
return response, nil
}
// markMintOrderFailed 独立事务中标 FAILED + 写入 error_message。
//
// 失败仅日志,不阻塞主流程(因为 markMintOrderFailed 自身失败时,
// ReconcileStuckMintOrders 也会兜底)。
//
// ★ Minor#6 nil 防御:cause == nil 时不能 cause.Error() panic,
// 落 "unknown error" 占位,避免把订单标 FAILED 但 error_message 为 NULL。
func (s *mintService) markMintOrderFailed(orderID string, cause error) {
if orderID == "" {
return
}
if cause == nil {
cause = errors.New("unknown error")
}
now := time.Now().UnixMilli()
msg := cause.Error()
err := s.db.Model(&models.MintOrder{}).
Where("order_id = ?", orderID).
Updates(map[string]interface{}{
"status": models.MintOrderStatusFailed,
"error_message": msg,
"updated_at": now,
}).Error
if err != nil {
logger.Logger.Error("markMintOrderFailed failed",
zap.String("order_id", orderID),
zap.Error(err),
)
}
}
// ModelToProtoMintOrder 将数据库模型转换为Proto格式
func ModelToProtoMintOrder(order *models.MintOrder) *pb.MintOrder {
if order == nil {
return nil
}
return &pb.MintOrder{
OrderId: order.OrderID,
UserId: order.UserID,
AssetId: getInt64Value(order.AssetID),
StarId: order.StarID,
Status: order.Status,
CostCrystal: order.CostCrystal,
ErrorMessage: getStringValue(order.ErrorMessage),
RetryCount: order.RetryCount,
MaterialUrl: getStringValue(order.MaterialURL),
Name: getStringValue(order.Name),
Description: getStringValue(order.Description),
MaterialType: getStringValue(order.MaterialType),
Event: getStringValue(order.Event),
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
MintedAt: getInt64Value(order.MintedAt),
Info: getStringValue(order.Info),
}
}
// stringToPtr 将字符串转换为指针
func stringToPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// int32ToPtr 将int32转换为指针
func int32ToPtr(i int32) *int32 {
return &i
}
// GetMintOrder 查询铸造订单状态
func (s *mintService) GetMintOrder(orderID string, userID, starID int64) (*pb.GetMintOrderResponse, error) {
// 1. 参数验证
if orderID == "" {
logger.Logger.Warn("Invalid order_id (empty)")
return nil, fmt.Errorf("order_id不能为空")
}
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
// 2. 查询订单(验证权限)
order, err := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID)
if err != nil {
logger.Logger.Error("Failed to get mint order",
zap.String("order_id", orderID),
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("订单不存在或无权访问: %w", err)
}
// 3. 查询关联的资产(如果存在)
var assetProto *pb.Asset
if order.AssetID != nil {
asset, err := s.assetRepo.GetByID(*order.AssetID)
if err == nil && asset != nil {
// 获取所有者昵称和头像
var ownerNickname string
var ownerAvatar string
profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID)
if err == nil && profile != nil {
ownerNickname = profile.Nickname
ownerAvatar = profile.AvatarUrl
}
// 转换为 Proto这里需要调用 ModelToProtoAssetDetail但需要 is_liked 参数)
// 由于是查询自己的订单is_liked 设为 false简化处理
// 获取 display_status
displayStatus, _ := s.assetRepo.GetDisplayStatusByAssetID(asset.ID)
assetProto = ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, false, displayStatus, 0, 0, 0, getInt32Value(asset.Grade)) // 新创建的资产earnings、hourlyEarnings 和 exhibitionExpireAt 为 0
// 如果 cover_url 存在,生成预签名 URL
if assetProto.CoverUrl != "" {
signedURL, err := s.generatePresignedURL(assetProto.CoverUrl, 3600)
if err == nil {
// 将预签名 URL 添加到 asset 的扩展字段中
// 注意proto 定义中可能没有 cover_url_signed 字段,需要检查
// 这里先记录日志,后续可以在 DTO 层处理
logger.Logger.Debug("生成预签名URL成功",
zap.String("cover_url", assetProto.CoverUrl),
zap.String("signed_url", signedURL),
)
// TODO: 将 signedURL 添加到响应中(需要在 proto 中添加字段或通过 DTO 处理)
}
}
}
}
// 4. 构建响应
response := &pb.GetMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(order),
Asset: assetProto,
}
logger.Logger.Info("GetMintOrder successful",
zap.String("order_id", orderID),
zap.Int64("user_id", userID),
zap.String("status", order.Status),
)
return response, nil
}
// generatePresignedURL 生成预签名 URL复用 Gateway 的逻辑)
func (s *mintService) generatePresignedURL(filePath string, expiresInSeconds int64) (string, error) {
// 从环境变量读取 OSS 配置(必须设置,无默认值)
region := os.Getenv("OSS_REGION")
bucketName := os.Getenv("OSS_BUCKET_NAME")
roleArn := os.Getenv("OSS_STS_ROLE_ARN")
accessKeyID := os.Getenv("OSS_ACCESS_KEY_ID")
accessKeySecret := os.Getenv("OSS_ACCESS_KEY_SECRET")
// 验证必需的配置项
if region == "" || bucketName == "" || roleArn == "" || accessKeyID == "" || accessKeySecret == "" {
return "", fmt.Errorf("OSS 配置不完整,请设置环境变量: OSS_REGION, OSS_BUCKET_NAME, OSS_STS_ROLE_ARN, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET")
}
// 获取 STS 临时凭证
credConfig := new(credentials.Config).
SetType("ram_role_arn").
SetAccessKeyId(accessKeyID).
SetAccessKeySecret(accessKeySecret).
SetRoleArn(roleArn).
SetRoleSessionName("topfans-download-session").
SetPolicy("").
SetRoleSessionExpiration(int(expiresInSeconds))
provider, err := credentials.NewCredential(credConfig)
if err != nil {
return "", fmt.Errorf("创建凭证提供器失败: %w", err)
}
cred, err := provider.GetCredential()
if err != nil {
return "", fmt.Errorf("获取临时凭证失败: %w", err)
}
// 创建 OSS 客户端
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
oss.SecurityToken(*cred.SecurityToken))
if err != nil {
return "", fmt.Errorf("创建OSS客户端失败: %w", err)
}
// 获取 Bucket
bucket, err := client.Bucket(bucketName)
if err != nil {
return "", fmt.Errorf("获取Bucket失败: %w", err)
}
// 从完整 URL 中提取 OSS key
// 格式: https://bucket.oss-region.aliyuncs.com/key
ossKey := filePath
if strings.HasPrefix(filePath, "https://") {
// 提取 key 部分
// 例如: https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/7/88/covers/123_1234567890.png
// 需要提取: asset/7/88/covers/123_1234567890.png
parts := strings.SplitN(filePath, ".oss-", 2)
if len(parts) == 2 {
// parts[1] = "cn-shanghai.aliyuncs.com/asset/7/88/covers/123_1234567890.png"
keyParts := strings.SplitN(parts[1], "/", 2)
if len(keyParts) == 2 {
ossKey = keyParts[1] // asset/7/88/covers/123_1234567890.png
}
}
}
// 生成预签名 URL
signedURL, err := bucket.SignURL(ossKey, oss.HTTPGet, expiresInSeconds)
if err != nil {
return "", fmt.Errorf("生成预签名URL失败: %w", err)
}
// 修复 path 的 URL 编码OSS SDK 的 buildURL 会把 / 编成 %2F导致 OSS 按字面 key 查找失败(403)。
// 只把 path 段(? 之前)的 %2F 改回 /。
if idx := strings.Index(signedURL, "?"); idx >= 0 {
signedURL = strings.ReplaceAll(signedURL[:idx], "%2F", "/") + signedURL[idx:]
} else {
signedURL = strings.ReplaceAll(signedURL, "%2F", "/")
}
// 若 SDK 未把 STS 的 security-token 加入 URL则手动追加使用 STS 时预签名必须带此参数,否则 403
if !strings.Contains(signedURL, "security-token") && cred.SecurityToken != nil && *cred.SecurityToken != "" {
signedURL = signedURL + "&security-token=" + url.QueryEscape(*cred.SecurityToken)
}
return signedURL, nil
}
// CancelMintOrder 取消铸造订单
func (s *mintService) CancelMintOrder(orderID string, userID, starID int64) error {
// 1. 参数验证
if orderID == "" {
logger.Logger.Warn("Invalid order_id (empty)")
return fmt.Errorf("order_id不能为空")
}
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return appErrors.ErrInvalidStarID
}
// 2. 查询订单
order, err := s.mintOrderRepo.GetByOrderID(orderID)
if err != nil {
logger.Logger.Error("Failed to get mint order",
zap.String("order_id", orderID),
zap.Error(err),
)
return fmt.Errorf("failed to get mint order: %w", err)
}
// 3. 验证订单所有者
if order.UserID != userID || order.StarID != starID {
logger.Logger.Warn("Unauthorized to cancel this order",
zap.String("order_id", orderID),
zap.Int64("order_user_id", order.UserID),
zap.Int64("order_star_id", order.StarID),
zap.Int64("request_user_id", userID),
zap.Int64("request_star_id", starID),
)
return appErrors.ErrMintOrderAccessDenied
}
// 4. 检查订单状态新流程PENDING 才允许“取消并清理素材与订单”)
if order.Status != models.MintOrderStatusPending && order.Status != models.MintOrderStatusFailed {
logger.Logger.Warn("Cannot cancel order in current status",
zap.String("order_id", orderID),
zap.String("status", order.Status),
)
return fmt.Errorf("订单状态为%s不能取消", order.Status)
}
// 5. PENDING删除素材 + 删除订单(按 list.txt 要求)
if order.Status == models.MintOrderStatusPending {
// 删除 OSS 素材best-effort
if mu := getStringValue(order.MaterialURL); mu != "" {
ossKey := extractOSSKeyFromURLForService(mu)
if ossKey != "" {
cfg := util.OSSConfig{
Region: os.Getenv("OSS_REGION"),
BucketName: os.Getenv("OSS_BUCKET_NAME"),
RoleArn: os.Getenv("OSS_STS_ROLE_ARN"),
AccessKeyID: os.Getenv("OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("OSS_ACCESS_KEY_SECRET"),
}
_ = util.DeleteObjectFromOSS(cfg, ossKey)
}
}
// 删除订单记录
if err := s.mintOrderRepo.DeleteByOrderID(orderID); err != nil {
return fmt.Errorf("failed to delete mint order: %w", err)
}
return nil
}
// 6. FAILED保留兼容旧逻辑只改状态为 CANCELLED
if err := s.mintOrderRepo.CancelOrder(orderID); err != nil {
return fmt.Errorf("failed to cancel mint order: %w", err)
}
// 注意:不需要退回水晶,因为水晶是在创建订单时扣除的
// 取消订单不会退款
logger.Logger.Info("Mint order cancelled successfully",
zap.String("order_id", orderID),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil
}
// GetMintCost 获取铸造消耗配置
// 根据当前累计铸爱次数获取下次铸造的消耗配置
func (s *mintService) GetMintCost(mintCount int32) (*models.MintCostConfig, error) {
// 铸爱次数从1开始最大10
if mintCount < 1 {
mintCount = 1
}
if mintCount > 10 {
mintCount = 10
}
config, err := s.localMintCostRepo.GetByMintCount(mintCount)
if err != nil {
logger.Logger.Error("Failed to get mint cost config",
zap.Int32("mint_count", mintCount),
zap.Error(err))
return nil, fmt.Errorf("获取铸造配置失败: %w", err)
}
return config, nil
}
// GetUserMintCount 获取用户累计铸爱次数
func (s *mintService) GetUserMintCount(userID, starID int64) (int32, error) {
record, err := s.userMintCountRepo.Get(userID, starID)
if err != nil {
// 如果记录不存在返回0
return 0, nil
}
return record.MintCount, nil
}
// EstimateMintCost 估算铸造费用(不实际扣款)
func (s *mintService) EstimateMintCost(userID, starID int64) (*MintCostEstimate, error) {
// 获取当前累计铸爱次数
currentMintCount, err := s.GetUserMintCount(userID, starID)
if err != nil {
logger.Logger.Warn("Failed to get user mint count, using 0",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
currentMintCount = 0
}
// 获取铸造消耗配置(本次铸造是第 currentMintCount+1 次)
localMintCost, err := s.GetMintCost(currentMintCount + 1)
if err != nil {
return nil, fmt.Errorf("获取铸造消耗配置失败: %w", err)
}
// 获取当前水晶余额
profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID)
var currentBalance int64 = 0
if err != nil {
logger.Logger.Warn("Failed to get fan profile, balance will be 0",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
} else {
currentBalance = profile.CrystalBalance
}
// 计算铸造后余额
afterBalance := currentBalance - localMintCost.CostCrystal
if afterBalance < 0 {
afterBalance = 0
}
// 下一阶梯费用
var nextTierCost int64
nextMintCount := currentMintCount + 2 // 下一次铸造的次数
if nextMintCount <= 10 {
nextCost, err := s.GetMintCost(nextMintCount)
if err == nil {
nextTierCost = nextCost.CostCrystal
}
}
return &MintCostEstimate{
CostCrystal: localMintCost.CostCrystal,
CurrentBalance: currentBalance,
AfterBalance: afterBalance,
MintCount: currentMintCount + 1, // 本次将是第几次铸造
NextTierCost: nextTierCost,
}, nil
}
// MintCostEstimate 铸造费用估算结果
type MintCostEstimate struct {
CostCrystal int64 // 本次铸造消耗水晶
CurrentBalance int64 // 当前余额(铸造前)
AfterBalance int64 // 铸造后余额
MintCount int32 // 本次铸造是第几次
NextTierCost int64 // 下一阶梯费用
}
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
// 在事务内调用tx 为nil时会创建新事务
func (s *mintService) UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error {
// 获取或创建用户铸爱累计记录
record, _, err := s.userMintCountRepo.GetOrCreate(tx, userID, starID)
if err != nil {
logger.Logger.Error("Failed to get or create user mint count",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
return fmt.Errorf("获取用户铸爱累计失败: %w", err)
}
// 计算新的铸爱次数
newMintCount := record.MintCount + 1
// 如果达到10次重置为0
if newMintCount > 10 {
newMintCount = 0
}
// 更新记录
record.MintCount = newMintCount
if boostBps > 0 {
record.RevenueBoostBps += boostBps
}
record.UpdatedAt = time.Now().UnixMilli()
// 注意GetOrCreate 已经插入了一条记录(isNew=true),所以这里直接 Save 更新即可
// 不需要再 Create否则会违反 user_id+star_id 唯一索引
if err := tx.Save(record).Error; err != nil {
return fmt.Errorf("更新用户铸爱累计记录失败: %w", err)
}
logger.Logger.Info("Updated mint count and boost",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int32("new_mint_count", newMintCount),
zap.Int32("revenue_boost_bps", record.RevenueBoostBps))
return nil
}
// extractOSSKeyFromURLForService 从 OSS URL 提取 key服务内使用
func extractOSSKeyFromURLForService(filePath string) string {
ossKey := filePath
if strings.HasPrefix(filePath, "https://") {
parts := strings.SplitN(filePath, ".oss-", 2)
if len(parts) == 2 {
keyParts := strings.SplitN(parts[1], "/", 2)
if len(keyParts) == 2 {
ossKey = keyParts[1]
}
}
}
return ossKey
}
// syncAssetsIDSequence 将 assets_id_seq 对齐到 MAX(id),避免序列落后导致 assets_pkey 冲突 (23505)
func syncAssetsIDSequence(tx *gorm.DB) error {
return tx.Exec(`
SELECT setval(
pg_get_serial_sequence('assets', 'id'),
GREATEST(COALESCE((SELECT MAX(id) FROM assets), 1), 1)
)
`).Error
}
// rollGuarantee 按 probability(0-100) 概率返回是否触发保底.
//
// ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100,
// 消除并发同纳秒相同结果与脚本卡点操纵风险。
// 边界约定:
// - probability <= 0 → 永不触发(false)
// - probability >= 100 → 总是触发(true)
// - 熵源失败时降级为不触发(false),避免在极端环境下出现伪随机劣化
//
// 入参取 int64 与 models.MintCostConfig.Probability 字段类型一致,
// 避免调用点显式转换;非法值(负数 / 远超 100)按既定边界处理。
func rollGuarantee(probability int64) 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() < probability
}
// =====================================================================
// 孤儿订单对账 (mint-task-3)
//
// 背景:
// CreateMintOrder 拆为三段后,如果资产服务在阶段2(RPC 扣费)和阶段3(建 asset)
// 之间崩溃,可能留下"水晶已扣但 asset 未建"的订单卡在 PROCESSING 状态。
//
// 如果 markMintOrderFailed 也失败(罕见的 DB 故障),同样会留下 PROCESSING 订单。
//
// 设计:
// 1) 扫描 status=PROCESSING 且 updated_at 早于 staleAfter 的订单;
// 2) 对每单,查 crystal_transaction_records 是否有该 order_id 的 mint_cost 流水;
// 3) 决策:
// - 已扣水晶 + 无 asset(资产已建但订单状态没推进) → 幂等补完 phase3;
// - 未扣水晶 → 安全标 FAILED(没扣钱,直接结束状态机);
// - 已扣 + 已建 asset(理论上不会发生) → 不动,留人工排查。
//
// 边界:
// - 幂等:重复跑不会重复建 asset,靠 mint_order.asset_id / asset_registry.asset_id 判断。
// - 不退款:避免双花。只标 FAILED 或补完 SUCCESS,绝不调用 UpdateCrystalBalance 正向补扣。
//
// 调用方:
// - 可由 main.go 启动时起一个 goroutine 周期触发(建议 5~15 分钟一次);
// - 也提供 RPC / HTTP 管理接口供运维手动触发(本任务未实现)。
// =====================================================================
// reconcileThreshold 对账扫描阈值(默认 10 分钟前还在 PROCESSING 的订单视为孤儿)
// 与 brief 推荐一致。函数签名上仍允许调用方传入 staleAfter 覆盖默认。
const reconcileThreshold = 10 * time.Minute
// ReconcileStuckMintOrders 扫描 status=PROCESSING 且 updated_at 早于 staleAfter 的订单,
// 对每单判定:
// - 水晶已扣(mint_cost 流水存在) + 无 asset → 幂等补完 phase3(建 asset + 订单→SUCCESS)
// - 水晶未扣 → 标 FAILED(没扣钱,安全结束状态机)
// - 水晶已扣 + asset 已建(理论上不会发生) → 不动,告警日志留人工排查
//
// 幂等:重复跑不会重复建 asset,不会二次扣水晶,不会把 SUCCESS/FAILED 订单再次处理。
//
// 返回:扫描到的 PROCESSING 订单数,以及最终处理结果(recovered / marked_failed / skipped)。
func (s *mintService) ReconcileStuckMintOrders(ctx context.Context, staleAfter time.Duration) error {
if staleAfter <= 0 {
staleAfter = reconcileThreshold
}
cutoff := time.Now().Add(-staleAfter).UnixMilli()
// 1) 扫描陈旧 PROCESSING 订单(每次最多处理 100 单,避免长事务)
var stuckOrders []models.MintOrder
if err := s.db.WithContext(ctx).
Where("status = ? AND updated_at < ?", models.MintOrderStatusProcessing, cutoff).
Order("updated_at ASC").
Limit(100).
Find(&stuckOrders).Error; err != nil {
return fmt.Errorf("scan stuck mint orders: %w", err)
}
logger.Logger.Info("ReconcileStuckMintOrders start",
zap.Int("stuck_count", len(stuckOrders)),
zap.Int64("cutoff_ms", cutoff),
zap.Duration("stale_after", staleAfter))
var recovered, failed, skipped int
for i := range stuckOrders {
order := stuckOrders[i]
action, err := s.reconcileOneMintOrder(ctx, &order)
if err != nil {
// 单单失败不中断对账(其他订单还有救)
failed++
logger.Logger.Error("reconcile one order failed",
zap.String("order_id", order.OrderID),
zap.Error(err))
continue
}
switch action {
case reconcileActionRecovered:
recovered++
case reconcileActionFailed:
failed++
default:
skipped++
}
}
logger.Logger.Info("ReconcileStuckMintOrders done",
zap.Int("scanned", len(stuckOrders)),
zap.Int("recovered", recovered),
zap.Int("marked_failed", failed),
zap.Int("skipped", skipped))
return nil
}
// reconcileAction 对账动作结果(给 worker 上报计数用)
type reconcileAction int
const (
reconcileActionSkipped reconcileAction = iota
reconcileActionFailed // 安全标 FAILED(无 mint_cost 流水)
reconcileActionRecovered // 幂等补完 SUCCESS
)
// reconcileOneMintOrder 处理单个孤儿订单。
//
// 决策表:
//
// | asset_id | mint_cost 流水 | 动作 |
// | -------- | -------------- | -------------------------- |
// | NULL | 无 | markMintOrderFailed (FAILED) |
// | NULL | 有 | 补完 phase3 (Recovered) |
// | 已设 | (任意) | force SUCCESS (Recovered) |
//
// 返回 action + error:error 不为 nil 时 action 忽略不计(failed)。
func (s *mintService) reconcileOneMintOrder(ctx context.Context, order *models.MintOrder) (reconcileAction, error) {
// 二次校验:防止扫描后被并发改状态
fresh, err := s.mintOrderRepo.GetByOrderID(order.OrderID)
if err != nil {
return reconcileActionSkipped, fmt.Errorf("reload order: %w", err)
}
if fresh.Status != models.MintOrderStatusProcessing {
logger.Logger.Debug("reconcile: order no longer PROCESSING, skip",
zap.String("order_id", order.OrderID),
zap.String("status", fresh.Status))
return reconcileActionSkipped, nil
}
// 查水晶流水:source_id = order_id, change_type = 'mint_cost'
var txCount int64
if err := s.db.WithContext(ctx).
Table("crystal_transaction_records").
Where("source_id = ? AND change_type = ?", order.OrderID, "mint_cost").
Count(&txCount).Error; err != nil {
return reconcileActionSkipped, fmt.Errorf("count crystal tx: %w", err)
}
// 决策分支
if txCount == 0 {
// 未扣水晶:安全标 FAILED
logger.Logger.Info("reconcile: no crystal charged, mark FAILED",
zap.String("order_id", order.OrderID),
zap.Int64("user_id", order.UserID),
zap.Int64("star_id", order.StarID))
s.markMintOrderFailed(order.OrderID, fmt.Errorf("reconciled: PROCESSING without mint_cost flow"))
return reconcileActionFailed, nil
}
// 已扣水晶
if fresh.AssetID != nil && *fresh.AssetID > 0 {
// 理论上不该发生:asset 已建但订单 PROCESSING。
// 直接推进到 SUCCESS 兜底,避免无限对账。
logger.Logger.Warn("reconcile: order has asset_id but status=PROCESSING, force SUCCESS",
zap.String("order_id", order.OrderID),
zap.Int64("asset_id", *fresh.AssetID))
now := time.Now().UnixMilli()
if err := s.db.Model(&models.MintOrder{}).
Where("order_id = ? AND status = ?", order.OrderID, models.MintOrderStatusProcessing).
Updates(map[string]interface{}{
"status": models.MintOrderStatusSuccess,
"updated_at": now,
}).Error; err != nil {
return reconcileActionSkipped, fmt.Errorf("force SUCCESS: %w", err)
}
return reconcileActionRecovered, nil
}
// 已扣水晶 + 无 asset:幂等补完 phase3(带行锁)
logger.Logger.Info("reconcile: crystal charged but no asset, recovering phase3",
zap.String("order_id", order.OrderID),
zap.Int64("user_id", order.UserID),
zap.Int64("star_id", order.StarID))
if err := s.finalizeMintOrderRecovery(ctx, order.OrderID); err != nil {
return reconcileActionSkipped, err
}
return reconcileActionRecovered, nil
}
// finalizeMintOrderRecovery 在对账场景下补完 phase3(建 asset + registry + 订单→SUCCESS)。
//
// 与 CreateMintOrder 阶段3 的区别:
// - 不再调 UpdateCrystalBalance(钱已扣过)
// - 不再 UpdateMintCountAndBoost(阶段1 已更新过)
// - 复用同样的事务结构 + 序列同步
// - ★ mint-task-3 review: 事务**开头**对该 order 加 SELECT...FOR UPDATE 行锁,
// 锁内重新判定 status/asset_id,避免与并发的 CreateMintOrder-phase3 双建 asset。
//
// 失败处理:写 ERROR 日志。下一轮对账或人工介入,
// 关键原则:**绝不退款,避免双花**。
func (s *mintService) finalizeMintOrderRecovery(ctx context.Context, orderID string) error {
now := time.Now().UnixMilli()
var createdAssetID int64
var recoveredUserID, recoveredStarID int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// ★ 行锁:SELECT...FOR UPDATE 串行化并发的 finalize / CreateMintOrder-phase3
var locked models.MintOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ?", orderID).
First(&locked).Error; err != nil {
return fmt.Errorf("lock order for recovery: %w", err)
}
// 锁内重判:状态机是否已被并发推进?
if locked.Status != models.MintOrderStatusProcessing {
logger.Logger.Info("recovery: order no longer PROCESSING after lock, skip",
zap.String("order_id", orderID),
zap.String("status", locked.Status))
return nil
}
recoveredUserID = locked.UserID
recoveredStarID = locked.StarID
// 锁内重判:asset 已被并发路径建好了?
hasAsset := false
var existingAsset models.Asset
if locked.AssetID != nil && *locked.AssetID > 0 {
if terr := tx.Where("id = ?", *locked.AssetID).First(&existingAsset).Error; terr == nil {
hasAsset = true
createdAssetID = existingAsset.ID
}
}
if !hasAsset {
// 序列同步
if serr := syncAssetsIDSequence(tx); serr != nil {
return fmt.Errorf("sync asset id sequence: %w", serr)
}
// 重建 asset(与正常 CreateMintOrder 阶段3 一致)
materialURLValue := getStringValue(locked.MaterialURL)
nameValue := getStringValue(locked.Name)
if nameValue == "" {
nameValue = "未命名藏品"
}
infoValue := getStringValue(locked.Info)
mintedAt := now
asset := &models.Asset{
OwnerUID: locked.UserID,
StarID: locked.StarID,
Name: nameValue,
CoverURL: materialURLValue,
MaterialURL: &materialURLValue,
Description: locked.Description,
Visibility: models.AssetVisibilityPrivate,
Status: models.AssetStatusActive,
LikeCount: 0,
Info: infoValue,
MintedAt: &mintedAt,
}
if err := tx.Create(asset).Error; err != nil {
return fmt.Errorf("create asset during recovery: %w", err)
}
createdAssetID = asset.ID
grade := int32(1)
registry := &models.AssetRegistry{
AssetID: asset.ID,
AssetType: models.AssetTypeRegular,
OwnerUID: locked.UserID,
StarID: locked.StarID,
Grade: &grade,
Status: models.AssetRegistryStatusActive,
LikeCount: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(registry).Error; err != nil {
return fmt.Errorf("create asset registry during recovery: %w", err)
}
}
// 推进订单 PROCESSING → SUCCESS(带 status 守卫,防双写)
updates := map[string]interface{}{
"status": models.MintOrderStatusSuccess,
"updated_at": now,
}
if createdAssetID > 0 {
updates["asset_id"] = createdAssetID
updates["minted_at"] = now
}
// mint_count 在阶段1 已自增过,这里不再回退(防止 mint_count 倒退)
// 同样不重置 cost_crystal(已记录的快照代表真实扣费)
res := tx.Model(&models.MintOrder{}).
Where("order_id = ? AND status = ?", orderID, models.MintOrderStatusProcessing).
Updates(updates)
if res.Error != nil {
return fmt.Errorf("mark SUCCESS during recovery: %w", res.Error)
}
if res.RowsAffected == 0 {
// 理论上锁内不可能被改;RowsAffected=0 极少见,留 WARN 不算错。
logger.Logger.Warn("recovery: status guard returned 0 rows, status may have flipped",
zap.String("order_id", orderID))
return nil
}
// 初始化资产等级记录(如果有 service)
if s.assetLevelService != nil && createdAssetID > 0 {
if _, lerr := s.assetLevelService.GetOrCreateRecord(createdAssetID); lerr != nil {
// 不阻塞主流程,只 WARN
logger.Logger.Warn("recovery: failed to create asset level record",
zap.Int64("asset_id", createdAssetID), zap.Error(lerr))
}
}
return nil
})
if err != nil {
logger.Logger.Error("recovery transaction failed, will retry next round",
zap.String("order_id", orderID),
zap.Int64("user_id", recoveredUserID),
zap.Int64("star_id", recoveredStarID),
zap.Error(err))
// 不标 FAILED — 留到下轮重试或人工排查。
// 因为水晶已扣,如果本轮标 FAILED,运营只能"手动重建 asset"或"退款",
// 都比"再让对账跑一次"风险更高。
return err
}
logger.Logger.Info("recovery succeeded",
zap.String("order_id", orderID),
zap.Int64("asset_id", createdAssetID),
zap.Int64("user_id", recoveredUserID),
zap.Int64("star_id", recoveredStarID))
return nil
}