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, }) }