- 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>
1465 lines
50 KiB
Go
1465 lines
50 KiB
Go
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_id,status=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_id,status=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
|
||
}
|