876 lines
28 KiB
Go
876 lines
28 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||
"github.com/aliyun/credentials-go/credentials"
|
||
"github.com/google/uuid"
|
||
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"
|
||
)
|
||
|
||
// 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)
|
||
|
||
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
|
||
UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error
|
||
}
|
||
|
||
// mintService 铸造服务实现
|
||
type mintService struct {
|
||
assetRepo repository.AssetRepository
|
||
mintOrderRepo repository.MintOrderRepository
|
||
userClient client.UserServiceClient
|
||
db *gorm.DB
|
||
config *config.AssetConfig
|
||
registryRepo starbookRepo.AssetRegistryRepository // 资产索引仓库(用于星册体系)
|
||
mintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
|
||
userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库
|
||
}
|
||
|
||
// NewMintService 创建铸造服务实例
|
||
func NewMintService(
|
||
assetRepo repository.AssetRepository,
|
||
mintOrderRepo repository.MintOrderRepository,
|
||
userClient client.UserServiceClient,
|
||
db *gorm.DB,
|
||
cfg *config.AssetConfig,
|
||
registryRepo starbookRepo.AssetRegistryRepository,
|
||
mintCostRepo repository.MintCostRepository,
|
||
userMintCountRepo repository.UserMintCountRepository,
|
||
) MintService {
|
||
return &mintService{
|
||
assetRepo: assetRepo,
|
||
mintOrderRepo: mintOrderRepo,
|
||
userClient: userClient,
|
||
db: db,
|
||
config: cfg,
|
||
registryRepo: registryRepo,
|
||
mintCostRepo: mintCostRepo,
|
||
userMintCountRepo: userMintCountRepo,
|
||
}
|
||
}
|
||
|
||
// 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: pbCommon.StatusCode_STATUS_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: pbCommon.StatusCode_STATUS_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: pbCommon.StatusCode_STATUS_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: pbCommon.StatusCode_STATUS_OK,
|
||
Message: "",
|
||
Timestamp: time.Now().UnixMilli(),
|
||
},
|
||
Order: ModelToProtoMintOrder(order),
|
||
}, nil
|
||
}
|
||
|
||
// CreateMintOrder 创建铸造订单
|
||
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 获取)")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 3. 使用事务创建铸造订单(或将阶段一订单推进到 PROCESSING)
|
||
var mintOrder *models.MintOrder
|
||
var asset *models.Asset
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
// 3.0 取出阶段一订单,并校验状态/所有者
|
||
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
|
||
if err != nil {
|
||
return fmt.Errorf("order not found: %w", err)
|
||
}
|
||
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")
|
||
}
|
||
// info 为必填
|
||
if getStringValue(mintOrder.Info) == "" {
|
||
return fmt.Errorf("info is required")
|
||
}
|
||
|
||
// 如果没有提供名称,使用默认名称
|
||
if getStringValue(mintOrder.Name) == "" {
|
||
mintOrder.Name = stringToPtr("未命名藏品")
|
||
}
|
||
|
||
// 3.1 获取铸造消耗配置(阶梯计价)
|
||
// 本次铸造是第 currentMintCount+1 次
|
||
mintCost, err := s.GetMintCost(currentMintCount + 1)
|
||
if err != nil {
|
||
return fmt.Errorf("获取铸造消耗配置失败: %w", err)
|
||
}
|
||
|
||
// 3.2 扣除水晶余额(调用 User Service RPC)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintCost.CostCrystal,
|
||
"mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId))
|
||
if err != nil {
|
||
logger.Logger.Error("Failed to deduct crystal balance",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("cost", mintCost.CostCrystal),
|
||
zap.Error(err),
|
||
)
|
||
return fmt.Errorf("水晶余额不足或扣除失败: %w", err)
|
||
}
|
||
|
||
logger.Logger.Info("Crystal balance deducted with tiered cost",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("cost", mintCost.CostCrystal),
|
||
zap.Int32("mint_count", currentMintCount+1),
|
||
zap.Int64("new_balance", newBalance),
|
||
)
|
||
|
||
// 3.3 检查是否触发保底(概率触发)
|
||
var boostBps int32 = 0
|
||
if mintCost.Probability > 0 && mintCost.RewardValue > 0 {
|
||
// 随机判断是否触发
|
||
randomValue := time.Now().UnixNano() % 100
|
||
if randomValue < mintCost.Probability {
|
||
boostBps = int32(mintCost.RewardValue) // reward_value 单位是 bps
|
||
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", mintCost.Probability))
|
||
}
|
||
}
|
||
|
||
// 3.4 更新用户铸爱次数和收益提升
|
||
if err := s.UpdateMintCountAndBoost(ctx, tx, userID, starID, boostBps); err != nil {
|
||
logger.Logger.Error("Failed to update mint count and boost",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Error(err))
|
||
// 不阻断主流程,只记录错误
|
||
}
|
||
|
||
// 3.5 创建资产记录(状态:Active,CoverURL 直接使用 MaterialURL)
|
||
materialURLValue := getStringValue(mintOrder.MaterialURL)
|
||
mintedAt := time.Now().UnixMilli()
|
||
mockTxHash := fmt.Sprintf("0x%x", sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%d-%d", mintOrder.OrderID, userID, starID, time.Now().UnixNano()))))
|
||
mockBlockNumber := int64(time.Now().Unix()) // 使用当前时间戳作为模拟区块号
|
||
asset = &models.Asset{
|
||
OwnerUID: userID,
|
||
StarID: starID,
|
||
Name: getStringValue(mintOrder.Name),
|
||
CoverURL: materialURLValue, // 直接使用素材图作为封面
|
||
MaterialURL: &materialURLValue, // 使用指针
|
||
Description: mintOrder.Description,
|
||
Visibility: models.AssetVisibilityPrivate,
|
||
Status: models.AssetStatusActive, // 直接设为 Active
|
||
LikeCount: 0,
|
||
Info: getStringValue(mintOrder.Info),
|
||
TxHash: &mockTxHash,
|
||
BlockNumber: &mockBlockNumber,
|
||
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("Failed to create asset",
|
||
zap.Int64("user_id", userID),
|
||
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),
|
||
)
|
||
|
||
// 3.3 同步写入 asset_registry(普通藏品纳入星册体系)
|
||
grade := int32(1) // 普通藏品初始等级为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),
|
||
)
|
||
|
||
// 3.5 推进阶段一订单到 SUCCESS,并关联资产(铸造同步完成)
|
||
mintedAt = time.Now().UnixMilli()
|
||
updates := map[string]interface{}{
|
||
"asset_id": asset.ID,
|
||
"status": models.MintOrderStatusSuccess, // 直接设为成功,无需异步处理
|
||
"cost_crystal": mintCost.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 = mintCost.CostCrystal
|
||
|
||
logger.Logger.Info("Mint order created",
|
||
zap.String("order_id", mintOrder.OrderID),
|
||
zap.Int64("asset_id", asset.ID),
|
||
)
|
||
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 4. 无需异步 AI 处理,cover_url 已在步骤 3.2 中直接设置
|
||
|
||
// 5. 获取所有者的昵称(创建时所有者就是当前用户)
|
||
var ownerNickname 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
|
||
}
|
||
|
||
// 6. 构建响应
|
||
response := &pb.CreateMintOrderResponse{
|
||
Base: &pbCommon.BaseResponse{
|
||
Code: pbCommon.StatusCode_STATUS_OK,
|
||
Message: "",
|
||
Timestamp: time.Now().UnixMilli(),
|
||
},
|
||
Order: ModelToProtoMintOrder(mintOrder),
|
||
Asset: ModelToProtoAssetDetail(asset, ownerNickname, false, 0, 0, 0), // 新创建的资产,is_liked 为 false,display_status 默认为 0,earnings 和 exhibitionExpireAt 为 0
|
||
}
|
||
|
||
logger.Logger.Info("Create mint order successful",
|
||
zap.String("order_id", mintOrder.OrderID),
|
||
zap.Int64("asset_id", asset.ID),
|
||
zap.Int64("user_id", userID),
|
||
)
|
||
|
||
return response, nil
|
||
}
|
||
|
||
// 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
|
||
profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID)
|
||
if err == nil && profile != nil {
|
||
ownerNickname = profile.Nickname
|
||
}
|
||
|
||
// 转换为 Proto(这里需要调用 ModelToProtoAssetDetail,但需要 is_liked 参数)
|
||
// 由于是查询自己的订单,is_liked 设为 false(简化处理)
|
||
// 获取 display_status
|
||
displayStatus, _ := s.assetRepo.GetDisplayStatusByAssetID(asset.ID)
|
||
assetProto = ModelToProtoAssetDetail(asset, ownerNickname, false, displayStatus, 0, 0) // 新创建的资产,earnings 和 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: pbCommon.StatusCode_STATUS_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.mintCostRepo.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
|
||
}
|
||
|
||
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
|
||
// 在事务内调用,tx 为nil时会创建新事务
|
||
func (s *mintService) UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error {
|
||
// 获取或创建用户铸爱累计记录
|
||
record, isNew, 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()
|
||
|
||
if isNew {
|
||
if err := tx.Create(record).Error; err != nil {
|
||
return fmt.Errorf("创建用户铸爱累计记录失败: %w", err)
|
||
}
|
||
} else {
|
||
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
|
||
}
|