topfans/backend/services/assetService/service/mint_service.go
2026-05-16 02:42:32 +08:00

876 lines
28 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/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_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)
// 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_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: 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 创建资产记录状态ActiveCoverURL 直接使用 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 为 falsedisplay_status 默认为 0earnings 和 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
}