1463 lines
45 KiB
Go
1463 lines
45 KiB
Go
package controller
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"hash"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"dubbo.apache.org/dubbo-go/v3/client"
|
||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||
"github.com/aliyun/credentials-go/credentials"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
"github.com/topfans/backend/gateway/config"
|
||
"github.com/topfans/backend/gateway/dto"
|
||
"github.com/topfans/backend/gateway/pkg/response"
|
||
pbAsset "github.com/topfans/backend/pkg/proto/asset"
|
||
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"github.com/topfans/backend/services/assetService/service"
|
||
)
|
||
|
||
// AssetController 资产相关控制器
|
||
type AssetController struct {
|
||
assetService pbAsset.AssetService
|
||
minimaxService service.MinimaxService
|
||
}
|
||
|
||
// NewAssetController 创建资产控制器
|
||
func NewAssetController(dubboClient *client.Client) (*AssetController, error) {
|
||
// 创建 AssetService 客户端
|
||
assetService, err := pbAsset.NewAssetService(dubboClient)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &AssetController{
|
||
assetService: assetService,
|
||
minimaxService: service.NewMinimaxService(),
|
||
}, nil
|
||
}
|
||
|
||
// UploadImage 上传图片(URL方式)
|
||
// @Summary 上传图片
|
||
// @Description 上传图片到系统(提供图片URL)
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body object{image_url=string,type=string} true "图片URL和类型: cover/material"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/upload [post]
|
||
func (ctrl *AssetController) UploadImage(c *gin.Context) {
|
||
// 解析请求参数
|
||
var req struct {
|
||
ImageURL string `json:"image_url" binding:"required"`
|
||
Type string `json:"type"` // cover, material,默认cover
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 验证URL格式
|
||
if !isValidURL(req.ImageURL) {
|
||
response.Error(c, http.StatusBadRequest, "无效的图片URL")
|
||
return
|
||
}
|
||
|
||
// 设置默认类型
|
||
if req.Type == "" {
|
||
req.Type = "cover"
|
||
}
|
||
|
||
// 验证类型
|
||
if req.Type != "cover" && req.Type != "material" {
|
||
response.Error(c, http.StatusBadRequest, "type必须是cover或material")
|
||
return
|
||
}
|
||
|
||
// TODO: 后续可以添加图片审核逻辑
|
||
// TODO: 后续可以添加AI生成逻辑
|
||
|
||
// 目前直接返回原URL
|
||
// 后续可以:
|
||
// 1. 下载图片到本地/OSS
|
||
// 2. 进行审核
|
||
// 3. AI处理
|
||
// 4. 返回处理后的URL
|
||
|
||
// 生成上传ID(可选)
|
||
uploadID := generateUploadID()
|
||
|
||
logger.Logger.Info("Image uploaded",
|
||
zap.String("image_url", req.ImageURL),
|
||
zap.String("type", req.Type),
|
||
zap.String("upload_id", uploadID),
|
||
)
|
||
|
||
response.Success(c, gin.H{
|
||
"pic_url": req.ImageURL, // 目前直接返回原URL
|
||
"upload_id": uploadID, // 上传ID,用于后续取消
|
||
})
|
||
}
|
||
|
||
// isValidURL 验证URL格式
|
||
func isValidURL(urlStr string) bool {
|
||
u, err := url.Parse(urlStr)
|
||
return err == nil && u.Scheme != "" && u.Host != ""
|
||
}
|
||
|
||
// generateUploadID 生成上传ID
|
||
func generateUploadID() string {
|
||
return fmt.Sprintf("upload_%d_%s", time.Now().Unix(), uuid.New().String()[:8])
|
||
}
|
||
|
||
// parseRPCError 解析 Dubbo RPC 错误字符串
|
||
// 格式: "code_17: 错误消息" 或 "Failed to xxx: code_17: 错误消息"
|
||
func parseRPCError(err error) (code int, message string) {
|
||
if err == nil {
|
||
return http.StatusOK, ""
|
||
}
|
||
|
||
errStr := err.Error()
|
||
|
||
// 查找最后一个 "code_XX:" 出现的位置(处理嵌套错误)
|
||
if strings.Contains(errStr, "code_") {
|
||
// 找到 "code_" 的最后一个位置
|
||
lastCodeIdx := strings.LastIndex(errStr, "code_")
|
||
if lastCodeIdx != -1 {
|
||
// 从 "code_" 开始提取后面的部分
|
||
remaining := errStr[lastCodeIdx:]
|
||
|
||
// 分割 "code_XX: message"
|
||
parts := strings.SplitN(remaining, ":", 2)
|
||
if len(parts) == 2 {
|
||
// 提取状态码
|
||
codeStr := strings.TrimSpace(strings.TrimPrefix(parts[0], "code_"))
|
||
if c, parseErr := strconv.Atoi(codeStr); parseErr == nil {
|
||
code = c
|
||
} else {
|
||
code = http.StatusInternalServerError
|
||
}
|
||
|
||
// 提取并清理消息(去掉可能的前缀)
|
||
message = strings.TrimSpace(parts[1])
|
||
return code, message
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果不是标准格式,返回原始错误消息
|
||
return http.StatusInternalServerError, errStr
|
||
}
|
||
|
||
// PreCreateMintOrder 阶段一:预创建铸造订单(生成 order_id)
|
||
// @Summary 预创建铸造订单(阶段一)
|
||
// @Description 上传素材后先创建 PENDING 订单,返回 order_id,前端决定是否继续铸造或取消
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body dto.PreCreateMintOrderRequestDTO true "预创建铸造订单请求"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/mints/precreate [post]
|
||
func (ctrl *AssetController) PreCreateMintOrder(c *gin.Context) {
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
starID, exists := c.Get("star_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
var req dto.PreCreateMintOrderRequestDTO
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
resp, err := ctrl.assetService.PreCreateMintOrder(ctx, &pbAsset.PreCreateMintOrderRequest{
|
||
Name: req.Name,
|
||
MaterialUrl: req.MaterialURL,
|
||
Description: req.Description,
|
||
MaterialType: req.MaterialType,
|
||
Event: req.Event,
|
||
})
|
||
if err != nil {
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 复用 MintOrderDTO 输出
|
||
data := map[string]interface{}{
|
||
"order": dto.ConvertMintOrder(resp.Order),
|
||
}
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// CreateMintOrder 创建铸造订单
|
||
// @Summary 创建铸造订单
|
||
// @Description 创建一个新的数字藏品铸造订单
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body dto.CreateMintOrderRequestDTO true "铸造订单请求"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/mints [post]
|
||
func (ctrl *AssetController) CreateMintOrder(c *gin.Context) {
|
||
// 从上下文获取用户信息
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
starID, exists := c.Get("star_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
// 解析请求参数
|
||
var req dto.CreateMintOrderRequestDTO
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 设置上下文和 Dubbo attachments
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.CreateMintOrder(ctx, &pbAsset.CreateMintOrderRequest{
|
||
OrderId: req.OrderID,
|
||
Name: req.Name,
|
||
MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成
|
||
Description: req.Description,
|
||
Grade: req.Grade,
|
||
Tags: req.Tags,
|
||
MaterialType: req.MaterialType,
|
||
Info: req.Info,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("CreateMintOrder RPC failed",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.Error(err),
|
||
)
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
// 处理响应
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 转换响应
|
||
data := dto.ConvertCreateMintOrderResponse(resp)
|
||
|
||
// 为 material_url 生成预签名 URL(参考头像上传的方式,返回带签名的 URL)
|
||
if data != nil && data.Asset.MaterialURL != "" {
|
||
cfg := config.Load()
|
||
// 从 material_url 中提取 OSS key
|
||
materialURL := data.Asset.MaterialURL
|
||
ossKey := ctrl.extractOSSKeyFromURL(materialURL)
|
||
if ossKey != "" {
|
||
// 确保 ossKey 没有被 URL 编码(extractOSSKeyFromURL 已经处理了)
|
||
// OSS SDK 的 SignURL 会自动对路径进行 URL 编码
|
||
presignedURL, err := ctrl.generatePresignedURL(cfg.OSS, ossKey, 3600)
|
||
if err == nil {
|
||
data.Asset.MaterialURLSigned = presignedURL
|
||
urlPreview := presignedURL
|
||
if len(presignedURL) > 100 {
|
||
urlPreview = presignedURL[:100] + "..."
|
||
}
|
||
logger.Logger.Info("Generated presigned URL for material_url",
|
||
zap.String("material_url", materialURL),
|
||
zap.String("oss_key", ossKey),
|
||
zap.String("presigned_url_preview", urlPreview),
|
||
)
|
||
} else {
|
||
logger.Logger.Warn("Failed to generate presigned URL for material_url",
|
||
zap.String("material_url", materialURL),
|
||
zap.String("oss_key", ossKey),
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
} else {
|
||
logger.Logger.Warn("Failed to extract OSS key from material_url",
|
||
zap.String("material_url", materialURL),
|
||
)
|
||
}
|
||
}
|
||
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// GetMyAssets 获取我的藏品列表
|
||
// @Summary 获取我的藏品
|
||
// @Description 获取当前用户的数字藏品列表
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param page query int false "页码,默认1"
|
||
// @Param page_size query int false "每页数量,默认20"
|
||
// @Param status query string false "藏品状态"
|
||
// @Param keyword query string false "搜索关键词"
|
||
// @Param sort query string false "排序方式,默认created_at_desc"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/me/items [get]
|
||
func (ctrl *AssetController) GetMyAssets(c *gin.Context) {
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 解析查询参数
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
status := c.Query("status")
|
||
keyword := c.Query("keyword")
|
||
sort := c.DefaultQuery("sort", "created_at_desc")
|
||
|
||
// 设置上下文
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.GetMyAssets(ctx, &pbAsset.GetMyAssetsRequest{
|
||
Page: int32(page),
|
||
PageSize: int32(pageSize),
|
||
Status: status,
|
||
Keyword: keyword,
|
||
Sort: sort,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("GetMyAssets RPC failed", zap.Error(err))
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 转换响应
|
||
data := dto.ConvertGetMyAssetsResponse(resp)
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// GetMintOrder 查询铸造订单状态
|
||
// @Summary 查询铸造订单状态
|
||
// @Description 查询指定铸造订单的状态和详细信息。订单状态包括:PROCESSING(处理中)、SUCCESS(成功)、FAILED(失败)。成功时会返回关联的资产信息,包括 cover_url_signed(预签名URL)和 tx_hash(交易哈希)。
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param order_id path string true "订单ID(UUID格式)"
|
||
// @Success 200 {object} response.Response{data=dto.GetMintOrderResponseDTO} "成功返回订单和资产信息"
|
||
// @Failure 400 {object} response.Response "参数错误"
|
||
// @Failure 401 {object} response.Response "未授权"
|
||
// @Failure 404 {object} response.Response "订单不存在"
|
||
// @Router /api/v1/assets/mints/{order_id} [get]
|
||
func (ctrl *AssetController) GetMintOrder(c *gin.Context) {
|
||
// 从上下文获取用户信息
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
starID, exists := c.Get("star_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
// 获取订单ID
|
||
orderID := c.Param("order_id")
|
||
if orderID == "" {
|
||
response.Error(c, http.StatusBadRequest, "order_id不能为空")
|
||
return
|
||
}
|
||
|
||
// 设置上下文和 Dubbo attachments
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.GetMintOrder(ctx, &pbAsset.GetMintOrderRequest{
|
||
OrderId: orderID,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("GetMintOrder RPC failed",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.String("order_id", orderID),
|
||
zap.Error(err),
|
||
)
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
// 处理响应
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 转换响应,并生成预签名 URL
|
||
data := dto.ConvertGetMintOrderResponse(resp)
|
||
|
||
// 加载 OSS 配置
|
||
cfg := config.Load()
|
||
|
||
// 为资产信息生成预签名 URL(如果存在)
|
||
if data.Asset != nil {
|
||
// 生成预签名 URL 的函数
|
||
generatePresigned := func(ossURL string) (string, error) {
|
||
// 从完整 URL 中提取 OSS key
|
||
// 格式: https://bucket.oss-region.aliyuncs.com/key
|
||
ossKey := ossURL
|
||
if strings.HasPrefix(ossURL, "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(ossURL, ".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
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用 generatePresignedURL 生成预签名 URL
|
||
return ctrl.generatePresignedURL(cfg.OSS, ossKey, 3600)
|
||
}
|
||
|
||
// 为 cover_url 生成预签名 URL
|
||
if data.Asset.CoverURL != "" {
|
||
if signedURL, err := generatePresigned(data.Asset.CoverURL); err == nil {
|
||
data.Asset.CoverURLSigned = signedURL
|
||
// 同时更新订单中的 cover_url_signed(如果订单状态为 SUCCESS)
|
||
if data.Order.Status == "SUCCESS" {
|
||
data.Order.CoverURLSigned = signedURL
|
||
}
|
||
} else {
|
||
logger.Logger.Warn("生成 cover_url 预签名 URL 失败",
|
||
zap.String("cover_url", data.Asset.CoverURL),
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
}
|
||
|
||
// 为 material_url 生成预签名 URL
|
||
if data.Asset.MaterialURL != "" {
|
||
if signedURL, err := generatePresigned(data.Asset.MaterialURL); err == nil {
|
||
data.Asset.MaterialURLSigned = signedURL
|
||
} else {
|
||
logger.Logger.Warn("生成 material_url 预签名 URL 失败",
|
||
zap.String("material_url", data.Asset.MaterialURL),
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// CancelMintOrder 取消铸造订单
|
||
// @Summary 取消铸造订单
|
||
// @Description 取消指定铸造订单
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param order_id path string true "订单ID"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/mints/{order_id} [delete]
|
||
func (ctrl *AssetController) CancelMintOrder(c *gin.Context) {
|
||
// 从上下文获取用户信息
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
starID, exists := c.Get("star_id")
|
||
if !exists {
|
||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||
return
|
||
}
|
||
|
||
// 获取订单ID
|
||
orderID := c.Param("order_id")
|
||
if orderID == "" {
|
||
response.Error(c, http.StatusBadRequest, "order_id不能为空")
|
||
return
|
||
}
|
||
|
||
// 设置上下文和 Dubbo attachments
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.CancelMintOrder(ctx, &pbAsset.CancelMintOrderRequest{
|
||
OrderId: orderID,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("CancelMintOrder RPC failed",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.String("order_id", orderID),
|
||
zap.Error(err),
|
||
)
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
// 处理响应
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 返回响应
|
||
response.Success(c, gin.H{
|
||
"order_id": resp.OrderId,
|
||
"status": resp.Status,
|
||
})
|
||
}
|
||
|
||
// GetAsset 获取资产详情
|
||
// @Summary 获取资产详情
|
||
// @Description 获取指定资产的详细信息
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param asset_id path int true "资产ID"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/{asset_id} [get]
|
||
func (ctrl *AssetController) GetAsset(c *gin.Context) {
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 解析路径参数
|
||
assetIDStr := c.Param("asset_id")
|
||
assetID, err := strconv.ParseInt(assetIDStr, 10, 64)
|
||
if err != nil {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字")
|
||
return
|
||
}
|
||
|
||
// 设置上下文
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.GetAsset(ctx, &pbAsset.GetAssetRequest{
|
||
AssetId: assetID,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("GetAsset RPC failed",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.Error(err),
|
||
)
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 转换响应
|
||
data := dto.ConvertGetAssetResponse(resp)
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// GetAssetStatus 查询上链状态
|
||
// @Summary 查询上链状态
|
||
// @Description 查询指定资产的上链状态
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param asset_id path int true "资产ID"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/{asset_id}/status [get]
|
||
func (ctrl *AssetController) GetAssetStatus(c *gin.Context) {
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 解析路径参数
|
||
assetIDStr := c.Param("asset_id")
|
||
assetID, err := strconv.ParseInt(assetIDStr, 10, 64)
|
||
if err != nil {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字")
|
||
return
|
||
}
|
||
|
||
// 设置上下文
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 RPC
|
||
resp, err := ctrl.assetService.GetAssetStatus(ctx, &pbAsset.GetAssetStatusRequest{
|
||
AssetId: assetID,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("GetAssetStatus RPC failed",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.Error(err),
|
||
)
|
||
// 优先使用响应中的错误信息
|
||
if resp != nil && resp.Base != nil && resp.Base.Message != "" {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
// 解析 RPC 错误字符串
|
||
code, msg := parseRPCError(err)
|
||
response.ErrorWithCode(c, code, msg)
|
||
return
|
||
}
|
||
|
||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
// 转换响应
|
||
data := dto.ConvertGetAssetStatusResponse(resp)
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// GetOSSUploadSignature 获取 OSS 上传签名
|
||
// @Summary 获取OSS上传签名
|
||
// @Description 获取阿里云OSS上传签名和策略(阶段一:素材准备)。前端调用此接口获取上传签名后,可以直接上传图片到 OSS,获得 material_url。
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param type query string false "上传类型: avatar/asset,默认asset"
|
||
// @Param order_id query string false "订单ID(可选)。不传则后端生成,并在响应中返回,用于后续铸造全流程唯一标识"
|
||
// @Success 200 {object} response.Response{data=object{order_id=string,policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,expire_time=int}} "成功返回上传签名信息"
|
||
// @Failure 400 {object} response.Response "参数错误"
|
||
// @Failure 401 {object} response.Response "未授权"
|
||
// @Failure 500 {object} response.Response "OSS配置错误或生成签名失败"
|
||
// @Router /api/v1/assets/oss/upload-signature [get]
|
||
// @Router /api/v1/assets/oss/signature [get]
|
||
func (ctrl *AssetController) GetOSSUploadSignature(c *gin.Context) {
|
||
// 1. 从上下文获取用户信息(可选,用于生成用户特定的上传目录)
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 2. 获取上传类型参数
|
||
uploadType := c.DefaultQuery("type", "asset") // 默认为 asset
|
||
|
||
// 2.1 获取/生成 order_id(用于后续铸造全流程唯一标识)
|
||
orderID := c.Query("order_id")
|
||
if orderID == "" {
|
||
orderID = uuid.New().String()
|
||
}
|
||
|
||
// 3. 验证上传类型
|
||
if uploadType != "avatar" && uploadType != "asset" {
|
||
response.Error(c, http.StatusBadRequest, "参数错误: type 必须是 avatar 或 asset")
|
||
return
|
||
}
|
||
|
||
// 4. 加载配置
|
||
cfg := config.Load()
|
||
|
||
// 5. 验证配置
|
||
if cfg.OSS.BucketName == "" || cfg.OSS.RoleArn == "" {
|
||
response.Error(c, http.StatusInternalServerError, "OSS 配置未完成")
|
||
return
|
||
}
|
||
|
||
// 6. 生成签名
|
||
policyToken, err := ctrl.generateOSSPolicyToken(cfg.OSS, userID, starID, uploadType)
|
||
if err != nil {
|
||
logger.Logger.Error("Generate OSS signature failed",
|
||
zap.Error(err),
|
||
zap.String("upload_type", uploadType),
|
||
)
|
||
response.Error(c, http.StatusInternalServerError, "生成签名失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("OSS signature generated successfully",
|
||
zap.Any("user_id", userID),
|
||
zap.Any("star_id", starID),
|
||
zap.String("upload_type", uploadType),
|
||
zap.String("order_id", orderID),
|
||
)
|
||
|
||
// 7. 返回签名信息
|
||
policyToken["order_id"] = orderID
|
||
|
||
// 7.1 阶段一:把 order_id 落库(创建 PENDING 草稿订单,幂等)
|
||
// 说明:后续铸造接口必须携带该 order_id,服务端以 order_id 为唯一标识推进状态
|
||
if userID != nil && starID != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
if _, err := ctrl.assetService.InitMintOrder(ctx, &pbAsset.InitMintOrderRequest{OrderId: orderID}); err != nil {
|
||
logger.Logger.Error("InitMintOrder failed",
|
||
zap.String("order_id", orderID),
|
||
zap.Error(err),
|
||
)
|
||
response.Error(c, http.StatusInternalServerError, "初始化订单失败: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
response.Success(c, policyToken)
|
||
}
|
||
|
||
// generateOSSPolicyToken 生成 OSS 上传策略和签名
|
||
func (ctrl *AssetController) generateOSSPolicyToken(
|
||
ossConfig config.OSSConfig,
|
||
userID interface{},
|
||
starID interface{},
|
||
uploadType string,
|
||
) (map[string]interface{}, error) {
|
||
|
||
// 1. 创建 STS 凭证提供器
|
||
credConfig := new(credentials.Config).
|
||
SetType("ram_role_arn").
|
||
SetAccessKeyId(ossConfig.AccessKeyID).
|
||
SetAccessKeySecret(ossConfig.AccessKeySecret).
|
||
SetRoleArn(ossConfig.RoleArn).
|
||
SetRoleSessionName("topfans-upload-session").
|
||
SetPolicy("").
|
||
SetRoleSessionExpiration(ossConfig.TokenExpireTime)
|
||
|
||
provider, err := credentials.NewCredential(credConfig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建凭证提供器失败: %w", err)
|
||
}
|
||
|
||
// 2. 获取临时凭证
|
||
cred, err := provider.GetCredential()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取临时凭证失败: %w", err)
|
||
}
|
||
|
||
// 3. 构建 Policy
|
||
utcTime := time.Now().UTC()
|
||
date := utcTime.Format("20060102")
|
||
expiration := utcTime.Add(time.Duration(ossConfig.TokenExpireTime) * time.Second)
|
||
|
||
// 根据上传类型获取基础目录
|
||
baseDir := ossConfig.GetUploadDir(uploadType)
|
||
|
||
// 动态生成上传目录(基于用户ID和上传类型)
|
||
uploadDir := baseDir
|
||
if userID != nil && starID != nil {
|
||
uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
|
||
}
|
||
|
||
// 构建 Policy 条件
|
||
conditions := []interface{}{
|
||
map[string]string{"bucket": ossConfig.BucketName},
|
||
// 限制 key 必须以指定目录开头(安全限制)
|
||
[]interface{}{"starts-with", "$key", uploadDir},
|
||
map[string]string{"x-oss-signature-version": "OSS4-HMAC-SHA256"},
|
||
map[string]string{"x-oss-credential": fmt.Sprintf("%s/%s/%s/%s/aliyun_v4_request",
|
||
*cred.AccessKeyId, date, ossConfig.Region, "oss")},
|
||
map[string]string{"x-oss-date": utcTime.Format("20060102T150405Z")},
|
||
map[string]string{"x-oss-security-token": *cred.SecurityToken},
|
||
}
|
||
|
||
policyMap := map[string]interface{}{
|
||
"expiration": expiration.Format("2006-01-02T15:04:05.000Z"),
|
||
"conditions": conditions,
|
||
}
|
||
|
||
// 4. 生成 Policy 的 Base64 编码
|
||
policyJSON, err := json.Marshal(policyMap)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化 policy 失败: %w", err)
|
||
}
|
||
policyBase64 := base64.StdEncoding.EncodeToString(policyJSON)
|
||
|
||
// 5. 计算签名
|
||
signingKey := buildSigningKey(*cred.AccessKeySecret, date, ossConfig.Region, "oss")
|
||
signature := calculateSignature(signingKey, policyBase64)
|
||
|
||
// 6. 构建返回数据
|
||
host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", ossConfig.BucketName, ossConfig.Region)
|
||
|
||
return map[string]interface{}{
|
||
"policy": policyBase64,
|
||
"security_token": *cred.SecurityToken,
|
||
"x_oss_signature_version": "OSS4-HMAC-SHA256",
|
||
"x_oss_credential": fmt.Sprintf("%s/%s/%s/%s/aliyun_v4_request",
|
||
*cred.AccessKeyId, date, ossConfig.Region, "oss"),
|
||
"x_oss_date": utcTime.Format("20060102T150405Z"),
|
||
"signature": signature,
|
||
"host": host,
|
||
"dir": uploadDir,
|
||
"expire_time": expiration.Unix(),
|
||
}, nil
|
||
}
|
||
|
||
// buildSigningKey 构建签名密钥
|
||
func buildSigningKey(secret, date, region, product string) []byte {
|
||
hmacHash := func() hash.Hash { return sha256.New() }
|
||
|
||
signingKey := "aliyun_v4" + secret
|
||
h1 := hmac.New(hmacHash, []byte(signingKey))
|
||
io.WriteString(h1, date)
|
||
h1Key := h1.Sum(nil)
|
||
|
||
h2 := hmac.New(hmacHash, h1Key)
|
||
io.WriteString(h2, region)
|
||
h2Key := h2.Sum(nil)
|
||
|
||
h3 := hmac.New(hmacHash, h2Key)
|
||
io.WriteString(h3, product)
|
||
h3Key := h3.Sum(nil)
|
||
|
||
h4 := hmac.New(hmacHash, h3Key)
|
||
io.WriteString(h4, "aliyun_v4_request")
|
||
return h4.Sum(nil)
|
||
}
|
||
|
||
// calculateSignature 计算签名
|
||
func calculateSignature(signingKey []byte, stringToSign string) string {
|
||
h := hmac.New(sha256.New, signingKey)
|
||
io.WriteString(h, stringToSign)
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
// GetOSSPresignedURL 获取OSS预签名URL
|
||
// @Summary 获取OSS预签名URL
|
||
// @Description 获取OSS预签名URL用于下载文件。file_name 支持三种形式:1)纯文件名(自动拼接当前用户的路径);2)完整 OSS key(例如 asset/7/88/materials/x.png);3)完整 OSS URL(例如 https://bucket.oss-xx.aliyuncs.com/asset/...)。
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param type query string true "类型: avatar/asset"
|
||
// @Param file_name query string true "文件名或完整OSS key/URL"
|
||
// @Param expires query int false "过期时间(秒),默认3600,最大86400"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/oss/presigned-url [get]
|
||
func (ctrl *AssetController) GetOSSPresignedURL(c *gin.Context) {
|
||
// 1. 获取参数
|
||
uploadType := c.Query("type")
|
||
fileName := c.Query("file_name")
|
||
expiresStr := c.DefaultQuery("expires", "3600") // 默认1小时
|
||
|
||
if uploadType == "" {
|
||
response.BadRequest(c, "type参数不能为空(avatar 或 asset)")
|
||
return
|
||
}
|
||
|
||
if fileName == "" {
|
||
response.BadRequest(c, "file_name参数不能为空")
|
||
return
|
||
}
|
||
|
||
// 验证上传类型
|
||
if uploadType != "avatar" && uploadType != "asset" {
|
||
response.BadRequest(c, "type参数无效,必须是 avatar 或 asset")
|
||
return
|
||
}
|
||
|
||
// 2. 解析过期时间
|
||
expires, err := strconv.ParseInt(expiresStr, 10, 64)
|
||
if err != nil || expires <= 0 || expires > 86400 { // 最大24小时
|
||
response.BadRequest(c, "expires参数无效,必须是1-86400之间的数字(秒)")
|
||
return
|
||
}
|
||
|
||
// 3. 从 JWT 获取用户信息(用于默认路径)
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 4. 构建 OSS 文件路径(支持扩展:file_name 可为文件名 / OSS key / 完整 URL)
|
||
var filePath string
|
||
// 4.1 如果是完整 URL 或包含路径分隔符,视为 OSS key/URL,尝试提取真正的 key
|
||
if strings.HasPrefix(fileName, "http://") || strings.HasPrefix(fileName, "https://") || strings.Contains(fileName, "/") {
|
||
filePath = ctrl.extractOSSKeyFromURL(fileName)
|
||
if filePath == "" {
|
||
response.BadRequest(c, "file_name 无法解析为有效的 OSS key")
|
||
return
|
||
}
|
||
} else {
|
||
// 4.2 纯文件名:按原逻辑拼接当前用户的路径
|
||
if userID == nil || starID == nil {
|
||
response.BadRequest(c, "当前用户信息缺失,无法构建默认 OSS 路径,请传入完整 OSS URL 或 key")
|
||
return
|
||
}
|
||
filePath = ctrl.buildOSSKey(uploadType, userID.(int64), starID.(int64), fileName)
|
||
}
|
||
|
||
// 5. 加载配置
|
||
cfg := config.Load()
|
||
|
||
// 6. 生成预签名URL
|
||
presignedURL, err := ctrl.generatePresignedURL(cfg.OSS, filePath, expires)
|
||
if err != nil {
|
||
logger.Logger.Error("Generate presigned URL failed",
|
||
zap.String("file_path", filePath),
|
||
zap.Int64("expires", expires),
|
||
zap.Error(err),
|
||
)
|
||
response.Error(c, http.StatusInternalServerError, "生成预签名URL失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("Presigned URL generated successfully",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.String("type", uploadType),
|
||
zap.String("file_path", filePath),
|
||
zap.Int64("expires", expires),
|
||
)
|
||
|
||
// 7. 返回预签名URL
|
||
response.Success(c, gin.H{
|
||
"url": presignedURL,
|
||
"expires_in": expires,
|
||
"file_path": filePath,
|
||
})
|
||
}
|
||
|
||
// extractOSSKeyFromURL 从完整的 OSS URL 中提取 key
|
||
// 格式: https://bucket.oss-region.aliyuncs.com/key
|
||
// 例如: https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/17/88/materials/test.png
|
||
// 返回: asset/17/88/materials/test.png
|
||
// 注意:如果 URL 中包含 URL 编码的字符,需要先解码
|
||
func (ctrl *AssetController) extractOSSKeyFromURL(ossURL string) string {
|
||
if !strings.HasPrefix(ossURL, "https://") {
|
||
// 如果不是完整 URL,可能是 key 本身
|
||
return ossURL
|
||
}
|
||
|
||
// 使用 url.Parse 解析 URL(会自动处理 URL 编码)
|
||
u, err := url.Parse(ossURL)
|
||
if err != nil {
|
||
logger.Logger.Warn("Failed to parse OSS URL",
|
||
zap.String("oss_url", ossURL),
|
||
zap.Error(err),
|
||
)
|
||
// 如果解析失败,尝试手动提取
|
||
parts := strings.SplitN(ossURL, ".oss-", 2)
|
||
if len(parts) == 2 {
|
||
keyParts := strings.SplitN(parts[1], "/", 2)
|
||
if len(keyParts) == 2 {
|
||
// 尝试解码 URL 编码的字符
|
||
if decoded, err := url.QueryUnescape(keyParts[1]); err == nil {
|
||
return decoded
|
||
}
|
||
return keyParts[1]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 从 URL 路径中提取 key(url.Parse 已经自动解码了 URL 编码)
|
||
key := strings.TrimPrefix(u.Path, "/")
|
||
if key != "" {
|
||
return key
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// buildOSSKey 构建OSS对象完整路径
|
||
// 格式: {type}/{user_id}/{star_id}/{file_name}
|
||
// 例如: avatar/106/87/test.jpg
|
||
func (ctrl *AssetController) buildOSSKey(uploadType string, userID, starID int64, fileName string) string {
|
||
// 获取基础目录
|
||
cfg := config.Load()
|
||
baseDir := cfg.OSS.GetUploadDir(uploadType)
|
||
|
||
// 构建完整路径
|
||
return fmt.Sprintf("%s%d/%d/%s", baseDir, userID, starID, fileName)
|
||
}
|
||
|
||
// buildOSSPrefix 构建OSS目录前缀(用于列出文件)
|
||
// 格式: {type}/{user_id}/{star_id}/
|
||
// 例如: avatar/106/87/
|
||
func (ctrl *AssetController) buildOSSPrefix(uploadType string, userID, starID int64) string {
|
||
cfg := config.Load()
|
||
baseDir := cfg.OSS.GetUploadDir(uploadType)
|
||
return fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
|
||
}
|
||
|
||
// generatePresignedURL 使用STS临时凭证生成预签名URL
|
||
func (ctrl *AssetController) generatePresignedURL(
|
||
ossConfig config.OSSConfig,
|
||
filePath string,
|
||
expiresInSeconds int64,
|
||
) (string, error) {
|
||
// 1. 获取STS临时凭证
|
||
// 注意:STS 的 DurationSeconds 最小 15 分钟(900秒),最大 1 小时(3600秒)
|
||
// 但预签名 URL 的过期时间可以更长,由 OSS SDK 的 SignURL 方法控制
|
||
// 所以我们需要限制 STS token 的过期时间,但预签名 URL 可以使用更长的过期时间
|
||
stsExpiration := expiresInSeconds
|
||
if stsExpiration > 3600 {
|
||
stsExpiration = 3600 // STS 最大支持 1 小时
|
||
}
|
||
if stsExpiration < 900 {
|
||
stsExpiration = 900 // STS 最小支持 15 分钟
|
||
}
|
||
|
||
credConfig := new(credentials.Config).
|
||
SetType("ram_role_arn").
|
||
SetAccessKeyId(ossConfig.AccessKeyID).
|
||
SetAccessKeySecret(ossConfig.AccessKeySecret).
|
||
SetRoleArn(ossConfig.RoleArn).
|
||
SetRoleSessionName("topfans-download-session").
|
||
SetPolicy("").
|
||
SetRoleSessionExpiration(int(stsExpiration))
|
||
|
||
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)
|
||
}
|
||
|
||
// 2. 创建OSS客户端(使用临时凭证)
|
||
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", ossConfig.Region)
|
||
|
||
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
|
||
oss.SecurityToken(*cred.SecurityToken))
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建OSS客户端失败: %w", err)
|
||
}
|
||
|
||
// 3. 获取Bucket
|
||
bucket, err := client.Bucket(ossConfig.BucketName)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取Bucket失败: %w", err)
|
||
}
|
||
|
||
// 4. 生成预签名URL
|
||
signedURL, err := bucket.SignURL(filePath, oss.HTTPGet, expiresInSeconds)
|
||
if err != nil {
|
||
logger.Logger.Error("OSS SignURL failed",
|
||
zap.String("file_path", filePath),
|
||
zap.Int64("expires_in_seconds", expiresInSeconds),
|
||
zap.Error(err),
|
||
)
|
||
return "", fmt.Errorf("生成预签名URL失败: %w", err)
|
||
}
|
||
|
||
// 5. 修复 path 的 URL 编码:OSS SDK 的 buildURL 用 QueryEscape 把 / 编成 %2F,
|
||
// 导致 OSS 按字面 key "asset%2F18%2F88%2Fxxx" 查找失败(403)。只把 path 段(? 之前)的 %2F 改回 /。
|
||
if idx := strings.Index(signedURL, "?"); idx >= 0 {
|
||
signedURL = strings.ReplaceAll(signedURL[:idx], "%2F", "/") + signedURL[idx:]
|
||
} else {
|
||
signedURL = strings.ReplaceAll(signedURL, "%2F", "/")
|
||
}
|
||
|
||
// 6. 若 SDK 未把 STS 的 security-token 加入 URL,则手动追加(使用 STS 临时凭证时,预签名 URL 必须带此参数,否则 403)
|
||
if !strings.Contains(signedURL, "security-token") && cred.SecurityToken != nil && *cred.SecurityToken != "" {
|
||
signedURL = signedURL + "&security-token=" + url.QueryEscape(*cred.SecurityToken)
|
||
}
|
||
|
||
// 检查生成的预签名 URL 是否包含 security-token 参数
|
||
hasSecurityToken := strings.Contains(signedURL, "security-token")
|
||
|
||
urlPreview := signedURL
|
||
if len(signedURL) > 150 {
|
||
urlPreview = signedURL[:150] + "..."
|
||
}
|
||
|
||
tokenPreview := ""
|
||
if cred.SecurityToken != nil && *cred.SecurityToken != "" {
|
||
token := *cred.SecurityToken
|
||
if len(token) > 50 {
|
||
tokenPreview = token[:50] + "..."
|
||
} else {
|
||
tokenPreview = token
|
||
}
|
||
} else {
|
||
tokenPreview = "nil"
|
||
}
|
||
|
||
logger.Logger.Info("Presigned URL generated",
|
||
zap.String("file_path", filePath),
|
||
zap.String("signed_url_preview", urlPreview),
|
||
zap.Bool("has_security_token", hasSecurityToken),
|
||
zap.Bool("has_expires", strings.Contains(signedURL, "Expires=")),
|
||
zap.Bool("has_signature", strings.Contains(signedURL, "Signature=")),
|
||
zap.String("security_token_preview", tokenPreview),
|
||
)
|
||
|
||
return signedURL, nil
|
||
}
|
||
|
||
// GetOSSBatchPresignedURLs 批量获取OSS预签名URL(只返回图片)
|
||
// @Summary 批量获取OSS预签名URL
|
||
// @Description 批量获取OSS预签名URL用于下载多个文件
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param type query string true "类型: avatar/asset"
|
||
// @Param expires query int false "过期时间(秒),默认3600"
|
||
// @Param max_keys query int false "最大返回数量,默认100"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/oss/batch-presigned-urls [get]
|
||
func (ctrl *AssetController) GetOSSBatchPresignedURLs(c *gin.Context) {
|
||
// 1. 获取参数
|
||
uploadType := c.Query("type")
|
||
expiresStr := c.DefaultQuery("expires", "3600")
|
||
maxKeysStr := c.DefaultQuery("max_keys", "100") // 最大返回数量
|
||
|
||
if uploadType == "" {
|
||
response.BadRequest(c, "type参数不能为空(avatar 或 asset)")
|
||
return
|
||
}
|
||
|
||
// 验证上传类型
|
||
if uploadType != "avatar" && uploadType != "asset" {
|
||
response.BadRequest(c, "type参数无效,必须是 avatar 或 asset")
|
||
return
|
||
}
|
||
|
||
// 2. 解析参数
|
||
expires, err := strconv.ParseInt(expiresStr, 10, 64)
|
||
if err != nil || expires <= 0 || expires > 86400 {
|
||
response.BadRequest(c, "expires参数无效,必须是1-86400之间的数字(秒)")
|
||
return
|
||
}
|
||
|
||
maxKeys, err := strconv.Atoi(maxKeysStr)
|
||
if err != nil || maxKeys <= 0 || maxKeys > 1000 {
|
||
maxKeys = 100 // 默认值
|
||
}
|
||
|
||
// 3. 从 JWT 获取用户信息
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
// 4. 构建目录前缀
|
||
dirPrefix := ctrl.buildOSSPrefix(uploadType, userID.(int64), starID.(int64))
|
||
|
||
// 5. 加载配置
|
||
cfg := config.Load()
|
||
|
||
// 6. 列出目录下的所有图片文件
|
||
files, err := ctrl.listOSSImageFiles(cfg.OSS, dirPrefix, maxKeys)
|
||
if err != nil {
|
||
logger.Logger.Error("List OSS files failed",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.String("dir_prefix", dirPrefix),
|
||
zap.Error(err),
|
||
)
|
||
response.Error(c, http.StatusInternalServerError, "列出文件失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 7. 为每个文件生成预签名URL
|
||
type FileInfo struct {
|
||
Key string `json:"key"` // 文件路径
|
||
PresignedURL string `json:"presigned_url"` // 预签名URL
|
||
Size int64 `json:"size"` // 文件大小(字节)
|
||
LastModified string `json:"last_modified"` // 最后修改时间
|
||
}
|
||
|
||
var fileInfos []FileInfo
|
||
for _, file := range files {
|
||
presignedURL, err := ctrl.generatePresignedURL(cfg.OSS, file.Key, expires)
|
||
if err != nil {
|
||
logger.Logger.Warn("Generate presigned URL failed for file",
|
||
zap.String("file_key", file.Key),
|
||
zap.Error(err),
|
||
)
|
||
continue // 跳过生成失败的文件
|
||
}
|
||
|
||
fileInfos = append(fileInfos, FileInfo{
|
||
Key: file.Key,
|
||
PresignedURL: presignedURL,
|
||
Size: file.Size,
|
||
LastModified: file.LastModified.Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
logger.Logger.Info("Batch presigned URLs generated successfully",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.Int64("star_id", starID.(int64)),
|
||
zap.String("type", uploadType),
|
||
zap.String("dir_prefix", dirPrefix),
|
||
zap.Int("file_count", len(fileInfos)),
|
||
zap.Int64("expires", expires),
|
||
)
|
||
|
||
// 8. 返回结果
|
||
response.Success(c, gin.H{
|
||
"files": fileInfos,
|
||
"count": len(fileInfos),
|
||
"dir_path": dirPrefix,
|
||
"expires_in": expires,
|
||
})
|
||
}
|
||
|
||
// OSSFileInfo OSS文件信息
|
||
type OSSFileInfo struct {
|
||
Key string
|
||
Size int64
|
||
LastModified time.Time
|
||
}
|
||
|
||
// listOSSImageFiles 列出OSS目录下的图片文件
|
||
func (ctrl *AssetController) listOSSImageFiles(
|
||
ossConfig config.OSSConfig,
|
||
prefix string,
|
||
maxKeys int,
|
||
) ([]OSSFileInfo, error) {
|
||
// 1. 获取STS临时凭证
|
||
credConfig := new(credentials.Config).
|
||
SetType("ram_role_arn").
|
||
SetAccessKeyId(ossConfig.AccessKeyID).
|
||
SetAccessKeySecret(ossConfig.AccessKeySecret).
|
||
SetRoleArn(ossConfig.RoleArn).
|
||
SetRoleSessionName("topfans-list-session").
|
||
SetPolicy("").
|
||
SetRoleSessionExpiration(ossConfig.TokenExpireTime)
|
||
|
||
provider, err := credentials.NewCredential(credConfig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建凭证提供器失败: %w", err)
|
||
}
|
||
|
||
cred, err := provider.GetCredential()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取临时凭证失败: %w", err)
|
||
}
|
||
|
||
// 2. 创建OSS客户端
|
||
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", ossConfig.Region)
|
||
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
|
||
oss.SecurityToken(*cred.SecurityToken))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建OSS客户端失败: %w", err)
|
||
}
|
||
|
||
// 3. 获取Bucket
|
||
bucket, err := client.Bucket(ossConfig.BucketName)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取Bucket失败: %w", err)
|
||
}
|
||
|
||
// 4. 列出对象
|
||
lsRes, err := bucket.ListObjects(oss.Prefix(prefix), oss.MaxKeys(maxKeys))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列出对象失败: %w", err)
|
||
}
|
||
|
||
// 5. 定义图片文件扩展名
|
||
imageExtensions := map[string]bool{
|
||
".jpg": true,
|
||
".jpeg": true,
|
||
".png": true,
|
||
".gif": true,
|
||
".webp": true,
|
||
".bmp": true,
|
||
".svg": true,
|
||
".ico": true,
|
||
}
|
||
|
||
// 6. 过滤并返回图片文件
|
||
var files []OSSFileInfo
|
||
for _, object := range lsRes.Objects {
|
||
// 跳过目录(以 / 结尾的)
|
||
if strings.HasSuffix(object.Key, "/") {
|
||
continue
|
||
}
|
||
|
||
// 只返回图片文件
|
||
ext := strings.ToLower(filepath.Ext(object.Key))
|
||
if !imageExtensions[ext] {
|
||
continue
|
||
}
|
||
|
||
files = append(files, OSSFileInfo{
|
||
Key: object.Key,
|
||
Size: object.Size,
|
||
LastModified: object.LastModified,
|
||
})
|
||
}
|
||
|
||
return files, nil
|
||
}
|
||
|
||
// ImageGeneration 图生图(同步调用)
|
||
// @Summary 图生图
|
||
// @Description 调用 MiniMax 图生图 API
|
||
// @Tags assets
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body dto.ImageGenerationRequest true "图生图请求"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/assets/mints/image/generation [post]
|
||
func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
|
||
var req dto.ImageGenerationRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Error(c, 400, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 开发模式下使用 mock 数据
|
||
if config.Load().Server.Mode == "debug" {
|
||
mockData, err := os.ReadFile(filepath.Join(config.Load().Root, "..", "mock", "minimax.json"))
|
||
if err != nil {
|
||
response.Error(c, 500, "Failed to read mock data: "+err.Error())
|
||
return
|
||
}
|
||
var mockResult map[string]interface{}
|
||
if err := json.Unmarshal(mockData, &mockResult); err != nil {
|
||
response.Error(c, 500, "Failed to parse mock data: "+err.Error())
|
||
return
|
||
}
|
||
response.Success(c, mockResult)
|
||
return
|
||
}
|
||
|
||
result, err := ctrl.minimaxService.GenerateImage(c.Request.Context(), &req)
|
||
if err != nil {
|
||
response.Error(c, 500, "Image generation failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 将输入图片追加到图片数组末尾
|
||
if len(req.SubjectReference) > 0 && req.SubjectReference[0].ImageFile != "" {
|
||
result.Images = append(result.Images, req.SubjectReference[0].ImageFile)
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"images": result.Images,
|
||
})
|
||
}
|