feat: 完善minimax调用图生图功能

This commit is contained in:
zerosaturation 2026-04-08 01:29:00 +08:00
parent 6a0f4cc26a
commit baf56d5ecf
9 changed files with 539 additions and 496 deletions

View File

@ -48,3 +48,6 @@ OSS_TOKEN_EXPIRE_TIME=3600
ENV=development ENV=development
# 日志级别: debug, info, warn, error # 日志级别: debug, info, warn, error
LOG_LEVEL=info LOG_LEVEL=info
# ==================== MiniMax API Configuration ====================
MINIMAX_API_KEY=
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation

View File

@ -50,7 +50,7 @@ func NewAssetController(dubboClient *client.Client) (*AssetController, error) {
return &AssetController{ return &AssetController{
assetService: assetService, assetService: assetService,
minimaxService: service.NewMinimaxService(nil), minimaxService: service.NewMinimaxService(),
}, nil }, nil
} }
@ -1411,15 +1411,15 @@ func (ctrl *AssetController) listOSSImageFiles(
return files, nil return files, nil
} }
// ImageGeneration 创建图生图任务 // ImageGeneration 图生图(同步调用)
// @Summary 图生图 // @Summary 图生图
// @Description 创建图生图任务 // @Description 调用 MiniMax 图生图 API
// @Tags assets // @Tags assets
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param request body dto.ImageGenerationRequest true "图生图请求" // @Param request body dto.ImageGenerationRequest true "图生图请求"
// @Success 202 {object} response.Response // @Success 200 {object} response.Response
// @Router /api/v1/assets/mints/image/generation [post] // @Router /api/v1/assets/mints/image/generation [post]
func (ctrl *AssetController) ImageGeneration(c *gin.Context) { func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
var req dto.ImageGenerationRequest var req dto.ImageGenerationRequest
@ -1428,63 +1428,13 @@ func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
return return
} }
userID := c.GetInt64("userID") result, err := ctrl.minimaxService.GenerateImage(c.Request.Context(), &req)
starID := c.GetInt64("starID")
job, err := ctrl.minimaxService.CreateJob(c.Request.Context(), userID, starID, &req)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to create job: "+err.Error()) response.Error(c, 500, "Image generation failed: "+err.Error())
return return
} }
c.JSON(202, gin.H{"code": 202, "message": "ok", "data": &dto.ImageJobCreateResponse{ response.Success(c, gin.H{
JobID: job.JobID, "images": result.Images,
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,
}) })
} }

View File

@ -174,7 +174,6 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
assets.GET("/mints/:order_id", assetCtrl.GetMintOrder) // 查询铸造订单状态 assets.GET("/mints/:order_id", assetCtrl.GetMintOrder) // 查询铸造订单状态
assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单 assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单
assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图 assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图
assets.GET("/mints/image/generation/:job_id", assetCtrl.GetImageJob) // 查询任务
assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表 assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表
assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情
assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态

View File

