topfans/backend/gateway/controller/asset_controller.go
2026-04-08 01:30:58 +08:00

1441 lines
44 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 controller
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"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,
Rarity: req.Rarity,
Tags: req.Tags,
MaterialType: req.MaterialType,
Event: req.Event,
})
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 "订单IDUUID格式"
// @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.png3完整 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 路径中提取 keyurl.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
}
result, err := ctrl.minimaxService.GenerateImage(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, "Image generation failed: "+err.Error())
return
}
response.Success(c, gin.H{
"images": result.Images,
})
}