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" "github.com/topfans/backend/gateway/repository" "github.com/topfans/backend/pkg/database" 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/pkg/models" pbGallery "github.com/topfans/backend/pkg/proto/gallery" pbUser "github.com/topfans/backend/pkg/proto/user" "github.com/topfans/backend/services/assetService/service" ) // AssetController 资产相关控制器 type AssetController struct { assetService pbAsset.AssetService userService pbUser.UserSocialService galleryService pbGallery.GalleryService minimaxService service.MinimaxService laserRepo *repository.LaserCardRepository } // NewAssetController 创建资产控制器 func NewAssetController(assetClient *client.Client, userClient *client.Client, galleryClient *client.Client) (*AssetController, error) { ctrl := &AssetController{laserRepo: repository.NewLaserCardRepository(database.GetDB())} // 创建 AssetService 客户端 assetService, err := pbAsset.NewAssetService(assetClient) if err != nil { return nil, err } // 创建 UserService 客户端 userService, err := pbUser.NewUserSocialService(userClient) if err != nil { return nil, err } // 创建 GalleryService 客户端 galleryService, err := pbGallery.NewGalleryService(galleryClient) if err != nil { return nil, err } return &AssetController{ assetService: assetService, userService: userService, galleryService: galleryService, minimaxService: service.NewMinimaxService(), laserRepo: ctrl.laserRepo, }, 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) } // EstimateMintCost 估算铸造费用 // @Summary 估算铸造费用 // @Description 在用户确认铸造前,显示本次铸造消耗水晶数和当前余额 // @Tags assets // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response // @Router /api/v1/assets/mints/cost-estimate [post] func (ctrl *AssetController) EstimateMintCost(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 } 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.EstimateMintCost(ctx, &pbAsset.EstimateMintCostRequest{}) if err != nil { code, msg := parseRPCError(err) response.ErrorWithCode(c, code, msg) return } data := map[string]interface{}{ "cost_crystal": resp.CostCrystal, "current_balance": resp.CurrentBalance, "balance_after": resp.BalanceAfter, "mint_count": resp.MintCount, "next_tier_cost": resp.NextTierCost, } 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), ) } } // 镭射卡铸造:通过 instance_no 查找并更新实例状态 if req.InstanceNo != "" && ctrl.laserRepo != nil { inst, err := ctrl.laserRepo.FindByInstanceNo(req.InstanceNo) if err == nil { var compositeMaterialID *int64 asset := resp.GetAsset() if asset != nil { assetID := asset.GetAssetId() _ = ctrl.laserRepo.SetMintResult(inst.ID, assetID, req.OrderID, compositeMaterialID) _ = ctrl.laserRepo.CreateOperationLogSimple( inst.ID, inst.InstanceNo, userID.(int64), models.LaserCardActionMintSuccess, models.LaserCardInstanceStatusMinting, models.LaserCardInstanceStatusMinted, ) logger.Logger.Info("Laser card instance minted", zap.Int64("instance_id", inst.ID), zap.String("instance_no", inst.InstanceNo), zap.Int64("asset_id", assetID), ) } } } 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,key=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) } // GetPublicOSSUploadSignature 公开版 OSS 上传签名(用于注册等未登录场景的头像上传) // @Summary 获取公开 OSS 上传签名(注册流程用) // @Description 用于注册等未登录场景下上传头像,无需鉴权。 // @Description 后端生成唯一完整 key,policy 锁到该 key,前端只能写到指定路径;key 形如 avatar/register-pending/{key}/avatar_.png。 // @Tags assets // @Accept json // @Produce json // @Param scene query string true "场景,目前固定为 register" // @Param key query string true "前端传入的命名空间(注册时传 mobile),仅允许 [a-zA-Z0-9_-],最长 32 字符" // @Success 200 {object} response.Response{data=object{policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,key=string,expire_time=int}} "成功返回上传签名信息" // @Failure 400 {object} response.Response "参数错误" // @Failure 500 {object} response.Response "OSS配置错误或生成签名失败" // @Router /api/v1/public/oss/upload-signature [get] func (ctrl *AssetController) GetPublicOSSUploadSignature(c *gin.Context) { scene := c.Query("scene") key := c.Query("key") if scene != "register" { response.Error(c, http.StatusBadRequest, "参数错误: scene 仅支持 register") return } if !isValidNamespaceKey(key) { response.Error(c, http.StatusBadRequest, "参数错误: key 仅允许字母数字下划线中划线,最长 32 字符") return } cfg := config.Load() if cfg.OSS.BucketName == "" || cfg.OSS.RoleArn == "" { response.Error(c, http.StatusInternalServerError, "OSS 配置未完成") return } // 强制使用 avatar 目录,并将 key 限定到 avatar/register-pending/{key}/ 子目录 // 后端生成唯一文件名,policy 用 eq 锁到完整 key uploadDir := fmt.Sprintf("%sregister-pending/%s/", cfg.OSS.AvatarDir, key) uniqueFilename := fmt.Sprintf("avatar_%s.png", uuid.New().String()) token, err := ctrl.generateOSSPolicyTokenWithKey(cfg.OSS, uploadDir+uniqueFilename) if err != nil { logger.Logger.Error("Generate public OSS signature failed", zap.Error(err), zap.String("scene", scene), zap.String("key", key), ) response.Error(c, http.StatusInternalServerError, "生成签名失败: "+err.Error()) return } logger.Logger.Info("Public OSS signature generated", zap.String("scene", scene), zap.String("key", key), ) response.Success(c, token) } // isValidNamespaceKey 校验公开上传的命名空间 key(仅允许字母数字下划线中划线) func isValidNamespaceKey(key string) bool { if key == "" || len(key) > 32 { return false } for _, r := range key { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r >= '0' && r <= '9': case r == '_' || r == '-': default: return false } } return true } // generateOSSPolicyToken 生成 OSS 上传策略和签名 func (ctrl *AssetController) generateOSSPolicyToken( ossConfig config.OSSConfig, userID interface{}, starID interface{}, uploadType string, ) (map[string]interface{}, error) { // 根据上传类型获取基础目录 baseDir := ossConfig.GetUploadDir(uploadType) // 动态生成上传目录(基于用户ID和上传类型) uploadDir := baseDir if userID != nil && starID != nil { uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID) } <<<<<<< HEAD utcTime := time.Now().UTC() date := utcTime.Format("20060102") expiration := utcTime.Add(time.Duration(ossConfig.TokenExpireTime) * time.Second) baseDir := ossConfig.GetUploadDir(uploadType) uploadDir := baseDir if userID != nil && starID != nil { uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID) } var accessKeyID, accessKeySecret, securityToken string useSTS := false // 尝试 STS AssumeRole,失败则降级到永久 AccessKey 直连 ======= // 头像上传:后端生成唯一 key,避免 // 1) URL 字符串相同导致 webview/CDN 命中老图缓存 // 2) 并发上传时两路相互覆盖 // 3) 上传中途失败留下半截文件 // policy 用 eq 锁到该 key,前端只能写到指定路径 if uploadType == "avatar" { uniqueFilename := fmt.Sprintf("avatar_%s.png", uuid.New().String()) return ctrl.generateOSSPolicyTokenWithKey(ossConfig, uploadDir+uniqueFilename) } return ctrl.generateOSSPolicyTokenWithDir(ossConfig, uploadDir) } // dirFromKey 从完整 OSS key 提取目录(含末尾 /) func dirFromKey(key string) string { idx := strings.LastIndex(key, "/") if idx < 0 { return "" } return key[:idx+1] } // generateOSSPolicyTokenWithKey 按显式完整 key(OSS 对象路径)生成 OSS 上传策略和签名 // 与 WithDir 区别:policy 用 eq 锁到该 key(更严),返回值带 key 供前端直接使用 // 适用场景:头像上传——后端控制完整 key,URL 天然每次唯一 func (ctrl *AssetController) generateOSSPolicyTokenWithKey( ossConfig config.OSSConfig, uploadKey string, ) (map[string]interface{}, error) { // 1. 创建 STS 凭证提供器 >>>>>>> 0284bd6951fafc8a3862644ede979dc44eb6efe8 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) if provider, err := credentials.NewCredential(credConfig); err == nil { if cred, err := provider.GetCredential(); err == nil { accessKeyID = *cred.AccessKeyId accessKeySecret = *cred.AccessKeySecret securityToken = *cred.SecurityToken useSTS = true } else { logger.Logger.Warn("STS AssumeRole failed, falling back to direct AK/SK", zap.Error(err)) } } else { logger.Logger.Warn("STS credential provider failed, falling back to direct AK/SK", zap.Error(err)) } <<<<<<< HEAD if !useSTS { accessKeyID = ossConfig.AccessKeyID accessKeySecret = ossConfig.AccessKeySecret ======= // 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) // 构建 Policy 条件 conditions := []interface{}{ map[string]string{"bucket": ossConfig.BucketName}, // 限制 key 必须等于指定完整 key(更严:前端只能写到该路径) []interface{}{"eq", "$key", uploadKey}, 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}, >>>>>>> 0284bd6951fafc8a3862644ede979dc44eb6efe8 } 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": dirFromKey(uploadKey), "key": uploadKey, "expire_time": expiration.Unix(), }, nil } // generateOSSPolicyTokenWithDir 按显式目录生成 OSS 上传策略和签名 // (用于公开场景等需要把 key 限定到非 userID/starID 命名空间的场景) func (ctrl *AssetController) generateOSSPolicyTokenWithDir( ossConfig config.OSSConfig, uploadDir 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) // 构建 Policy 条件 conditions := []interface{}{ map[string]string{"bucket": ossConfig.BucketName}, []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", accessKeyID, date, ossConfig.Region, "oss")}, map[string]string{"x-oss-date": utcTime.Format("20060102T150405Z")}, } if useSTS && securityToken != "" { conditions = append(conditions, map[string]string{"x-oss-security-token": securityToken}) } policyMap := map[string]interface{}{ "expiration": expiration.Format("2006-01-02T15:04:05.000Z"), "conditions": conditions, } policyJSON, err := json.Marshal(policyMap) if err != nil { return nil, fmt.Errorf("序列化 policy 失败: %w", err) } policyBase64 := base64.StdEncoding.EncodeToString(policyJSON) signingKey := buildSigningKey(accessKeySecret, date, ossConfig.Region, "oss") signature := calculateSignature(signingKey, policyBase64) host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", ossConfig.BucketName, ossConfig.Region) result := map[string]interface{}{ "policy": policyBase64, "x_oss_signature_version": "OSS4-HMAC-SHA256", "x_oss_credential": fmt.Sprintf("%s/%s/%s/%s/aliyun_v4_request", accessKeyID, date, ossConfig.Region, "oss"), "x_oss_date": utcTime.Format("20060102T150405Z"), "signature": signature, "host": host, "dir": uploadDir, "expire_time": expiration.Unix(), } if useSTS && securityToken != "" { result["security_token"] = securityToken } return result, 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 生成预签名URL(优先 STS,失败降级到永久 AK/SK 直连) func (ctrl *AssetController) generatePresignedURL( ossConfig config.OSSConfig, filePath string, expiresInSeconds int64, ) (string, error) { // 尝试 STS AssumeRole,失败则降级到永久 AccessKey 直连 var accessKeyID, accessKeySecret, securityToken string useSTS := false credConfig := new(credentials.Config). SetType("ram_role_arn"). SetAccessKeyId(ossConfig.AccessKeyID). SetAccessKeySecret(ossConfig.AccessKeySecret). SetRoleArn(ossConfig.RoleArn). SetRoleSessionName("topfans-download-session"). SetPolicy(""). SetRoleSessionExpiration(3600) if provider, err := credentials.NewCredential(credConfig); err == nil { if cred, err := provider.GetCredential(); err == nil { accessKeyID = *cred.AccessKeyId accessKeySecret = *cred.AccessKeySecret securityToken = *cred.SecurityToken useSTS = true } else { logger.Logger.Warn("STS AssumeRole failed for presigned URL, falling back to direct AK/SK", zap.Error(err)) } } else { logger.Logger.Warn("STS credential provider failed for presigned URL, falling back to direct AK/SK", zap.Error(err)) } if !useSTS { accessKeyID = ossConfig.AccessKeyID accessKeySecret = ossConfig.AccessKeySecret } // 创建OSS客户端 endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", ossConfig.Region) var client *oss.Client var err error if useSTS && securityToken != "" { client, err = oss.New(endpoint, accessKeyID, accessKeySecret, oss.SecurityToken(securityToken)) } else { client, err = oss.New(endpoint, accessKeyID, accessKeySecret) } if err != nil { return "", fmt.Errorf("创建OSS客户端失败: %w", err) } // 获取Bucket bucket, err := client.Bucket(ossConfig.BucketName) if err != nil { return "", fmt.Errorf("获取Bucket失败: %w", err) } // 生成预签名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) } // 修复 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", "/") } // 若 SDK 未把 STS 的 security-token 加入 URL,则手动追加(使用 STS 临时凭证时,预签名 URL 必须带此参数,否则 403) if useSTS && securityToken != "" && !strings.Contains(signedURL, "security-token") { signedURL = signedURL + "&security-token=" + url.QueryEscape(securityToken) } // 检查生成的预签名 URL 是否包含 security-token 参数 hasSecurityToken := strings.Contains(signedURL, "security-token") urlPreview := signedURL if len(signedURL) > 150 { urlPreview = signedURL[:150] + "..." } tokenPreview := "" if securityToken != "" { token := 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, }) } // UploadMaterial 上传素材 func (ctrl *AssetController) UploadMaterial(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.UploadMaterialRequestDTO 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), }) w := int32(0) h := int32(0) if req.Width != nil { w = int32(*req.Width) } if req.Height != nil { h = int32(*req.Height) } resp, err := ctrl.assetService.UploadMaterial(ctx, &pbAsset.UploadMaterialRequest{ OssKey: req.OssKey, OriginalName: req.OriginalName, FileSize: req.FileSize, MimeType: req.MimeType, Width: w, Height: h, Hash: req.Hash, MaterialType: req.MaterialType, }) if err != nil { logger.Logger.Error("UploadMaterial RPC failed", zap.Error(err)) 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.ConvertMaterial(resp.Material) response.Success(c, data) } // BindAssetMaterials 绑定资产素材 func (ctrl *AssetController) BindAssetMaterials(c *gin.Context) { assetIDStr := c.Param("asset_id") assetID, err := strconv.ParseInt(assetIDStr, 10, 64) if err != nil { response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") return } userIDVal, exists := c.Get("user_id") if !exists { response.Error(c, http.StatusUnauthorized, "未授权") return } uid, _ := userIDVal.(int64) starIDVal, _ := c.Get("star_id") sid, _ := starIDVal.(int64) _ = uid _ = sid // suppress unused var req dto.BindAssetMaterialsRequestDTO 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(uid, 10), "star_id": strconv.FormatInt(sid, 10), }) materials := make([]*pbAsset.AssetMaterialRelation, 0, len(req.Materials)) for _, item := range req.Materials { m := &pbAsset.AssetMaterialRelation{ AssetId: assetID, MaterialId: item.MaterialID, MaterialType: item.MaterialType, LayerOrder: item.LayerOrder, } if item.PosX != nil { m.PosX = *item.PosX } if item.PosY != nil { m.PosY = *item.PosY } if item.Opacity != nil { m.Opacity = *item.Opacity } if item.Rotation != nil { m.Rotation = *item.Rotation } if item.ScaleX != nil { m.ScaleX = *item.ScaleX } if item.ScaleY != nil { m.ScaleY = *item.ScaleY } materials = append(materials, m) } resp, err := ctrl.assetService.BindAssetMaterials(ctx, &pbAsset.BindAssetMaterialsRequest{ AssetId: assetID, Materials: materials, }) if err != nil { logger.Logger.Error("BindAssetMaterials RPC failed", zap.Error(err)) 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{"message": "关联成功"}) } // GetAssetMaterials 获取资产素材列表 func (ctrl *AssetController) GetAssetMaterials(c *gin.Context) { assetIDStr := c.Param("asset_id") assetID, err := strconv.ParseInt(assetIDStr, 10, 64) if err != nil { response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") return } if _, exists := c.Get("user_id"); !exists { response.Error(c, http.StatusUnauthorized, "未授权") return } userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // 设置 Dubbo attachments 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.GetAssetMaterials(ctx, &pbAsset.GetAssetMaterialsRequest{ AssetId: assetID, }) if err != nil { logger.Logger.Error("GetAssetMaterials RPC failed", zap.Error(err)) 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 := make([]dto.AssetMaterialRelationDTO, 0, len(resp.Materials)) for _, m := range resp.Materials { data = append(data, dto.ConvertAssetMaterialRelation(m)) } response.Success(c, data) } // UpdateMaterialLayerOrder 更新图层顺序 func (ctrl *AssetController) UpdateMaterialLayerOrder(c *gin.Context) { assetIDStr := c.Param("asset_id") assetID, err := strconv.ParseInt(assetIDStr, 10, 64) if err != nil { response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字") return } if _, exists := c.Get("user_id"); !exists { response.Error(c, http.StatusUnauthorized, "未授权") return } var req dto.UpdateLayerOrderRequestDTO 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() orders := make([]*pbAsset.MaterialLayerOrderItem, 0, len(req.Orders)) for _, o := range req.Orders { orders = append(orders, &pbAsset.MaterialLayerOrderItem{ RelationId: o.RelationID, LayerOrder: o.LayerOrder, }) } resp, err := ctrl.assetService.UpdateMaterialLayerOrder(ctx, &pbAsset.UpdateMaterialLayerOrderRequest{ AssetId: assetID, Orders: orders, }) if err != nil { logger.Logger.Error("UpdateMaterialLayerOrder RPC failed", zap.Error(err)) 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{"message": "更新成功"}) } // UnbindAssetMaterial 解绑资产素材 func (ctrl *AssetController) UnbindAssetMaterial(c *gin.Context) { relationIDStr := c.Param("relation_id") relationID, err := strconv.ParseInt(relationIDStr, 10, 64) if err != nil { response.Error(c, http.StatusBadRequest, "参数错误: relation_id 必须为数字") return } if _, exists := c.Get("user_id"); !exists { response.Error(c, http.StatusUnauthorized, "未授权") return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp, err := ctrl.assetService.UnbindAssetMaterial(ctx, &pbAsset.UnbindAssetMaterialRequest{ RelationId: relationID, }) if err != nil { logger.Logger.Error("UnbindAssetMaterial RPC failed", zap.Error(err)) 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{"message": "解绑成功"}) } // GetEarningsSummary 获取收益汇总 // @Summary 获取收益汇总 // @Description 获取当前用户的收益汇总(总每小时收益、总展出收益、水晶余额) // @Tags assets // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response // @Router /api/v1/assets/me/earnings-summary [get] func (ctrl *AssetController) GetEarningsSummary(c *gin.Context) { userIDVal, _ := c.Get("user_id") starIDVal, _ := c.Get("star_id") userID, ok := userIDVal.(int64) if !ok { logger.Logger.Error("GetEarningsSummary: user_id type assertion failed", zap.Any("value", userIDVal)) response.Error(c, http.StatusInternalServerError, "用户ID无效") return } starID, ok := starIDVal.(int64) if !ok { logger.Logger.Error("GetEarningsSummary: star_id type assertion failed", zap.Any("value", starIDVal)) response.Error(c, http.StatusInternalServerError, "明星ID无效") return } logger.Logger.Info("GetEarningsSummary: start", zap.Int64("user_id", userID), zap.Int64("star_id", starID)) 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, 10), "star_id": strconv.FormatInt(starID, 10), }) // 1. 获取我展出的作品列表 var totalHourlyEarnings float64 var totalExhibitionRevenue int64 page := int32(1) pageSize := int32(200) for { resp, err := ctrl.galleryService.GetMyExhibitedAssets(ctx, &pbGallery.GetMyExhibitedAssetsRequest{ Page: page, PageSize: pageSize, }) if err != nil { logger.Logger.Error("GetEarningsSummary: GetMyExhibitedAssets failed", zap.Error(err)) response.Error(c, http.StatusInternalServerError, "获取展出作品列表失败") return } if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { logger.Logger.Warn("GetEarningsSummary: GetMyExhibitedAssets returned non-OK", zap.Any("code", resp.Base.Code), zap.String("msg", resp.Base.Message)) } // 累加每个 item 的收益(GetMyExhibitedAssets 返回的都是展出中的作品) for _, item := range resp.Data.Items { hourlyEarnings := item.HourlyEarnings totalHourlyEarnings += hourlyEarnings totalExhibitionRevenue += item.Earnings } // 检查是否还有更多 if !resp.Data.HasMore { break } page++ } logger.Logger.Info("GetEarningsSummary: assets fetched", zap.Float64("total_hourly", totalHourlyEarnings), zap.Int64("total_revenue", totalExhibitionRevenue)) // 2. 获取水晶余额 logger.Logger.Info("GetEarningsSummary: calling GetFanProfile", zap.Int64("user_id", userID), zap.Int64("star_id", starID)) userResp, err := ctrl.userService.GetFanProfile(ctx, &pbUser.GetFanProfileRequest{ UserId: userID, StarId: starID, }) if err != nil { logger.Logger.Error("GetEarningsSummary: GetFanProfile failed", zap.Error(err), zap.Int64("user_id", userID), zap.Int64("star_id", starID)) response.Error(c, http.StatusInternalServerError, "获取用户信息失败") return } logger.Logger.Info("GetEarningsSummary: success", zap.Float64("total_hourly", totalHourlyEarnings), zap.Int64("total_revenue", totalExhibitionRevenue), zap.Int64("balance", userResp.GetProfile().GetCrystalBalance())) response.Success(c, gin.H{ "total_hourly_earnings": totalHourlyEarnings, "total_exhibition_revenue": totalExhibitionRevenue, "crystal_balance": userResp.GetProfile().GetCrystalBalance(), }) }