@ -6,6 +6,7 @@ require (
dubbo.apache.org/dubbo-go/v3 v3.3.1 dubbo.apache.org/dubbo-go/v3 v3.3.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
go.uber.org/zap v1.27.1 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/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@ -90,7 +91,6 @@ require (
go.uber.org/atomic v1.10.0 // indirect go.uber.org/atomic v1.10.0 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.10.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/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect

View File

@ -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/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 h1:i0wtMvNVdy7vM4DdzYrlC4r/Mpk1OKUUBurKKkWhEo8=
github.com/alibaba/sentinel-golang v1.0.4/go.mod h1:Lag5rIYyJiPOylK8Kku2P+a23gdKMMqzQS7wTnjWEpk= 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 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 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 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o=
github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= 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= 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.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/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.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 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 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-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-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-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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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.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 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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= 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-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-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-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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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= 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-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-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.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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= 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-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-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-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-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-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-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-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-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-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-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.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.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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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-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.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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/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.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 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 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= 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.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/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.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 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 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= 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.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.2/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 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.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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=

View File

@ -3,370 +3,123 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io" "io"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings"
"sync"
"time" "time"
"github.com/google/uuid"
"github.com/nfnt/resize"
"github.com/topfans/backend/services/assetService/config"
dto "github.com/topfans/backend/gateway/dto" dto "github.com/topfans/backend/gateway/dto"
"go.uber.org/zap"
) )
// JobStatus 任务状态 // ImageGenerationResponse 图生图响应
type JobStatus string type ImageGenerationResponse struct {
Images []string `json:"images"`
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"`
} }
// MinimaxService MiniMax API 转发服务 // MinimaxService MiniMax API 转发服务
type MinimaxService interface { type MinimaxService interface {
CreateJob(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*ImageGenerationJob, error) GenerateImage(ctx context.Context, req *dto.ImageGenerationRequest) (*ImageGenerationResponse, error)
GetJob(ctx context.Context, jobID string, userID, starID int64) (*ImageGenerationJob, error)
} }
type minimaxService struct { type minimaxService struct {
config *config.AssetConfig
jobs map[string]*ImageGenerationJob
jobsLock sync.RWMutex
client *http.Client client *http.Client
stopCh chan struct{}
} }
// NewMinimaxService 创建 MiniMax 服务 // NewMinimaxService 创建 MiniMax 服务
func NewMinimaxService(cfg *config.AssetConfig) MinimaxService { func NewMinimaxService() MinimaxService {
svc := &minimaxService{ return &minimaxService{
config: cfg, client: &http.Client{Timeout: 320 * time.Second},
jobs: make(map[string]*ImageGenerationJob),
client: &http.Client{Timeout: 120 * time.Second},
stopCh: make(chan struct{}),
}
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. 压缩图片 // GenerateImage 调用 MiniMax 图生图 API同步调用
processedRefs := make([]dto.SubjectReference, len(job.Request.SubjectReference)) func (s *minimaxService) GenerateImage(ctx context.Context, req *dto.ImageGenerationRequest) (*ImageGenerationResponse, error) {
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) {
apiURL := os.Getenv("MINIMAX_API_URL") apiURL := os.Getenv("MINIMAX_API_URL")
apiKey := os.Getenv("MINIMAX_API_KEY") apiKey := os.Getenv("MINIMAX_API_KEY")
payload := map[string]interface{}{ payload := map[string]interface{}{
"model": model, "model": req.Model,
"prompt": prompt, "prompt": req.Prompt,
"aspect_ratio": aspectRatio, "aspect_ratio": req.AspectRatio,
"subject_reference": refs, "subject_reference": req.SubjectReference,
"n": n, "n": req.N,
"response_format": "base64",
} }
jsonData, err := json.Marshal(payload) jsonData, err := json.Marshal(payload)
if err != nil { 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 { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+apiKey) httpReq.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req) resp, err := s.client.Do(httpReq)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to call MiniMax API: %w", err)
} }
defer resp.Body.Close() 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 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("MiniMax API returned status %d: %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
} }
var result struct { var rawResp map[string]interface{}
Images []struct { if err := json.Unmarshal(body, &rawResp); err != nil {
URL string `json:"url"` return nil, fmt.Errorf("failed to parse response: %w", err)
} `json:"images"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
} }
images := make([]string, len(result.Images)) var images []string
for i, img := range result.Images {
images[i] = img.URL // MiniMax API 响应格式:
} // {"id": "...", "data": {"image_base64": ["..."]}, "base_resp": {"status_code": 0, "status_msg": "success"}}
return images, nil data, ok := rawResp["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid MiniMax response format: missing data field: %s", string(body))
} }
// compressImageIfNeeded 下载并压缩图片 // 检查 base_resp 状态码
func (s *minimaxService) compressImageIfNeeded(imageURL string) (string, error) { if baseResp, ok := rawResp["base_resp"].(map[string]interface{}); ok {
client := &http.Client{Timeout: 30 * time.Second} if statusCode, ok := baseResp["status_code"].(float64); ok && statusCode != 0 {
resp, err := client.Get(imageURL) statusMsg, _ := baseResp["status_msg"].(string)
if err != nil { return nil, fmt.Errorf("MiniMax API error: code=%d, msg=%s", int(statusCode), statusMsg)
return "", err
}
defer resp.Body.Close()
imgData, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
if err != nil {
return "", err
}
// 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)
} }
} }
if newWidth == uint(bounds.Dx()) && newHeight == uint(bounds.Dy()) { // 优先使用 base64 格式(当 response_format=base64 时)
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgData), nil if base64Imgs, ok := data["image_base64"].([]interface{}); ok {
} for _, img := range base64Imgs {
if base64Str, ok := img.(string); ok && base64Str != "" {
resized := resize.Thumbnail(newWidth, newHeight, img, resize.Lanczos3) images = append(images, "data:image/jpeg;base64,"+base64Str)
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)
}
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)
}
}
}
s.jobsLock.Unlock()
} }
} }
} }
// Close 关闭服务 // 回退使用 image_urls 格式
func (s *minimaxService) Close() { if len(images) == 0 {
close(s.stopCh) if urlImgs, ok := data["image_urls"].([]interface{}); ok {
for _, img := range urlImgs {
if urlStr, ok := img.(string); ok && urlStr != "" {
images = append(images, urlStr)
}
}
}
}
if len(images) == 0 {
return nil, fmt.Errorf("no images found in MiniMax response: %s", string(body))
}
return &ImageGenerationResponse{Images: images}, nil
} }

View File

@ -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 <token>",
"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 <token>" \
-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

View File

@ -91,107 +91,32 @@ const revertProgress = () => {
}, 100); }, 100);
}; };
// job_id // API -
let jobId = null;
// API -
const callImageGeneration = async () => { const callImageGeneration = async () => {
try { try {
const res = await imageGenerationApi(generationData); const res = await imageGenerationApi(generationData);
if (res.data && res.data.job_id) { if (res.data && res.data.images && res.data.images.length > 0) {
jobId = res.data.job_id; //
console.log('[GenerationLoading] 任务已创建:', jobId); uni.setStorageSync('generated_images', JSON.stringify(res.data.images));
//
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(); completeProgress();
} else { } else {
uni.showToast({ title: '未生成图片', icon: 'none' });
revertProgress();
}
return;
} else if (data.status === 'FAILED') {
//
uni.showToast({ uni.showToast({
title: data.error_msg || '生成失败', title: '未生成图片',
icon: 'none', icon: 'none',
duration: 2000 duration: 2000
}); });
revertProgress(); revertProgress();
return;
} }
}
pollCount++;
if (pollCount >= maxPolls) {
uni.showToast({ title: '生成超时,请重试', icon: 'none' });
revertProgress();
return;
}
// 3
setTimeout(poll, 3000);
} catch (err) { } catch (err) {
console.error('[GenerationLoading] 轮询失败:', err); console.error('[GenerationLoading] 生成失败:', err);
pollCount++; uni.showToast({
if (pollCount >= maxPolls) { title: err.message || '生成失败',
uni.showToast({ title: '查询失败,请重试', icon: 'none' }); icon: 'none',
duration: 2000
});
revertProgress(); revertProgress();
} else {
setTimeout(poll, 3000);
} }
}
};
poll();
}; };
// //

View File

@ -456,6 +456,7 @@ export function imageGenerationApi(params) {
data: params data: params
}) })
} }
// 获取热度排行榜 // 获取热度排行榜
export function getHotRankingApi(dimension = 'total', starId = null, page = 1, pageSize = 10) { export function getHotRankingApi(dimension = 'total', starId = null, page = 1, pageSize = 10) {
let url = `/api/v1/rankings/hot?dimension=${dimension}&page=${page}&page_size=${pageSize}` let url = `/api/v1/rankings/hot?dimension=${dimension}&page=${page}&page_size=${pageSize}`