Compare commits
15 Commits
867fe27a43
...
baf56d5ecf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf56d5ecf | ||
|
|
6a0f4cc26a | ||
|
|
575233b51f | ||
|
|
965c37f2ff | ||
|
|
e834029f6a | ||
|
|
40d5e597f6 | ||
|
|
76a5eaaad9 | ||
|
|
f43b06e13d | ||
|
|
d09f1122ce | ||
|
|
ed2acc3268 | ||
|
|
8ec1942e74 | ||
|
|
4bae8e9b64 | ||
|
|
9ca072b463 | ||
|
|
286db74837 | ||
|
|
cf7af5cfa5 |
@ -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
|
||||||
|
|||||||
@ -31,11 +31,13 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"github.com/topfans/backend/services/assetService/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AssetController 资产相关控制器
|
// AssetController 资产相关控制器
|
||||||
type AssetController struct {
|
type AssetController struct {
|
||||||
assetService pbAsset.AssetService
|
assetService pbAsset.AssetService
|
||||||
|
minimaxService service.MinimaxService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAssetController 创建资产控制器
|
// NewAssetController 创建资产控制器
|
||||||
@ -47,7 +49,8 @@ func NewAssetController(dubboClient *client.Client) (*AssetController, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &AssetController{
|
return &AssetController{
|
||||||
assetService: assetService,
|
assetService: assetService,
|
||||||
|
minimaxService: service.NewMinimaxService(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,12 +269,12 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) {
|
|||||||
|
|
||||||
// 调用 RPC
|
// 调用 RPC
|
||||||
resp, err := ctrl.assetService.CreateMintOrder(ctx, &pbAsset.CreateMintOrderRequest{
|
resp, err := ctrl.assetService.CreateMintOrder(ctx, &pbAsset.CreateMintOrderRequest{
|
||||||
OrderId: req.OrderID,
|
OrderId: req.OrderID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成
|
MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Rarity: req.Rarity,
|
Rarity: req.Rarity,
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
MaterialType: req.MaterialType,
|
MaterialType: req.MaterialType,
|
||||||
Event: req.Event,
|
Event: req.Event,
|
||||||
})
|
})
|
||||||
@ -301,7 +304,7 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) {
|
|||||||
|
|
||||||
// 转换响应
|
// 转换响应
|
||||||
data := dto.ConvertCreateMintOrderResponse(resp)
|
data := dto.ConvertCreateMintOrderResponse(resp)
|
||||||
|
|
||||||
// 为 material_url 生成预签名 URL(参考头像上传的方式,返回带签名的 URL)
|
// 为 material_url 生成预签名 URL(参考头像上传的方式,返回带签名的 URL)
|
||||||
if data != nil && data.Asset.MaterialURL != "" {
|
if data != nil && data.Asset.MaterialURL != "" {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
@ -336,7 +339,7 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, data)
|
response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -480,10 +483,10 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) {
|
|||||||
|
|
||||||
// 转换响应,并生成预签名 URL
|
// 转换响应,并生成预签名 URL
|
||||||
data := dto.ConvertGetMintOrderResponse(resp)
|
data := dto.ConvertGetMintOrderResponse(resp)
|
||||||
|
|
||||||
// 加载 OSS 配置
|
// 加载 OSS 配置
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// 为资产信息生成预签名 URL(如果存在)
|
// 为资产信息生成预签名 URL(如果存在)
|
||||||
if data.Asset != nil {
|
if data.Asset != nil {
|
||||||
// 生成预签名 URL 的函数
|
// 生成预签名 URL 的函数
|
||||||
@ -504,11 +507,11 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 generatePresignedURL 生成预签名 URL
|
// 使用 generatePresignedURL 生成预签名 URL
|
||||||
return ctrl.generatePresignedURL(cfg.OSS, ossKey, 3600)
|
return ctrl.generatePresignedURL(cfg.OSS, ossKey, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为 cover_url 生成预签名 URL
|
// 为 cover_url 生成预签名 URL
|
||||||
if data.Asset.CoverURL != "" {
|
if data.Asset.CoverURL != "" {
|
||||||
if signedURL, err := generatePresigned(data.Asset.CoverURL); err == nil {
|
if signedURL, err := generatePresigned(data.Asset.CoverURL); err == nil {
|
||||||
@ -524,7 +527,7 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为 material_url 生成预签名 URL
|
// 为 material_url 生成预签名 URL
|
||||||
if data.Asset.MaterialURL != "" {
|
if data.Asset.MaterialURL != "" {
|
||||||
if signedURL, err := generatePresigned(data.Asset.MaterialURL); err == nil {
|
if signedURL, err := generatePresigned(data.Asset.MaterialURL); err == nil {
|
||||||
@ -537,7 +540,7 @@ func (ctrl *AssetController) GetMintOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, data)
|
response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1049,7 +1052,7 @@ func (ctrl *AssetController) extractOSSKeyFromURL(ossURL string) string {
|
|||||||
// 如果不是完整 URL,可能是 key 本身
|
// 如果不是完整 URL,可能是 key 本身
|
||||||
return ossURL
|
return ossURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 url.Parse 解析 URL(会自动处理 URL 编码)
|
// 使用 url.Parse 解析 URL(会自动处理 URL 编码)
|
||||||
u, err := url.Parse(ossURL)
|
u, err := url.Parse(ossURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1071,13 +1074,13 @@ func (ctrl *AssetController) extractOSSKeyFromURL(ossURL string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 URL 路径中提取 key(url.Parse 已经自动解码了 URL 编码)
|
// 从 URL 路径中提取 key(url.Parse 已经自动解码了 URL 编码)
|
||||||
key := strings.TrimPrefix(u.Path, "/")
|
key := strings.TrimPrefix(u.Path, "/")
|
||||||
if key != "" {
|
if key != "" {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1180,12 +1183,12 @@ func (ctrl *AssetController) generatePresignedURL(
|
|||||||
|
|
||||||
// 检查生成的预签名 URL 是否包含 security-token 参数
|
// 检查生成的预签名 URL 是否包含 security-token 参数
|
||||||
hasSecurityToken := strings.Contains(signedURL, "security-token")
|
hasSecurityToken := strings.Contains(signedURL, "security-token")
|
||||||
|
|
||||||
urlPreview := signedURL
|
urlPreview := signedURL
|
||||||
if len(signedURL) > 150 {
|
if len(signedURL) > 150 {
|
||||||
urlPreview = signedURL[:150] + "..."
|
urlPreview = signedURL[:150] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenPreview := ""
|
tokenPreview := ""
|
||||||
if cred.SecurityToken != nil && *cred.SecurityToken != "" {
|
if cred.SecurityToken != nil && *cred.SecurityToken != "" {
|
||||||
token := *cred.SecurityToken
|
token := *cred.SecurityToken
|
||||||
@ -1197,7 +1200,7 @@ func (ctrl *AssetController) generatePresignedURL(
|
|||||||
} else {
|
} else {
|
||||||
tokenPreview = "nil"
|
tokenPreview = "nil"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Logger.Info("Presigned URL generated",
|
logger.Logger.Info("Presigned URL generated",
|
||||||
zap.String("file_path", filePath),
|
zap.String("file_path", filePath),
|
||||||
zap.String("signed_url_preview", urlPreview),
|
zap.String("signed_url_preview", urlPreview),
|
||||||
@ -1407,3 +1410,31 @@ func (ctrl *AssetController) listOSSImageFiles(
|
|||||||
|
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImageGeneration 图生图(同步调用)
|
||||||
|
// @Summary 图生图
|
||||||
|
// @Description 调用 MiniMax 图生图 API
|
||||||
|
// @Tags assets
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param request body dto.ImageGenerationRequest true "图生图请求"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /api/v1/assets/mints/image/generation [post]
|
||||||
|
func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
|
||||||
|
var req dto.ImageGenerationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Error(c, 400, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctrl.minimaxService.GenerateImage(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, 500, "Image generation failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"images": result.Images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
34
backend/gateway/dto/image_dto.go
Normal file
34
backend/gateway/dto/image_dto.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ImageGenerationRequest 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"` // 1-4
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectReference struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ImageFile string `json:"image_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageJobResponse 图生图任务响应
|
||||||
|
type ImageJobResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ErrorMsg string `json:"error_msg,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageJobCreateResponse 创建任务响应
|
||||||
|
type ImageJobCreateResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
@ -173,6 +173,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单
|
assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单
|
||||||
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.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) // 查询上链状态
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
@ -91,6 +91,7 @@ require (
|
|||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/nacos-group/nacos-sdk-go/v2 v2.2.5 // indirect
|
github.com/nacos-group/nacos-sdk-go/v2 v2.2.5 // indirect
|
||||||
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
|
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 // indirect
|
github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.11.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.11.0 // indirect
|
||||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||||
|
|||||||
@ -621,6 +621,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
|
|||||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||||
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
|
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
|
||||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||||
|
|||||||
125
backend/services/assetService/service/minimax_service.go
Normal file
125
backend/services/assetService/service/minimax_service.go
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dto "github.com/topfans/backend/gateway/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageGenerationResponse 图生图响应
|
||||||
|
type ImageGenerationResponse struct {
|
||||||
|
Images []string `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimaxService MiniMax API 转发服务
|
||||||
|
type MinimaxService interface {
|
||||||
|
GenerateImage(ctx context.Context, req *dto.ImageGenerationRequest) (*ImageGenerationResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type minimaxService struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMinimaxService 创建 MiniMax 服务
|
||||||
|
func NewMinimaxService() MinimaxService {
|
||||||
|
return &minimaxService{
|
||||||
|
client: &http.Client{Timeout: 320 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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": 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, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := s.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
return nil, fmt.Errorf("MiniMax API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawResp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &rawResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []string
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退使用 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(images) == 0 {
|
||||||
|
return nil, fmt.Errorf("no images found in MiniMax response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageGenerationResponse{Images: images}, nil
|
||||||
|
}
|
||||||
382
docs/MiniMax 图生图 API 集成方案.md
Normal file
382
docs/MiniMax 图生图 API 集成方案.md
Normal 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
|
||||||
@ -0,0 +1,765 @@
|
|||||||
|
# MiniMax 图生图 API 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 实现异步图生图 API,前端轮询 job 状态,后端调用 MiniMax 生成图片
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- Gateway 接收 HTTP 请求,创建 job 返回 job_id
|
||||||
|
- assetService 异步调用 MiniMax API,完成后更新 job 状态
|
||||||
|
- 前端轮询查询 job 状态,COMPLETED 后展示结果
|
||||||
|
|
||||||
|
**Tech Stack:** Go (Gin, Dubbo RPC), MiniMax API, uni-app (Vue 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── gateway/
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ └── image_dto.go # 新增: ImageGenerationRequest, ImageJobResponse
|
||||||
|
│ ├── controller/
|
||||||
|
│ │ └── asset_controller.go # 修改: 添加 ImageGeneration, GetImageJob
|
||||||
|
│ └── router/
|
||||||
|
│ └── router.go # 修改: 注册 /mints/image/generation 路由
|
||||||
|
└── services/assetService/
|
||||||
|
└── service/
|
||||||
|
└── minimax_service.go # 新增: 任务管理 + MiniMax API 调用 + 图片压缩
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
└── pages/discover/
|
||||||
|
└── generation-loading.vue # 修改: 改为轮询模式
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 创建 DTO 文件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/gateway/dto/image_dto.go`
|
||||||
|
- Test: (无独立测试,随 controller 测试)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 DTO 文件**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package dto
|
||||||
|
|
||||||
|
// ImageGenerationRequest 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"` // MiniMax API 为数组
|
||||||
|
N int `json:"n"` // 1-4
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectReference struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ImageFile string `json:"image_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageJobResponse 图生图任务响应
|
||||||
|
type ImageJobResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ErrorMsg string `json:"error_msg,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageJobCreateResponse 创建任务响应
|
||||||
|
type ImageJobCreateResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/gateway/dto/image_dto.go
|
||||||
|
git commit -m "feat: 添加图生图 DTO"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 创建 MiniMax Service (任务管理 + API 调用)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/services/assetService/service/minimax_service.go`
|
||||||
|
- Modify: `backend/services/assetService/go.mod` (添加依赖)
|
||||||
|
- Test: (无独立测试)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加依赖**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/services/assetService && go get github.com/nfnt/resize
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 minimax_service.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
type minimaxService struct {
|
||||||
|
config *config.AssetConfig
|
||||||
|
jobs map[string]*ImageGenerationJob
|
||||||
|
jobsLock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMinimaxService 创建 MiniMax 服务
|
||||||
|
func NewMinimaxService(cfg *config.AssetConfig) MinimaxService {
|
||||||
|
svc := &minimaxService{
|
||||||
|
config: cfg,
|
||||||
|
jobs: make(map[string]*ImageGenerationJob),
|
||||||
|
}
|
||||||
|
// 启动清理 goroutine
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 异步调用 MiniMax
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
return job, 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) {
|
||||||
|
apiURL := s.config.GetMiniMaxAPIURL()
|
||||||
|
apiKey := s.config.GetMiniMaxAPIKey()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": aspectRatio,
|
||||||
|
"subject_reference": refs,
|
||||||
|
"n": n,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Images []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"images"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]string, len(result.Images))
|
||||||
|
for i, img := range result.Images {
|
||||||
|
images[i] = img.URL
|
||||||
|
}
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressImageIfNeeded 下载并压缩图片
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
resized := resize.Thumbnail(newWidth, newHeight, img, resize.Lanczos)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
|
||||||
|
// 检查是否是 IP
|
||||||
|
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)
|
||||||
|
for range 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/assetService/service/minimax_service.go backend/services/assetService/go.mod backend/services/assetService/go.sum
|
||||||
|
git commit -m "feat: 添加 MiniMax 图生图服务和任务管理"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 修改 AssetController 添加新接口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/gateway/controller/asset_controller.go` (在 AssetController struct 添加 minimaxService 字段,添加两个方法)
|
||||||
|
- Test: (无独立测试)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加字段和方法到 AssetController**
|
||||||
|
|
||||||
|
在 `AssetController` struct 添加:
|
||||||
|
```go
|
||||||
|
minimaxService service.MinimaxService
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `NewAssetController` 添加:
|
||||||
|
```go
|
||||||
|
ctrl.minimaxService = service.NewMinimaxService(nil) // TODO: 传入 config
|
||||||
|
```
|
||||||
|
|
||||||
|
添加两个方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ImageGeneration 创建图生图任务
|
||||||
|
// @Summary 图生图
|
||||||
|
// @Description 创建图生图任务
|
||||||
|
// @Tags assets
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param request body dto.ImageGenerationRequest true "图生图请求"
|
||||||
|
// @Success 202 {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
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetInt64("userID")
|
||||||
|
starID := c.GetInt64("starID")
|
||||||
|
|
||||||
|
job, err := ctrl.minimaxService.CreateJob(c.Request.Context(), userID, starID, &req)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, 500, "Failed to create job: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.SuccessWithStatus(c, 202, &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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/gateway/controller/asset_controller.go
|
||||||
|
git commit -m "feat: 添加 ImageGeneration 和 GetImageJob 接口"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 注册路由
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/gateway/router/router.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 assets 路由组添加新路由**
|
||||||
|
|
||||||
|
在 `assets.POST("/mints/precreate", ...)` 附近添加:
|
||||||
|
|
||||||
|
```go
|
||||||
|
assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图
|
||||||
|
assets.GET("/mints/image/generation/:job_id", assetCtrl.GetImageJob) // 查询任务
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/gateway/router/router.go
|
||||||
|
git commit -m "feat: 注册图生图 API 路由"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 修改前端轮询逻辑
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/pages/discover/generation-loading.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改 script 部分**
|
||||||
|
|
||||||
|
将 `callImageGeneration` 函数改为:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// job_id 轮询
|
||||||
|
let jobId = null;
|
||||||
|
|
||||||
|
// 调用图生图API - 异步模式
|
||||||
|
const callImageGeneration = async () => {
|
||||||
|
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: baseURL + `/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();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/discover/generation-loading.vue
|
||||||
|
git commit -m "feat: 前端改为轮询模式"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 添加配置读取 (config)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/services/assetService/config/minimax_config.go`
|
||||||
|
- Modify: `backend/services/assetService/config/asset_config.go` (如果需要)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加配置读取**
|
||||||
|
|
||||||
|
在 config 包添加 MiniMax 配置读取方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetMiniMaxAPIURL 获取 MiniMax API URL
|
||||||
|
func (c *AssetConfig) GetMiniMaxAPIURL() string {
|
||||||
|
return os.Getenv("MINIMAX_API_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMiniMaxAPIKey 获取 MiniMax API Key
|
||||||
|
func (c *AssetConfig) GetMiniMaxAPIKey() string {
|
||||||
|
return os.Getenv("MINIMAX_API_KEY")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/assetService/config/
|
||||||
|
git commit -m "feat: 添加 MiniMax 配置读取"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 验证测试
|
||||||
|
|
||||||
|
- [ ] **Step 1: 启动后端服务**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/gateway && go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 测试创建任务**
|
||||||
|
|
||||||
|
```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":[]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 测试轮询任务状态**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/assets/mints/image/generation/<job_id> \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
# MiniMax 图生图 API 集成设计方案
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
前端传递参数 → 后端异步调用 MiniMax 图生图 API → 前端轮询任务状态 → 返回结果
|
||||||
|
|
||||||
|
## 现有架构
|
||||||
|
|
||||||
|
| 层级 | 技术栈 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 前端 | uni-app (Vue 3) | 使用 `uni.request` 发起请求,已有 loading 页面 |
|
||||||
|
| 后端 | Go + Gin | API Gateway 模式,Dubbo RPC 调用微服务 |
|
||||||
|
| 微服务 | Go | `assetService` 等独立服务 |
|
||||||
|
| 配置 | `.env` 文件 | API keys 等敏感配置 |
|
||||||
|
|
||||||
|
## API 设计
|
||||||
|
|
||||||
|
### 1. 创建图生图任务
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/assets/mints/image/generation
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求头:**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 (202 Accepted):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 202,
|
||||||
|
"message": "任务已创建",
|
||||||
|
"data": {
|
||||||
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"created_at": 1744118400000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询任务状态
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/assets/mints/image/generation/:job_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 (PROCESSING):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "处理中",
|
||||||
|
"data": {
|
||||||
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"progress": 50,
|
||||||
|
"created_at": 1744118400000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 (COMPLETED):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "成功",
|
||||||
|
"data": {
|
||||||
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"progress": 100,
|
||||||
|
"images": [
|
||||||
|
"https://api.minimaxi.com/v1/images/xxx.png"
|
||||||
|
],
|
||||||
|
"created_at": 1744118400000,
|
||||||
|
"completed_at": 1744118490000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 (FAILED):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "失败",
|
||||||
|
"data": {
|
||||||
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": "FAILED",
|
||||||
|
"progress": 0,
|
||||||
|
"error_msg": "MiniMax API 调用失败: timeout",
|
||||||
|
"created_at": 1744118400000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### Job 状态 (内存存储)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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"` // 0-100
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ErrorMsg string `json:"error_msg,omitempty"`
|
||||||
|
Request *ImageGenerationRequest `json:"request,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"` //毫秒时间戳
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CompletedAt int64 `json:"completed_at,omitempty"` // 毫秒时间戳
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DTO
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ImageGenerationRequest 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"` // 1-4
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectReference struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ImageFile string `json:"image_file"` // 必须为有效 URL,需 SSRF 校验
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageJobResponse 图生图任务响应
|
||||||
|
type ImageJobResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ErrorMsg string `json:"error_msg,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后端实现
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── services/assetService/
|
||||||
|
│ └── service/
|
||||||
|
│ └── minimax_service.go # MiniMax API 转发服务 + 任务管理
|
||||||
|
└── gateway/
|
||||||
|
├── controller/
|
||||||
|
│ └── asset_controller.go # 新增 ImageGeneration, GetImageJob
|
||||||
|
├── dto/
|
||||||
|
│ └── image_dto.go # 请求/响应 DTO
|
||||||
|
└── router/
|
||||||
|
└── router.go # 注册路由
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心逻辑
|
||||||
|
|
||||||
|
1. **创建任务**: 生成 job_id,存储任务到内存 map,返回 202
|
||||||
|
2. **异步处理**: goroutine 调用 MiniMax API,图片压缩(最大边1024px),更新 job 状态
|
||||||
|
3. **查询状态**: 从内存读取 job 状态返回
|
||||||
|
4. **任务清理**: 后台 goroutine 定期清理超期(>24h)的已完成任务
|
||||||
|
|
||||||
|
### 图片压缩
|
||||||
|
|
||||||
|
- 最大边压缩至 1024px,保持宽高比
|
||||||
|
- 格式转换: PNG/GIF → JPEG(质量85%)
|
||||||
|
- 返回 base64 data URI 格式
|
||||||
|
|
||||||
|
### SSRF 防护
|
||||||
|
|
||||||
|
`subject_reference[].image_file` 必须是有效 URL,下载前需校验:
|
||||||
|
- 不能是私有 IP (10.x, 172.16-31.x, 192.168.x)
|
||||||
|
- 不能是 localhost
|
||||||
|
- 不能是内网域名
|
||||||
|
- 校验失败则拒绝请求 (400)
|
||||||
|
|
||||||
|
## 前端改动
|
||||||
|
|
||||||
|
**generation-loading.vue**:
|
||||||
|
- 调用 `POST /generation` 获取 job_id
|
||||||
|
- 每 3 秒轮询 `GET /generation/:job_id`
|
||||||
|
- 超时时间: 120 秒后显示"生成超时,请重试"
|
||||||
|
- 完成后跳转到结果页
|
||||||
|
|
||||||
|
## HTTP 状态码
|
||||||
|
|
||||||
|
| 场景 | HTTP 状态码 |
|
||||||
|
|------|-------------|
|
||||||
|
| 成功 (创建/查询) | 200 / 202 |
|
||||||
|
| 参数校验失败 | 400 |
|
||||||
|
| 未认证 | 401 |
|
||||||
|
| 无权访问 job | 403 |
|
||||||
|
| Job 不存在 | 404 |
|
||||||
|
| MiniMax API 失败 | 500 |
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| MiniMax API 超时 | 标记 job 为 FAILED,error_msg 包含原因 |
|
||||||
|
| 图片压缩失败 | 使用原图,继续处理 |
|
||||||
|
| SSRF 校验失败 | 返回 400,"无效的图片URL" |
|
||||||
|
| Job 不存在 | 返回 404 |
|
||||||
|
| 无权访问 job | 返回 403 |
|
||||||
|
|
||||||
|
## 文件修改清单
|
||||||
|
|
||||||
|
| 操作 | 文件路径 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 新增 | `backend/gateway/dto/image_dto.go` | 请求/响应 DTO |
|
||||||
|
| 新增 | `backend/services/assetService/service/minimax_service.go` | MiniMax API 转发 + 图片压缩 + 任务管理 |
|
||||||
|
| 修改 | `backend/gateway/controller/asset_controller.go` | 新增 ImageGeneration, GetImageJob |
|
||||||
|
| 修改 | `backend/gateway/router/router.go` | 注册路由 |
|
||||||
|
| 修改 | `frontend/pages/discover/generation-loading.vue` | 改为轮询模式 |
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/services/assetService && go get github.com/nfnt/resize
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证方案
|
||||||
|
|
||||||
|
1. 启动后端服务
|
||||||
|
2. 获取 JWT token
|
||||||
|
3. 测试创建任务:
|
||||||
|
```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":[]}'
|
||||||
|
```
|
||||||
|
4. 使用返回的 job_id 轮询状态:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/assets/mints/image/generation/<job_id> \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
@ -91,54 +91,27 @@ const revertProgress = () => {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 调用图生图API
|
// 调用图生图API - 同步模式
|
||||||
const callImageGeneration = async () => {
|
const callImageGeneration = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await imageGenerationApi(generationData);
|
const res = await imageGenerationApi(generationData);
|
||||||
|
|
||||||
if (res.data && res.data.base_resp && res.data.base_resp.status_code === 0) {
|
if (res.data && res.data.images && res.data.images.length > 0) {
|
||||||
console.log('[GenerationLoading] 图生图成功:', res);
|
// 保存生成的图片
|
||||||
|
uni.setStorageSync('generated_images', JSON.stringify(res.data.images));
|
||||||
// 获取返回的图片数据
|
completeProgress();
|
||||||
const imageUrls = res.data.data?.image_urls || [];
|
|
||||||
|
|
||||||
if (imageUrls.length > 0) {
|
|
||||||
// 将base64图片数据转换为可用的格式
|
|
||||||
const processedImages = imageUrls.map(base64Data => {
|
|
||||||
// 如果已经是完整的data URL,直接返回
|
|
||||||
if (base64Data.startsWith('data:image')) {
|
|
||||||
return base64Data;
|
|
||||||
}
|
|
||||||
// 否则添加data URL前缀
|
|
||||||
return `data:image/png;base64,${base64Data}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储图片数据
|
|
||||||
uni.setStorageSync('generated_images', JSON.stringify(processedImages));
|
|
||||||
|
|
||||||
// 成功,完成进度
|
|
||||||
completeProgress();
|
|
||||||
} else {
|
|
||||||
uni.showToast({
|
|
||||||
title: '未生成图片',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
revertProgress();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 失败,回退进度
|
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: res.data?.base_resp?.status_msg || '生成失败',
|
title: '未生成图片',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
});
|
});
|
||||||
revertProgress();
|
revertProgress();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GenerationLoading] 图生图失败:', err);
|
console.error('[GenerationLoading] 生成失败:', err);
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '生成失败',
|
title: err.message || '生成失败',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
});
|
});
|
||||||
@ -153,7 +126,7 @@ const handleSuccess = () => {
|
|||||||
icon: 'success',
|
icon: 'success',
|
||||||
duration: 1500
|
duration: 1500
|
||||||
});
|
});
|
||||||
|
|
||||||
// 跳转到结果页面
|
// 跳转到结果页面
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
@ -176,10 +149,10 @@ onMounted(() => {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析请求数据
|
// 解析请求数据
|
||||||
generationData = JSON.parse(requestDataStr);
|
generationData = JSON.parse(requestDataStr);
|
||||||
|
|
||||||
// 从castlove_form_data获取base64图片
|
// 从castlove_form_data获取base64图片
|
||||||
const castloveDataStr = uni.getStorageSync('castlove_form_data');
|
const castloveDataStr = uni.getStorageSync('castlove_form_data');
|
||||||
if (castloveDataStr) {
|
if (castloveDataStr) {
|
||||||
@ -189,15 +162,15 @@ onMounted(() => {
|
|||||||
generationData.subject_reference[0].image_file = castloveData.imageBase64;
|
generationData.subject_reference[0].image_file = castloveData.imageBase64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除generation_request_data存储,避免重复使用
|
// 清除generation_request_data存储,避免重复使用
|
||||||
uni.removeStorageSync('generation_request_data');
|
uni.removeStorageSync('generation_request_data');
|
||||||
|
|
||||||
console.log('[GenerationLoading] 接收到生成数据');
|
console.log('[GenerationLoading] 接收到生成数据');
|
||||||
|
|
||||||
// 开始模拟进度
|
// 开始模拟进度
|
||||||
simulateProgress();
|
simulateProgress();
|
||||||
|
|
||||||
// 调用API
|
// 调用API
|
||||||
callImageGeneration();
|
callImageGeneration();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// API 基础配置
|
// API 基础配置
|
||||||
// const baseURL = 'http://101.132.250.62:8080'
|
// const baseURL = 'http://101.132.250.62:8080'
|
||||||
const baseURL = 'http://192.168.110.60:8080'
|
// const baseURL = 'http://192.168.110.60:8080'
|
||||||
|
const baseURL = 'http://localhost:8080'
|
||||||
|
|
||||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||||
const USE_MOCK_API = false
|
const USE_MOCK_API = false
|
||||||
@ -54,7 +55,7 @@ function request(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 可以在这里统一处理响应
|
// 可以在这里统一处理响应
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200 || res.statusCode === 202) {
|
||||||
// 检查业务状态码
|
// 检查业务状态码
|
||||||
if (res.data && res.data.code !== undefined) {
|
if (res.data && res.data.code !== undefined) {
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
@ -455,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}`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user