diff --git a/backend/.env.example b/backend/.env.example index 26984c9..bd1be21 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -48,3 +48,6 @@ OSS_TOKEN_EXPIRE_TIME=3600 ENV=development # 日志级别: debug, info, warn, error LOG_LEVEL=info +# ==================== MiniMax API Configuration ==================== +MINIMAX_API_KEY= +MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go index a9661cd..67222b4 100644 --- a/backend/gateway/controller/asset_controller.go +++ b/backend/gateway/controller/asset_controller.go @@ -36,8 +36,8 @@ import ( // AssetController 资产相关控制器 type AssetController struct { - assetService pbAsset.AssetService - minimaxService service.MinimaxService + assetService pbAsset.AssetService + minimaxService service.MinimaxService } // NewAssetController 创建资产控制器 @@ -50,7 +50,7 @@ func NewAssetController(dubboClient *client.Client) (*AssetController, error) { return &AssetController{ assetService: assetService, - minimaxService: service.NewMinimaxService(nil), + minimaxService: service.NewMinimaxService(), }, nil } @@ -269,12 +269,12 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) { // 调用 RPC resp, err := ctrl.assetService.CreateMintOrder(ctx, &pbAsset.CreateMintOrderRequest{ - OrderId: req.OrderID, - Name: req.Name, - MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成 - Description: req.Description, - Rarity: req.Rarity, - Tags: req.Tags, + OrderId: req.OrderID, + Name: req.Name, + MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成 + Description: req.Description, + Rarity: req.Rarity, + Tags: req.Tags, MaterialType: req.MaterialType, Event: req.Event, }) @@ -304,7 +304,7 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) { // 转换响应 data := dto.ConvertCreateMintOrderResponse(resp) - + // 为 material_url 生成预签名 URL(参考头像上传的方式,返回带签名的 URL) if data != nil && data.Asset.MaterialURL != "" { cfg := config.Load() @@ -339,7 +339,7 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) { ) } } - + response.Success(c, data) } @@ -483,10 +483,10 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) { // 转换响应,并生成预签名 URL data := dto.ConvertGetMintOrderResponse(resp) - + // 加载 OSS 配置 cfg := config.Load() - + // 为资产信息生成预签名 URL(如果存在) if data.Asset != nil { // 生成预签名 URL 的函数 @@ -507,11 +507,11 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) { } } } - + // 使用 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 { @@ -527,7 +527,7 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) { ) } } - + // 为 material_url 生成预签名 URL if data.Asset.MaterialURL != "" { if signedURL, err := generatePresigned(data.Asset.MaterialURL); err == nil { @@ -540,7 +540,7 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) { } } } - + response.Success(c, data) } @@ -1052,7 +1052,7 @@ func (ctrl *AssetController) extractOSSKeyFromURL(ossURL string) string { // 如果不是完整 URL,可能是 key 本身 return ossURL } - + // 使用 url.Parse 解析 URL(会自动处理 URL 编码) u, err := url.Parse(ossURL) if err != nil { @@ -1074,13 +1074,13 @@ func (ctrl *AssetController) extractOSSKeyFromURL(ossURL string) string { } return "" } - + // 从 URL 路径中提取 key(url.Parse 已经自动解码了 URL 编码) key := strings.TrimPrefix(u.Path, "/") if key != "" { return key } - + return "" } @@ -1183,12 +1183,12 @@ func (ctrl *AssetController) generatePresignedURL( // 检查生成的预签名 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 @@ -1200,7 +1200,7 @@ func (ctrl *AssetController) generatePresignedURL( } else { tokenPreview = "nil" } - + logger.Logger.Info("Presigned URL generated", zap.String("file_path", filePath), zap.String("signed_url_preview", urlPreview), @@ -1411,15 +1411,15 @@ func (ctrl *AssetController) listOSSImageFiles( return files, nil } -// ImageGeneration 创建图生图任务 +// ImageGeneration 图生图(同步调用) // @Summary 图生图 -// @Description 创建图生图任务 +// @Description 调用 MiniMax 图生图 API // @Tags assets // @Accept json // @Produce json // @Security BearerAuth // @Param request body dto.ImageGenerationRequest true "图生图请求" -// @Success 202 {object} response.Response +// @Success 200 {object} response.Response // @Router /api/v1/assets/mints/image/generation [post] func (ctrl *AssetController) ImageGeneration(c *gin.Context) { var req dto.ImageGenerationRequest @@ -1428,63 +1428,13 @@ func (ctrl *AssetController) ImageGeneration(c *gin.Context) { return } - userID := c.GetInt64("userID") - starID := c.GetInt64("starID") - - job, err := ctrl.minimaxService.CreateJob(c.Request.Context(), userID, starID, &req) + result, err := ctrl.minimaxService.GenerateImage(c.Request.Context(), &req) if err != nil { - response.Error(c, 500, "Failed to create job: "+err.Error()) + response.Error(c, 500, "Image generation failed: "+err.Error()) return } - c.JSON(202, gin.H{"code": 202, "message": "ok", "data": &dto.ImageJobCreateResponse{ - JobID: job.JobID, - Status: string(job.Status), - CreatedAt: job.CreatedAt, - }}) -} - -// GetImageJob 查询图生图任务状态 -// @Summary 查询图生图任务 -// @Description 查询图生图任务状态和结果 -// @Tags assets -// @Produce json -// @Security BearerAuth -// @Param job_id path string true "任务ID" -// @Success 200 {object} response.Response -// @Router /api/v1/assets/mints/image/generation/{job_id} [get] -func (ctrl *AssetController) GetImageJob(c *gin.Context) { - jobID := c.Param("job_id") - if jobID == "" { - response.Error(c, 400, "job_id is required") - return - } - - userID := c.GetInt64("userID") - starID := c.GetInt64("starID") - - job, err := ctrl.minimaxService.GetJob(c.Request.Context(), jobID, userID, starID) - if err != nil { - if strings.Contains(err.Error(), "not found") { - response.Error(c, 404, "Job not found") - return - } - if strings.Contains(err.Error(), "access denied") { - response.Error(c, 403, "Access denied") - return - } - response.Error(c, 500, "Failed to get job: "+err.Error()) - return - } - - response.Success(c, &dto.ImageJobResponse{ - JobID: job.JobID, - Status: string(job.Status), - Progress: job.Progress, - Images: job.Images, - ErrorMsg: job.ErrorMsg, - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, - CompletedAt: job.CompletedAt, + response.Success(c, gin.H{ + "images": result.Images, }) } diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 38bce1e..c6c6a8c 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -174,7 +174,6 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl assets.GET("/mints/:order_id", assetCtrl.GetMintOrder) // 查询铸造订单状态 assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单 assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图 - assets.GET("/mints/image/generation/:job_id", assetCtrl.GetImageJob) // 查询任务 assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表 assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 diff --git a/backend/go.mod b/backend/go.mod index 7fdc83d..3018663 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( dubbo.apache.org/dubbo-go/v3 v3.3.1 github.com/golang-jwt/jwt/v5 v5.3.0 go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.46.0 google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b google.golang.org/protobuf v1.36.11 gorm.io/driver/postgres v1.6.0 @@ -90,7 +91,6 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3aa137b..b0fb740 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -59,8 +59,11 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alibaba/sentinel-golang v1.0.4 h1:i0wtMvNVdy7vM4DdzYrlC4r/Mpk1OKUUBurKKkWhEo8= github.com/alibaba/sentinel-golang v1.0.4/go.mod h1:Lag5rIYyJiPOylK8Kku2P+a23gdKMMqzQS7wTnjWEpk= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o= github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= github.com/aliyun/alibaba-cloud-sdk-go v1.61.18/go.mod h1:v8ESoHo4SyHmuB4b1tJqDHxfTGEciD+yhvOU/5s1Rfk= @@ -726,6 +729,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -803,6 +807,8 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -843,6 +849,8 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -889,6 +897,10 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211105192438-b53810dc28af/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -910,6 +922,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -976,18 +989,28 @@ golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211106132015-ebca88c72f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -996,6 +1019,10 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1064,6 +1091,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1201,6 +1230,7 @@ gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= diff --git a/backend/services/assetService/service/minimax_service.go b/backend/services/assetService/service/minimax_service.go index 2daf5a0..d2ef9db 100644 --- a/backend/services/assetService/service/minimax_service.go +++ b/backend/services/assetService/service/minimax_service.go @@ -3,370 +3,123 @@ package service import ( "bytes" "context" - "encoding/base64" "encoding/json" "fmt" - "image" - "image/gif" - "image/jpeg" - "image/png" "io" - "net" "net/http" - "net/url" "os" - "strings" - "sync" "time" - "github.com/google/uuid" - "github.com/nfnt/resize" - "github.com/topfans/backend/services/assetService/config" dto "github.com/topfans/backend/gateway/dto" - "go.uber.org/zap" ) -// JobStatus 任务状态 -type JobStatus string - -const ( - StatusPending JobStatus = "PENDING" - StatusProcessing JobStatus = "PROCESSING" - StatusCompleted JobStatus = "COMPLETED" - StatusFailed JobStatus = "FAILED" -) - -// ImageGenerationJob 图生图任务 -type ImageGenerationJob struct { - JobID string `json:"job_id"` - UserID int64 `json:"user_id"` - StarID int64 `json:"star_id"` - Status JobStatus `json:"status"` - Progress int `json:"progress"` - Images []string `json:"images,omitempty"` - ErrorMsg string `json:"error_msg,omitempty"` - Request *dto.ImageGenerationRequest `json:"request,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - CompletedAt int64 `json:"completed_at,omitempty"` +// ImageGenerationResponse 图生图响应 +type ImageGenerationResponse struct { + Images []string `json:"images"` } // MinimaxService MiniMax API 转发服务 type MinimaxService interface { - CreateJob(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*ImageGenerationJob, error) - GetJob(ctx context.Context, jobID string, userID, starID int64) (*ImageGenerationJob, error) + GenerateImage(ctx context.Context, req *dto.ImageGenerationRequest) (*ImageGenerationResponse, error) } type minimaxService struct { - config *config.AssetConfig - jobs map[string]*ImageGenerationJob - jobsLock sync.RWMutex - client *http.Client - stopCh chan struct{} + client *http.Client } // NewMinimaxService 创建 MiniMax 服务 -func NewMinimaxService(cfg *config.AssetConfig) MinimaxService { - svc := &minimaxService{ - config: cfg, - jobs: make(map[string]*ImageGenerationJob), - client: &http.Client{Timeout: 120 * time.Second}, - stopCh: make(chan struct{}), +func NewMinimaxService() MinimaxService { + return &minimaxService{ + client: &http.Client{Timeout: 320 * time.Second}, } - go svc.cleanupExpiredJobs() - return svc } -// CreateJob 创建图生图任务 -func (s *minimaxService) CreateJob(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*ImageGenerationJob, error) { - jobID := uuid.New().String() - now := time.Now().UnixMilli() - - job := &ImageGenerationJob{ - JobID: jobID, - UserID: userID, - StarID: starID, - Status: StatusProcessing, - Progress: 0, - Request: req, - CreatedAt: now, - UpdatedAt: now, - } - - s.jobsLock.Lock() - s.jobs[jobID] = job - s.jobsLock.Unlock() - - go s.processJob(job) - - return job, nil -} - -// GetJob 获取任务 -func (s *minimaxService) GetJob(ctx context.Context, jobID string, userID, starID int64) (*ImageGenerationJob, error) { - s.jobsLock.RLock() - job, ok := s.jobs[jobID] - s.jobsLock.RUnlock() - - if !ok { - return nil, fmt.Errorf("job not found") - } - if job.UserID != userID || job.StarID != starID { - return nil, fmt.Errorf("access denied") - } - - // Copy data to avoid race - result := *job - result.Images = make([]string, len(job.Images)) - copy(result.Images, job.Images) - return &result, nil -} - -// processJob 异步处理任务 -func (s *minimaxService) processJob(job *ImageGenerationJob) { - defer func() { - if r := recover(); r != nil { - job.Status = StatusFailed - job.ErrorMsg = fmt.Sprintf("panic: %v", r) - job.UpdatedAt = time.Now().UnixMilli() - } - }() - - // 1. 校验 SSRF - for _, ref := range job.Request.SubjectReference { - if err := validateURL(ref.ImageFile); err != nil { - job.Status = StatusFailed - job.ErrorMsg = "invalid image URL: " + err.Error() - job.UpdatedAt = time.Now().UnixMilli() - return - } - } - - // 2. 压缩图片 - processedRefs := make([]dto.SubjectReference, len(job.Request.SubjectReference)) - for i, ref := range job.Request.SubjectReference { - job.Progress = 10 + i*20 - job.UpdatedAt = time.Now().UnixMilli() - - compressed, err := s.compressImageIfNeeded(ref.ImageFile) - if err != nil { - compressed = ref.ImageFile - zap.S().Warnf("Image compression failed, using original: %v", err) - } - processedRefs[i] = dto.SubjectReference{ - Type: ref.Type, - ImageFile: compressed, - } - } - - job.Progress = 50 - job.UpdatedAt = time.Now().UnixMilli() - - // 3. 调用 MiniMax API - images, err := s.callMiniMaxAPI(job.Request.Model, job.Request.Prompt, job.Request.AspectRatio, processedRefs, job.Request.N) - if err != nil { - job.Status = StatusFailed - job.ErrorMsg = "MiniMax API failed: " + err.Error() - job.UpdatedAt = time.Now().UnixMilli() - return - } - - job.Progress = 90 - job.UpdatedAt = time.Now().UnixMilli() - - // 4. 完成 - job.Status = StatusCompleted - job.Progress = 100 - job.Images = images - job.CompletedAt = time.Now().UnixMilli() - job.UpdatedAt = time.Now().UnixMilli() -} - -// callMiniMaxAPI 调用 MiniMax API -func (s *minimaxService) callMiniMaxAPI(model, prompt, aspectRatio string, refs []dto.SubjectReference, n int) ([]string, error) { +// GenerateImage 调用 MiniMax 图生图 API(同步调用) +func (s *minimaxService) GenerateImage(ctx context.Context, req *dto.ImageGenerationRequest) (*ImageGenerationResponse, error) { apiURL := os.Getenv("MINIMAX_API_URL") apiKey := os.Getenv("MINIMAX_API_KEY") payload := map[string]interface{}{ - "model": model, - "prompt": prompt, - "aspect_ratio": aspectRatio, - "subject_reference": refs, - "n": n, + "model": req.Model, + "prompt": req.Prompt, + "aspect_ratio": req.AspectRatio, + "subject_reference": req.SubjectReference, + "n": req.N, + "response_format": "base64", } jsonData, err := json.Marshal(payload) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Content-Type", "application/json") - resp, err := s.client.Do(req) + resp, err := s.client.Do(httpReq) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to call MiniMax API: %w", err) } defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("MiniMax API returned status %d: %s", resp.StatusCode, string(body)) } - var result struct { - Images []struct { - URL string `json:"url"` - } `json:"images"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + var rawResp map[string]interface{} + if err := json.Unmarshal(body, &rawResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) } - images := make([]string, len(result.Images)) - for i, img := range result.Images { - images[i] = img.URL - } - return images, nil -} + var images []string -// compressImageIfNeeded 下载并压缩图片 -func (s *minimaxService) compressImageIfNeeded(imageURL string) (string, error) { - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Get(imageURL) - if err != nil { - return "", err - } - defer resp.Body.Close() - - imgData, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit - if err != nil { - return "", err + // MiniMax API 响应格式: + // {"id": "...", "data": {"image_base64": ["..."]}, "base_resp": {"status_code": 0, "status_msg": "success"}} + data, ok := rawResp["data"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid MiniMax response format: missing data field: %s", string(body)) } - // First decode config to check dimensions (prevents decompression bomb) - cfg, format, err := image.DecodeConfig(bytes.NewReader(imgData)) - if err != nil { - return "", err - } - // Limit decoded image to reasonable size (e.g., 4096x4096 = 64M pixels) - if int64(cfg.Width)*int64(cfg.Height) > 4096*4096 { - return "", fmt.Errorf("image too large: %dx%d", cfg.Width, cfg.Height) - } - // Full decode (already limited by size) - img, _, err := image.Decode(bytes.NewReader(imgData)) - if err != nil { - return "", err - } - _ = format // suppress unused warning - - bounds := img.Bounds() - maxDim := uint(1024) - newWidth := uint(bounds.Dx()) - newHeight := uint(bounds.Dy()) - - if newWidth > maxDim || newHeight > maxDim { - if newWidth > newHeight { - ratio := float64(maxDim) / float64(newWidth) - newWidth = maxDim - newHeight = uint(float64(newHeight) * ratio) - } else { - ratio := float64(maxDim) / float64(newHeight) - newHeight = maxDim - newWidth = uint(float64(newWidth) * ratio) + // 检查 base_resp 状态码 + if baseResp, ok := rawResp["base_resp"].(map[string]interface{}); ok { + if statusCode, ok := baseResp["status_code"].(float64); ok && statusCode != 0 { + statusMsg, _ := baseResp["status_msg"].(string) + return nil, fmt.Errorf("MiniMax API error: code=%d, msg=%s", int(statusCode), statusMsg) } } - if newWidth == uint(bounds.Dx()) && newHeight == uint(bounds.Dy()) { - return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgData), nil - } - - resized := resize.Thumbnail(newWidth, newHeight, img, resize.Lanczos3) - - var buf bytes.Buffer - switch format { - case "png": - err = png.Encode(&buf, resized) - case "gif": - err = gif.Encode(&buf, resized, nil) - default: - err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 85}) - } - if err != nil { - return "", err - } - - encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) - mimeType := "image/jpeg" - if format == "png" { - mimeType = "image/png" - } else if format == "gif" { - mimeType = "image/gif" - } - return "data:" + mimeType + ";base64," + encoded, nil -} - -// validateURL 校验 URL 防止 SSRF -func validateURL(rawURL string) error { - if rawURL == "" { - return nil - } - u, err := url.Parse(rawURL) - if err != nil { - return err - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("unsupported scheme: %s", u.Scheme) - } - host := u.Hostname() - - ip := net.ParseIP(host) - if ip != nil { - if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() { - return fmt.Errorf("private IP not allowed: %s", host) + // 优先使用 base64 格式(当 response_format=base64 时) + if base64Imgs, ok := data["image_base64"].([]interface{}); ok { + for _, img := range base64Imgs { + if base64Str, ok := img.(string); ok && base64Str != "" { + images = append(images, "data:image/jpeg;base64,"+base64Str) + } } - return nil } - lowerHost := strings.ToLower(host) - if strings.HasSuffix(lowerHost, ".local") || - strings.HasSuffix(lowerHost, ".internal") || - strings.HasSuffix(lowerHost, ".private") { - return fmt.Errorf("internal domain not allowed: %s", host) - } - - return nil -} - -// cleanupExpiredJobs 清理过期任务 -func (s *minimaxService) cleanupExpiredJobs() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - for { - select { - case <-s.stopCh: - return - case <-ticker.C: - s.jobsLock.Lock() - now := time.Now().UnixMilli() - expiredThreshold := int64(24 * 60 * 60 * 1000) // 24h - for jobID, job := range s.jobs { - if job.Status == StatusCompleted || job.Status == StatusFailed { - if now-job.UpdatedAt > expiredThreshold { - delete(s.jobs, jobID) - } + // 回退使用 image_urls 格式 + if len(images) == 0 { + if urlImgs, ok := data["image_urls"].([]interface{}); ok { + for _, img := range urlImgs { + if urlStr, ok := img.(string); ok && urlStr != "" { + images = append(images, urlStr) } } - s.jobsLock.Unlock() } } -} -// Close 关闭服务 -func (s *minimaxService) Close() { - close(s.stopCh) -} \ No newline at end of file + if len(images) == 0 { + return nil, fmt.Errorf("no images found in MiniMax response: %s", string(body)) + } + + return &ImageGenerationResponse{Images: images}, nil +} diff --git a/docs/MiniMax 图生图 API 集成方案.md b/docs/MiniMax 图生图 API 集成方案.md new file mode 100644 index 0000000..39727ae --- /dev/null +++ b/docs/MiniMax 图生图 API 集成方案.md @@ -0,0 +1,382 @@ +# MiniMax 图生图 API 集成方案 + +## 一、需求概述 + +前端传递参数 → 后端调用 MiniMax 图生图 API → 后端返回结果给前端 + +## 二、现有项目架构 + +| 层级 | 技术栈 | 说明 | +|------|--------|------| +| 前端 | uni-app (Vue 3) | 使用 `uni.request` 发起请求 | +| 后端 | Go + Gin | API Gateway 模式,端口 8080 | +| 配置 | `.env` 文件 | API keys 等敏感配置 | + +## 三、MiniMax API 信息 + +**接口地址**: `POST https://api.minimaxi.com/v1/image_generation` + +**请求头**: +```json +{ + "Authorization": "Bearer ", + "Content-Type": "application/json" +} +``` + +**请求体**: +```json +{ + "model": "image-01", + "prompt": "描述文本", + "aspect_ratio": "16:9", + "subject_reference": [ + { + "type": "character", + "image_file": "https://..." + } + ], + "n": 2 +} +``` + +## 四、后端设计方案 + +### 4.1 配置文件新增 (.env) + +```bash +# MiniMax API 配置 +MINIMAX_API_KEY=your_api_key_here +MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation +``` + +### 4.2 新增 DTO (gateway/dto/image_dto.go) + +```go +// MiniMax 图生图请求 +type ImageGenerationRequest struct { + Model string `json:"model" binding:"required"` + Prompt string `json:"prompt" binding:"required"` + AspectRatio string `json:"aspect_ratio"` + SubjectReference []SubjectReference `json:"subject_reference"` + N int `json:"n"` +} + +type SubjectReference struct { + Type string `json:"type"` + ImageFile string `json:"image_file"` +} + +// MiniMax 图生图响应 +type ImageGenerationResponse struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + AspectRatio string `json:"aspect_ratio"` + Images []Image `json:"images"` +} + +type Image struct { + URL string `json:"url"` +} +``` + +### 4.3 新增 Controller (gateway/controller/image_controller.go) + +```go +// ImageGeneration 图生图 - 调用 MiniMax API +// @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 parameters: "+err.Error()) + return + } + + // 从 context 获取 userID 和 starID(由认证中间件设置) + userID := c.GetInt64("userID") + starID := c.GetInt64("starID") + + // 调用资产服务的 MiniMax 转发服务 + result, err := ctrl.assetService.CallMiniMaxImageAPI(c.Request.Context(), userID, starID, &req) + if err != nil { + response.Error(c, 500, "Image generation failed: "+err.Error()) + return + } + + response.Success(c, result) +} +``` + +### 4.4 新增 Service (assetService/service/minimax_service.go) + +```go +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "net/http" + "time" + + "github.com/nfnt/resize" + "github.com/topfans/backend/services/assetService/config" + dto "github.com/topfans/backend/gateway/dto" + "go.uber.org/zap" +) + +// MinimaxService MiniMax API 转发服务接口 +type MinimaxService interface { + // CallMiniMaxImageAPI 调用 MiniMax 图生图 API + CallMiniMaxImageAPI(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*dto.ImageGenerationResponse, error) +} + +// minimaxService MiniMax API 转发服务实现 +type minimaxService struct { + config *config.AssetConfig +} + +// NewMinimaxService 创建 MiniMax 服务实例 +func NewMinimaxService(cfg *config.AssetConfig) MinimaxService { + return &minimaxService{config: cfg} +} + +// CallMiniMaxImageAPI 调用 MiniMax 图生图 API +func (s *minimaxService) CallMiniMaxImageAPI(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*dto.ImageGenerationResponse, error) { + // 1. 压缩 subject_reference 中的图片 + processedRefs := make([]dto.SubjectReference, len(req.SubjectReference)) + for i, ref := range req.SubjectReference { + compressedURL, err := s.compressImageIfNeeded(ref.ImageFile) + if err != nil { + // 压缩失败时使用原图 + compressedURL = ref.ImageFile + zap.S().Warnf("Image compression failed, using original: %v", err) + } + processedRefs[i] = dto.SubjectReference{ + Type: ref.Type, + ImageFile: compressedURL, + } + } + + // 2. 构建请求体 + payload := map[string]interface{}{ + "model": req.Model, + "prompt": req.Prompt, + "aspect_ratio": req.AspectRatio, + "subject_reference": processedRefs, + "n": req.N, + } + + // 3. 发送 HTTP POST 请求到 MiniMax + apiURL := s.config.GetMiniMaxAPIURL() + apiKey := s.config.GetMiniMaxAPIKey() + + client := &http.Client{Timeout: 120 * time.Second} + jsonData, _ := json.Marshal(payload) + + httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call MiniMax API: %w", err) + } + defer resp.Body.Close() + + // 4. 解析响应 + var result dto.ImageGenerationResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +// compressImageIfNeeded 下载图片、压缩后返回 base64 编码 +func (s *minimaxService) compressImageIfNeeded(imageURL string) (string, error) { + // 下载图片 + resp, err := http.Get(imageURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // 读取图片数据 + imgData, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // 解码图片(自动识别格式) + img, format, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return "", err + } + + // 计算压缩后的尺寸(最大边 1024px,保持宽高比) + bounds := img.Bounds() + maxDim := uint(1024) + newWidth := uint(bounds.Dx()) + newHeight := uint(bounds.Dy()) + + if newWidth > maxDim || newHeight > maxDim { + if newWidth > newHeight { + ratio := float64(maxDim) / float64(newWidth) + newWidth = maxDim + newHeight = uint(float64(newHeight) * ratio) + } else { + ratio := float64(maxDim) / float64(newHeight) + newHeight = maxDim + newWidth = uint(float64(newWidth) * ratio) + } + } + + // 如果图片尺寸没变,不压缩直接返回原图 + if newWidth == uint(bounds.Dx()) && newHeight == uint(bounds.Dy()) { + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgData), nil + } + + // 缩放图片(Lanczos 算法,质量好) + resized := resize.Thumbnail(newWidth, newHeight, img, resize.Lanczos) + + // 重新编码为 JPEG(质量 85%,体积小) + var buf bytes.Buffer + switch format { + case "png": + err = png.Encode(&buf, resized) + case "gif": + err = gif.Encode(&buf, resized, nil) + default: + err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 85}) + } + if err != nil { + return "", err + } + + // 返回 base64 编码(data URI 格式) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + mimeType := "image/jpeg" + if format == "png" { + mimeType = "image/png" + } else if format == "gif" { + mimeType = "image/gif" + } + return "data:" + mimeType + ";base64," + encoded, nil +} +``` + +### 4.5 路由注册 (gateway/router/router.go) + +在 assets 路由组内添加: + +```go +// 资产相关路由(需要认证) +assets := v1.Group("/assets") +assets.Use(middleware.AuthMiddleware()) +{ + // ... 现有路由 ... + assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图(MiniMax API) +} +``` + +## 五、前端调用方案 + +**架构说明**:前端只与后端 API 通信,不直接调用 MiniMax。后端作为代理调用 MiniMax。 + +### 5.1 前端 API (frontend/utils/api.js) - 已存在 + +```javascript +// 图生图 API - 前端传递必要参数,后端调用 MiniMax +export function imageGenerationApi(params) { + return request({ + url: '/api/v1/assets/mints/image/generation', + method: 'POST', + data: params + }) +} +``` + +### 5.2 前端调用示例 + +```javascript +// 前端只需要传递这些参数,具体的 MiniMax API 调用由后端完成 +imageGenerationApi({ + prompt: '描述文字', + aspect_ratio: '16:9', + subject_reference: [ + { + type: 'character', + image_file: '用户选择的图片URL' + } + ], + n: 2 +}).then(res => { + // res.data.images 是 MiniMax 返回的图片 URL 列表 + console.log('生成的图片:', res.data.images) +}) +``` + +### 5.3 数据流向 + +``` +前端参数 {prompt, aspect_ratio, subject_reference, n} + ↓ POST /api/v1/assets/mints/image/generation +后端接收参数 → 调用 assetService 的 MiniMax 转发服务 → 调用 MiniMax API + ↓ +后端获取响应 → 直接透传 images 数组 → 返回给前端 + ↓ +前端收到 {code: 200, data: {images: [...]}} +``` + +## 六、文件修改清单 + +| 操作 | 文件路径 | 说明 | +|------|----------|------| +| 修改 | `backend/.env` | 添加 MiniMax 配置 | +| 新增 | `backend/gateway/dto/image_dto.go` | 请求/响应 DTO | +| 新增 | `backend/services/assetService/service/minimax_service.go` | MiniMax API 转发 + 图片压缩 | +| 新增 | `backend/services/assetService/config/minimax_config.go` | MiniMax 配置读取 | +| 修改 | `backend/gateway/controller/asset_controller.go` | 新增 ImageGeneration 处理器 | +| 修改 | `backend/gateway/router/router.go` | 注册 `/mints/image/generation` 路由 | +| 修改 | `frontend/utils/api.js` | 已存在,无需修改 | + +### 依赖安装 +```bash +cd backend/services/assetService && go get github.com/nfnt/resize +``` + +## 七、验证方案 + +1. **后端启动**: 启动 `backend/gateway/main.go` 和 `backend/services/assetService/main.go` +2. **接口测试**: 使用 curl 或 Postman 测试接口 + ```bash + curl -X POST http://localhost:8080/api/v1/assets/mints/image/generation \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"image-01","prompt":"test","aspect_ratio":"16:9","n":1,"subject_reference":[]}' + ``` +3. **前端测试**: 在页面中调用 `imageGenerationApi()` 并展示返回的图片 + +## 八、已确认事项 + +- 图片直接返回 MiniMax 的 URL,不经过 OSS +- subject_reference.image_file 由前端自定义传递给后端(用户选择的自定义图片) +- 转发服务位于 `assetService` 中,不在 Gateway diff --git a/frontend/pages/discover/generation-loading.vue b/frontend/pages/discover/generation-loading.vue index 7a58311..b208f95 100644 --- a/frontend/pages/discover/generation-loading.vue +++ b/frontend/pages/discover/generation-loading.vue @@ -91,107 +91,32 @@ const revertProgress = () => { }, 100); }; -// job_id 轮询 -let jobId = null; - -// 调用图生图API - 异步模式 +// 调用图生图API - 同步模式 const callImageGeneration = async () => { - try { - const res = await imageGenerationApi(generationData); + try { + const res = await imageGenerationApi(generationData); - if (res.data && res.data.job_id) { - jobId = res.data.job_id; - console.log('[GenerationLoading] 任务已创建:', jobId); - // 开始轮询 - pollJobStatus(); - } else { - uni.showToast({ - title: '创建任务失败', - icon: 'none', - duration: 2000 - }); - revertProgress(); - } - } catch (err) { - console.error('[GenerationLoading] 创建任务失败:', err); - uni.showToast({ - title: '创建任务失败', - icon: 'none', - duration: 2000 - }); - revertProgress(); - } -}; - -// 轮询任务状态 -const pollJobStatus = () => { - let pollCount = 0; - const maxPolls = 40; // 120秒 / 3秒 = 40次 - - const poll = async () => { - if (!jobId) return; - - try { - const res = await uni.request({ - url: 'http://192.168.110.60:8080' + `/api/v1/assets/mints/image/generation/${jobId}`, - method: 'GET', - header: { - 'Authorization': `Bearer ${uni.getStorageSync('access_token')}` - } - }); - - const data = res.data?.data; - if (data) { - // 更新进度 - if (data.progress) { - progress.value = Math.min(data.progress, 90); - } - - if (data.status === 'COMPLETED') { - // 完成 - const imageUrls = data.images || []; - if (imageUrls.length > 0) { - uni.setStorageSync('generated_images', JSON.stringify(imageUrls)); - completeProgress(); - } else { - uni.showToast({ title: '未生成图片', icon: 'none' }); - revertProgress(); - } - return; - } else if (data.status === 'FAILED') { - // 失败 - uni.showToast({ - title: data.error_msg || '生成失败', - icon: 'none', - duration: 2000 - }); - revertProgress(); - return; - } - } - - pollCount++; - if (pollCount >= maxPolls) { - uni.showToast({ title: '生成超时,请重试', icon: 'none' }); - revertProgress(); - return; - } - - // 3秒后继续轮询 - setTimeout(poll, 3000); - } catch (err) { - console.error('[GenerationLoading] 轮询失败:', err); - pollCount++; - if (pollCount >= maxPolls) { - uni.showToast({ title: '查询失败,请重试', icon: 'none' }); - revertProgress(); - } else { - setTimeout(poll, 3000); - } - } - }; - - poll(); + if (res.data && res.data.images && res.data.images.length > 0) { + // 保存生成的图片 + uni.setStorageSync('generated_images', JSON.stringify(res.data.images)); + completeProgress(); + } else { + uni.showToast({ + title: '未生成图片', + icon: 'none', + duration: 2000 + }); + revertProgress(); + } + } catch (err) { + console.error('[GenerationLoading] 生成失败:', err); + uni.showToast({ + title: err.message || '生成失败', + icon: 'none', + duration: 2000 + }); + revertProgress(); + } }; // 处理成功 @@ -201,7 +126,7 @@ const handleSuccess = () => { icon: 'success', duration: 1500 }); - + // 跳转到结果页面 setTimeout(() => { uni.redirectTo({ @@ -224,10 +149,10 @@ onMounted(() => { }, 1500); return; } - + // 解析请求数据 generationData = JSON.parse(requestDataStr); - + // 从castlove_form_data获取base64图片 const castloveDataStr = uni.getStorageSync('castlove_form_data'); if (castloveDataStr) { @@ -237,15 +162,15 @@ onMounted(() => { generationData.subject_reference[0].image_file = castloveData.imageBase64; } } - + // 清除generation_request_data存储,避免重复使用 uni.removeStorageSync('generation_request_data'); - + console.log('[GenerationLoading] 接收到生成数据'); - + // 开始模拟进度 simulateProgress(); - + // 调用API callImageGeneration(); } catch (e) { diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 7c9b1fa..fcfa403 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -456,6 +456,7 @@ export function imageGenerationApi(params) { data: params }) } + // 获取热度排行榜 export function getHotRankingApi(dimension = 'total', starId = null, page = 1, pageSize = 10) { let url = `/api/v1/rankings/hot?dimension=${dimension}&page=${page}&page_size=${pageSize}`