Merge branch 'develop' of gitee.com:hezhaohui123/gen2d into feat/async-generate-pipeline
Signed-off-by: 何朝晖 <hezhaohui0807@163.com>
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
GEN2D_PORT=8080
|
||||
GEN2D_MODE=debug
|
||||
|
||||
# 日志配置
|
||||
GEN2D_LOG_LEVEL=info # debug / info / warn / error
|
||||
GEN2D_LOG_FORMAT=text # text / json
|
||||
|
||||
# SQLite 数据库路径
|
||||
GEN2D_DSN=data/gen2d.db
|
||||
|
||||
@@ -27,3 +31,16 @@ GEN2D_IMAGE_MODEL=gpt-image-2
|
||||
GEN2D_IMAGE_WIDTH=1024
|
||||
GEN2D_IMAGE_HEIGHT=1024
|
||||
GEN2D_IMAGE_QUALITY=low
|
||||
GEN2D_IMAGE_TIMEOUT=120
|
||||
GEN2D_IMAGE_MAX_RETRIES=2
|
||||
GEN2D_IMAGE_RETRY_DELAY=5
|
||||
GEN2D_IMAGE_NUM_IMAGES=1
|
||||
GEN2D_IMAGE_STEPS=30
|
||||
GEN2D_IMAGE_CFG_SCALE=7.0
|
||||
|
||||
# 七牛云对象存储
|
||||
GEN2D_QINIU_ACCESS_KEY=your-access-key
|
||||
GEN2D_QINIU_SECRET_KEY=your-secret-key
|
||||
GEN2D_QINIU_BUCKET=your-bucket-name
|
||||
GEN2D_QINIU_CDN_HOST=https://cdn.example.com
|
||||
GEN2D_QINIU_USE_HTTPS=true
|
||||
|
||||
+20
-16
@@ -3,12 +3,12 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"log/slog"
|
||||
|
||||
"gen2d/internal/config"
|
||||
"gen2d/internal/db"
|
||||
"gen2d/internal/handler"
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/mildware"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
@@ -19,14 +19,15 @@ import (
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
// 初始化日志
|
||||
logger.Init(cfg.Log.Level, cfg.Log.Format)
|
||||
|
||||
// 确保 generation 输出目录存在(项目根级别)
|
||||
_ = os.MkdirAll("../generation", 0755)
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 初始化 SQLite 数据库
|
||||
if err := db.Init(cfg.Database.DSN, &model.User{}); err != nil {
|
||||
log.Fatalf("db init failed: %v", err)
|
||||
slog.Error("db init failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 AuthService,注入 JWT 配置
|
||||
@@ -36,27 +37,30 @@ func main() {
|
||||
service.InitLLMConfig(cfg.LLM)
|
||||
service.InitImageGenConfig(cfg.ImageGen)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机
|
||||
// 初始化七牛云存储服务
|
||||
storageSvc := service.NewStorageService(cfg.Qiniu)
|
||||
handler.InitStorageService(storageSvc)
|
||||
|
||||
// 静态文件服务 — 生成的图片
|
||||
r.Static("/generation", "../generation")
|
||||
r := gin.New()
|
||||
r.Use(mildware.Logger())
|
||||
r.Use(mildware.Recovery())
|
||||
|
||||
// API v1 路由组 — 公开接口
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/health", handler.Health) // 健康检查
|
||||
v1.POST("/prompt/optimize", handler.PromptOptimize) // 提示词优化
|
||||
v1.GET("/assets/download", handler.DownloadAsset) // 素材下载
|
||||
}
|
||||
|
||||
// API v1 路由组 — 需认证
|
||||
v1Auth := r.Group("/api/v1")
|
||||
v1Auth.Use(mildware.AuthMiddleware(cfg.JWT.Secret))
|
||||
{
|
||||
v1Auth.POST("/generate", handler.Generate) // 素材生成管线
|
||||
v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
|
||||
v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
|
||||
v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
|
||||
v1Auth.POST("/generate", handler.Generate) // 素材生成管线
|
||||
v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
|
||||
v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
|
||||
v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
|
||||
}
|
||||
|
||||
// Auth 路由组
|
||||
@@ -67,8 +71,8 @@ func main() {
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
log.Printf("gen2d backend starting on %s", addr)
|
||||
slog.Info("gen2d backend starting", "addr", addr, "mode", cfg.Server.Mode)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("server failed: %v", err)
|
||||
slog.Error("server failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/qiniu/go-sdk/v7 v7.26.12
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -14,6 +15,8 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
@@ -24,6 +27,7 @@ require (
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gammazero/toposort v0.1.1 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -31,6 +35,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gofrs/flock v0.8.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
@@ -65,8 +70,10 @@ require (
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/fileutil v1.0.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
@@ -32,6 +36,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
|
||||
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
@@ -53,6 +59,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
@@ -66,6 +74,8 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
@@ -118,6 +128,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qiniu/go-sdk/v7 v7.26.12 h1:AnWiKjBY62XpULoB/MySKdNmlUo9S9pQB7/0s7QueCo=
|
||||
github.com/qiniu/go-sdk/v7 v7.26.12/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -189,6 +201,8 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -215,3 +229,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w=
|
||||
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
|
||||
|
||||
@@ -12,8 +12,16 @@ type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
LLM LLMConfig `mapstructure:"llm"`
|
||||
ImageGen ImageGenConfig `mapstructure:"image_gen"`
|
||||
Qiniu QiniuConfig `mapstructure:"qiniu"`
|
||||
}
|
||||
|
||||
// LogConfig 日志配置。
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"` // debug / info / warn / error
|
||||
Format string `mapstructure:"format"` // text / json
|
||||
}
|
||||
|
||||
// ServerConfig HTTP 服务配置。
|
||||
@@ -45,12 +53,24 @@ type LLMConfig struct {
|
||||
|
||||
// ImageGenConfig OpenAI 兼容文生图模型配置。
|
||||
type ImageGenConfig struct {
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"`
|
||||
Width int `mapstructure:"width"`
|
||||
Height int `mapstructure:"height"`
|
||||
Quality string `mapstructure:"quality"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"`
|
||||
Width int `mapstructure:"width"`
|
||||
Height int `mapstructure:"height"`
|
||||
Quality string `mapstructure:"quality"`
|
||||
Timeout int `mapstructure:"timeout"` // HTTP 请求超时秒数,默认 120
|
||||
MaxRetries int `mapstructure:"max_retries"` // 5xx 错误重试次数,默认 2
|
||||
RetryDelay int `mapstructure:"retry_delay"` // 重试间隔秒数,默认 5
|
||||
}
|
||||
|
||||
// QiniuConfig 七牛云对象存储配置。
|
||||
type QiniuConfig struct {
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
CDNHost string `mapstructure:"cdn_host"`
|
||||
UseHTTPS bool `mapstructure:"use_https"`
|
||||
}
|
||||
|
||||
// Load 从 YAML 配置文件和环境变量加载配置。
|
||||
@@ -90,6 +110,9 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.mode", "debug")
|
||||
v.SetDefault("server.max_file_size", int64(10<<20))
|
||||
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("log.format", "text")
|
||||
|
||||
v.SetDefault("database.dsn", "data/gen2d.db")
|
||||
|
||||
v.SetDefault("jwt.secret", "gen2d-dev-secret")
|
||||
@@ -107,6 +130,18 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("image_gen.width", 1024)
|
||||
v.SetDefault("image_gen.height", 1024)
|
||||
v.SetDefault("image_gen.quality", "low")
|
||||
v.SetDefault("image_gen.timeout", 120)
|
||||
v.SetDefault("image_gen.max_retries", 2)
|
||||
v.SetDefault("image_gen.retry_delay", 5)
|
||||
v.SetDefault("image_gen.num_images", 1)
|
||||
v.SetDefault("image_gen.steps", 30)
|
||||
v.SetDefault("image_gen.cfg_scale", 7.0)
|
||||
|
||||
v.SetDefault("qiniu.access_key", "")
|
||||
v.SetDefault("qiniu.secret_key", "")
|
||||
v.SetDefault("qiniu.bucket", "")
|
||||
v.SetDefault("qiniu.cdn_host", "")
|
||||
v.SetDefault("qiniu.use_https", true)
|
||||
}
|
||||
|
||||
func bindEnvVars(v *viper.Viper) {
|
||||
@@ -114,6 +149,9 @@ func bindEnvVars(v *viper.Viper) {
|
||||
v.BindEnv("server.mode", "GEN2D_MODE")
|
||||
v.BindEnv("server.max_file_size", "GEN2D_MAX_FILE_SIZE")
|
||||
v.BindEnv("database.dsn", "GEN2D_DSN")
|
||||
|
||||
v.BindEnv("log.level", "GEN2D_LOG_LEVEL")
|
||||
v.BindEnv("log.format", "GEN2D_LOG_FORMAT")
|
||||
v.BindEnv("jwt.secret", "GEN2D_JWT_SECRET")
|
||||
v.BindEnv("jwt.expire", "GEN2D_JWT_EXPIRE")
|
||||
|
||||
@@ -129,4 +167,16 @@ func bindEnvVars(v *viper.Viper) {
|
||||
v.BindEnv("image_gen.width", "GEN2D_IMAGE_WIDTH")
|
||||
v.BindEnv("image_gen.height", "GEN2D_IMAGE_HEIGHT")
|
||||
v.BindEnv("image_gen.quality", "GEN2D_IMAGE_QUALITY")
|
||||
v.BindEnv("image_gen.timeout", "GEN2D_IMAGE_TIMEOUT")
|
||||
v.BindEnv("image_gen.max_retries", "GEN2D_IMAGE_MAX_RETRIES")
|
||||
v.BindEnv("image_gen.retry_delay", "GEN2D_IMAGE_RETRY_DELAY")
|
||||
v.BindEnv("image_gen.num_images", "GEN2D_IMAGE_NUM_IMAGES")
|
||||
v.BindEnv("image_gen.steps", "GEN2D_IMAGE_STEPS")
|
||||
v.BindEnv("image_gen.cfg_scale", "GEN2D_IMAGE_CFG_SCALE")
|
||||
|
||||
v.BindEnv("qiniu.access_key", "GEN2D_QINIU_ACCESS_KEY")
|
||||
v.BindEnv("qiniu.secret_key", "GEN2D_QINIU_SECRET_KEY")
|
||||
v.BindEnv("qiniu.bucket", "GEN2D_QINIU_BUCKET")
|
||||
v.BindEnv("qiniu.cdn_host", "GEN2D_QINIU_CDN_HOST")
|
||||
v.BindEnv("qiniu.use_https", "GEN2D_QINIU_USE_HTTPS")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ server:
|
||||
mode: debug
|
||||
max_file_size: 10485760 # 10MB
|
||||
|
||||
log:
|
||||
level: info # debug / info / warn / error
|
||||
format: text # text / json
|
||||
|
||||
database:
|
||||
dsn: "data/gen2d.db"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
|
||||
@@ -12,9 +13,9 @@ import (
|
||||
|
||||
// EditImageRequest 图片编辑请求。
|
||||
type EditImageRequest struct {
|
||||
Image string `json:"image" binding:"required"` // 底图 base64 编码
|
||||
Prompt string `json:"prompt" binding:"required"` // 编辑指令
|
||||
Count int `json:"count"` // 生成数量,默认 1
|
||||
Image string `json:"image" binding:"required"` // 底图 base64 编码
|
||||
Prompt string `json:"prompt" binding:"required"` // 编辑指令
|
||||
Count int `json:"count"` // 生成数量,默认 1
|
||||
}
|
||||
|
||||
// editAssetResponse 编辑结果素材(返回 base64)。
|
||||
@@ -38,7 +39,7 @@ func EditImage(c *gin.Context) {
|
||||
|
||||
imageData, err := base64.StdEncoding.DecodeString(req.Image)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "图片 base64 解码失败: "+err.Error()))
|
||||
c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "图片 base64 解码失败"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ func EditImage(c *gin.Context) {
|
||||
|
||||
images, err := service.EditImages(c.Request.Context(), imageData, req.Prompt, count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "图片编辑失败: "+err.Error()))
|
||||
logger.FromCtx(c.Request.Context()).Error("图片编辑失败", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "图片编辑失败"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
|
||||
@@ -58,8 +56,8 @@ type TaskResponse struct {
|
||||
|
||||
// AssetsResponse 素材列表响应。
|
||||
type AssetsResponse struct {
|
||||
Assets []AssetResponse `json:"assets"`
|
||||
Metadata service.AssetMetadata `json:"metadata"`
|
||||
Assets []AssetResponse `json:"assets"`
|
||||
Metadata service.AssetMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
// taskRecord 内存中的任务记录。
|
||||
@@ -106,7 +104,7 @@ func Generate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.OK(GenerateResponse{TaskID: taskID}))
|
||||
|
||||
// 后台执行管线
|
||||
go runPipelineBg(projectID, taskID, req)
|
||||
go runPipelineBg(c.Request.Context(), projectID, taskID, req)
|
||||
}
|
||||
|
||||
// runPipelineBg 后台执行生成管线,更新任务状态。
|
||||
@@ -116,6 +114,8 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
|
||||
updateTaskProgress(taskID, "running", stage, progress)
|
||||
})
|
||||
|
||||
l := logger.With("task_id", taskID, "project_id", projectID)
|
||||
|
||||
updateTaskProgress(taskID, "running", "prompt_builder", 5)
|
||||
|
||||
in := service.PipelineInput{
|
||||
@@ -139,30 +139,24 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
|
||||
|
||||
output, err := service.RunPipeline(ctx, in)
|
||||
if err != nil {
|
||||
log.Printf("[generate] task %s failed: %v", taskID, err)
|
||||
updateFailed(taskID, err.Error())
|
||||
l.Error("task pipeline failed", "error", err)
|
||||
updateFailed(taskID, "生成管线执行失败")
|
||||
return
|
||||
}
|
||||
|
||||
updateTaskProgress(taskID, "saving", "format_adapter", 90)
|
||||
|
||||
// 保存图片到 ../generation/{projectId}/{taskId}/
|
||||
genDir := filepath.Join("..", "generation", projectID, taskID)
|
||||
if err := os.MkdirAll(genDir, 0755); err != nil {
|
||||
updateFailed(taskID, "创建输出目录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assets := make([]AssetResponse, len(output.Assets))
|
||||
for i, a := range output.Assets {
|
||||
filename := fmt.Sprintf("%d.%s", i, a.Format)
|
||||
filePath := filepath.Join(genDir, filename)
|
||||
if err := os.WriteFile(filePath, a.Data, 0644); err != nil {
|
||||
updateFailed(taskID, "保存图片失败: "+err.Error())
|
||||
key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format)
|
||||
cdnURL, err := storageSvc.Upload(ctx, key, a.Data)
|
||||
if err != nil {
|
||||
l.Error("upload asset failed", "index", i, "error", err)
|
||||
updateFailed(taskID, "上传素材失败")
|
||||
return
|
||||
}
|
||||
assets[i] = AssetResponse{
|
||||
URL: fmt.Sprintf("/generation/%s/%s/%s", projectID, taskID, filename),
|
||||
URL: cdnURL,
|
||||
Format: a.Format,
|
||||
}
|
||||
}
|
||||
@@ -182,7 +176,7 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
|
||||
metadata: output.Metadata,
|
||||
})
|
||||
|
||||
log.Printf("[generate] task %s completed, %d assets", taskID, len(assets))
|
||||
l.Info("task completed", "asset_count", len(assets))
|
||||
}
|
||||
|
||||
func updateTaskProgress(taskID, status, stage string, progress int) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -31,6 +32,7 @@ func Login(c *gin.Context) {
|
||||
|
||||
token, expiresIn, user, err := authSvc.Login(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
logger.FromCtx(c.Request.Context()).Warn("登录失败", "username", req.Username, "error", err)
|
||||
c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
|
||||
@@ -34,7 +35,8 @@ func PromptOptimize(c *gin.Context) {
|
||||
|
||||
output, err := service.RunPromptAgent(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "提示词优化失败: "+err.Error()))
|
||||
logger.FromCtx(c.Request.Context()).Error("提示词优化失败", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "提示词优化失败"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
|
||||
@@ -39,6 +40,7 @@ func Register(c *gin.Context) {
|
||||
|
||||
user, err := authSvc.Register(c.Request.Context(), req.Username, req.Password, req.Email)
|
||||
if err != nil {
|
||||
logger.FromCtx(c.Request.Context()).Warn("注册失败", "username", req.Username, "error", err)
|
||||
c.JSON(http.StatusConflict, model.Fail(http.StatusConflict, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
"gen2d/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var storageSvc *service.StorageService
|
||||
|
||||
// InitStorageService 由 main 在启动时调用,注入七牛云存储服务。
|
||||
func InitStorageService(svc *service.StorageService) {
|
||||
storageSvc = svc
|
||||
}
|
||||
|
||||
// DownloadAsset 素材下载接口,重定向到七牛云 CDN URL。
|
||||
// GET /api/v1/assets/download?key=...
|
||||
func DownloadAsset(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "缺少 key 参数"))
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL, err := storageSvc.GetDownloadURL(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
logger.FromCtx(c.Request.Context()).Error("生成下载链接失败", "key", key, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "生成下载链接失败"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, downloadURL)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// L 全局 logger,Init 之前使用默认 text 输出。
|
||||
var L *slog.Logger
|
||||
|
||||
func init() {
|
||||
L = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
||||
// Init 初始化全局 logger。
|
||||
// level: debug / info / warn / error
|
||||
// format: text / json
|
||||
func Init(level, format string) {
|
||||
var lvl slog.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: lvl}
|
||||
|
||||
var h slog.Handler
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
h = slog.NewJSONHandler(os.Stdout, opts)
|
||||
default:
|
||||
h = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
|
||||
L = slog.New(h)
|
||||
slog.SetDefault(L)
|
||||
}
|
||||
|
||||
// With 创建带附加字段的 logger。
|
||||
func With(args ...any) *slog.Logger {
|
||||
return L.With(args...)
|
||||
}
|
||||
|
||||
// IntoCtx 将 logger 存入 context。
|
||||
func IntoCtx(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, l)
|
||||
}
|
||||
|
||||
// FromCtx 从 context 取出 logger,不存在时返回全局 L。
|
||||
func FromCtx(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
|
||||
return l
|
||||
}
|
||||
return L
|
||||
}
|
||||
|
||||
// WithRequestID 创建带 request_id 的 logger 并存入 context。
|
||||
func WithRequestID(ctx context.Context, requestID string) (context.Context, *slog.Logger) {
|
||||
l := L.With("request_id", requestID)
|
||||
return IntoCtx(ctx, l), l
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,12 +16,14 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
logger.FromCtx(c.Request.Context()).Warn("auth failed: no token")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未提供认证令牌"))
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
logger.FromCtx(c.Request.Context()).Warn("auth failed: invalid format")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "认证格式错误,需为 Bearer <token>"))
|
||||
return
|
||||
}
|
||||
@@ -29,12 +32,14 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
logger.FromCtx(c.Request.Context()).Warn("auth failed: invalid token", "error", err)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "令牌无效或已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
logger.FromCtx(c.Request.Context()).Warn("auth failed: parse claims")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "令牌解析失败"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package mildware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger 返回 HTTP 请求日志中间件。
|
||||
// 为每个请求生成 request_id,记录 method、path、status、latency、client_ip。
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
requestID := generateRequestID()
|
||||
|
||||
c.Set("request_id", requestID)
|
||||
ctx, _ := logger.WithRequestID(c.Request.Context(), requestID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Header("X-Request-ID", requestID)
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
l := logger.FromCtx(ctx)
|
||||
attrs := []slog.Attr{
|
||||
slog.String("method", c.Request.Method),
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.Int("status", status),
|
||||
slog.Duration("latency", latency),
|
||||
slog.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
|
||||
if status >= 500 {
|
||||
l.LogAttrs(ctx, slog.LevelError, "request completed", attrs...)
|
||||
} else if status >= 400 {
|
||||
l.LogAttrs(ctx, slog.LevelWarn, "request completed", attrs...)
|
||||
} else {
|
||||
l.LogAttrs(ctx, slog.LevelInfo, "request completed", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery 返回自定义 panic 恢复中间件。
|
||||
// panic 时记录完整的 request 上下文和堆栈,返回 500。
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ctx := c.Request.Context()
|
||||
l := logger.FromCtx(ctx)
|
||||
l.Error("panic recovered",
|
||||
"error", fmt.Sprintf("%v", r),
|
||||
"method", c.Request.Method,
|
||||
"path", c.Request.URL.Path,
|
||||
"request_id", c.GetString("request_id"),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError,
|
||||
gin.H{"code": http.StatusInternalServerError, "message": "服务器内部错误"})
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func generateRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return fmt.Sprintf("%d-%x", time.Now().UnixMilli(), b)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gen2d/internal/db"
|
||||
"gen2d/internal/logger"
|
||||
"gen2d/internal/model"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -29,8 +30,11 @@ func NewAuthService(jwtSecret string, jwtExpire int64) *AuthService {
|
||||
|
||||
// Register 注册新用户,密码使用 bcrypt 加密存储。
|
||||
func (s *AuthService) Register(ctx context.Context, username, password, email string) (*model.User, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
l.Error("bcrypt failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,25 +46,33 @@ func (s *AuthService) Register(ctx context.Context, username, password, email st
|
||||
|
||||
if err := db.DB.WithContext(ctx).Create(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
l.Warn("register failed: duplicate username", "username", username)
|
||||
return nil, errors.New("用户名已存在")
|
||||
}
|
||||
l.Error("register db failed", "username", username, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.Info("user registered", "username", username, "user_id", user.ID)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Login 校验用户名密码,成功返回 JWT token 及用户信息。
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (string, int64, *model.User, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
l.Warn("login failed: user not found", "username", username)
|
||||
return "", 0, nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
l.Error("login db query failed", "username", username, "error", err)
|
||||
return "", 0, nil, err
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
l.Warn("login failed: wrong password", "username", username)
|
||||
return "", 0, nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
@@ -72,8 +84,10 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (str
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
l.Error("jwt sign failed", "error", err)
|
||||
return "", 0, nil, err
|
||||
}
|
||||
|
||||
l.Info("login succeeded", "username", username, "user_id", user.ID)
|
||||
return token, int64(s.jwtExpire.Seconds()), &user, nil
|
||||
}
|
||||
|
||||
@@ -11,20 +11,29 @@ import (
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gen2d/internal/config"
|
||||
"gen2d/internal/logger"
|
||||
)
|
||||
|
||||
// imgCfg 保存文生图配置,由 main 通过 InitImageGenConfig 注入。
|
||||
var imgCfg config.ImageGenConfig
|
||||
|
||||
// imageHTTPClient 带超时的 HTTP 客户端,由 InitImageGenConfig 初始化。
|
||||
var imageHTTPClient *http.Client
|
||||
|
||||
// InitImageGenConfig 注入文生图配置。
|
||||
func InitImageGenConfig(cfg config.ImageGenConfig) {
|
||||
imgCfg = cfg
|
||||
timeout := time.Duration(cfg.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
imageHTTPClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
|
||||
// ======================== OpenAI 兼容 Images API 类型 ========================
|
||||
@@ -55,25 +64,37 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G
|
||||
count = params.Frames.Directions * params.Frames.FramesPerDirection
|
||||
}
|
||||
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
if imgCfg.APIKey != "" {
|
||||
width, height := imgCfg.Width, imgCfg.Height
|
||||
if params.Resolution > 0 {
|
||||
width = params.Resolution
|
||||
height = params.Resolution
|
||||
}
|
||||
log.Println("[inference] calling image gen API")
|
||||
return callImageAPI(ctx, prompt, count, width, height)
|
||||
l.Info("calling image gen API",
|
||||
"model", imgCfg.Model,
|
||||
"count", count,
|
||||
"size", fmt.Sprintf("%dx%d", width, height),
|
||||
)
|
||||
images, err := callImageAPI(ctx, prompt, count, width, height)
|
||||
if err != nil {
|
||||
l.Error("image gen API failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
l.Info("image gen API succeeded", "image_count", len(images))
|
||||
return images, nil
|
||||
}
|
||||
|
||||
size := params.Resolution
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
}
|
||||
log.Println("[inference] image API key not configured, using mock")
|
||||
l.Warn("image API key not configured, using mock")
|
||||
return generateMockImages(size, count)
|
||||
}
|
||||
|
||||
// callImageAPI 调用 OpenAI 兼容 Images API。
|
||||
// callImageAPI 调用 OpenAI 兼容 Images API,5xx 错误自动重试。
|
||||
func callImageAPI(ctx context.Context, prompt string, count, width, height int) ([]GeneratedImage, error) {
|
||||
reqBody := imageGenRequest{
|
||||
Model: imgCfg.Model,
|
||||
@@ -89,25 +110,70 @@ func callImageAPI(ctx context.Context, prompt string, count, width, height int)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/generations"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
maxRetries := imgCfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 2
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
retryDelay := time.Duration(imgCfg.RetryDelay) * time.Second
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = 5 * time.Second
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
l.Warn("retrying image API",
|
||||
"attempt", attempt,
|
||||
"max_retries", maxRetries,
|
||||
)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("context cancelled during retry: %w", ctx.Err())
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
|
||||
|
||||
resp, err := imageHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("send request: %w", err)
|
||||
l.Warn("image API request failed", "error", lastErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return parseImageResponse(ctx, resp.Body, width, height)
|
||||
}
|
||||
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b))
|
||||
l.Error("image API server error",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(b),
|
||||
"attempt", attempt,
|
||||
)
|
||||
continue
|
||||
}
|
||||
// 4xx 不重试
|
||||
l.Error("image API client error",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(b),
|
||||
)
|
||||
return nil, fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return parseImageResponse(ctx, resp.Body, width, height)
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// parseImageResponse 解析 OpenAI 兼容图片响应(b64_json 或 url)。
|
||||
@@ -152,7 +218,7 @@ func downloadImage(ctx context.Context, url string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := imageHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download: %w", err)
|
||||
}
|
||||
@@ -167,10 +233,17 @@ func downloadImage(ctx context.Context, url string) ([]byte, error) {
|
||||
|
||||
// EditImages 图片编辑接口,调用 OpenAI 兼容 Images Edits API(multipart/form-data)。
|
||||
func EditImages(ctx context.Context, imageData []byte, prompt string, count int) ([]GeneratedImage, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
if imgCfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("image API key not configured")
|
||||
}
|
||||
|
||||
l.Info("calling image edit API",
|
||||
"model", imgCfg.Model,
|
||||
"count", count,
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
@@ -199,18 +272,27 @@ func EditImages(ctx context.Context, imageData []byte, prompt string, count int)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := imageHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
l.Error("image edit API request failed", "error", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
l.Error("image edit API error", "status", resp.StatusCode, "body", string(b))
|
||||
return nil, fmt.Errorf("image edit api error %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height)
|
||||
images, err := parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height)
|
||||
if err != nil {
|
||||
l.Error("image edit response parse failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.Info("image edit API succeeded", "image_count", len(images))
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// ======================== 质检 ========================
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gen2d/internal/logger"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
@@ -101,20 +103,32 @@ func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
|
||||
|
||||
// RunPipeline 编译并执行生成管线。
|
||||
func RunPipeline(ctx context.Context, in PipelineInput) (*PipelineOutput, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
l.Info("pipeline started",
|
||||
"asset_type", in.AssetType,
|
||||
"tags", in.Tags,
|
||||
"resolution", in.Params.Resolution,
|
||||
)
|
||||
|
||||
g, err := NewGenerateGraph()
|
||||
if err != nil {
|
||||
l.Error("pipeline create graph failed", "error", err)
|
||||
return nil, fmt.Errorf("create graph: %w", err)
|
||||
}
|
||||
|
||||
r, err := g.Compile(ctx, compose.WithMaxRunSteps(20))
|
||||
if err != nil {
|
||||
l.Error("pipeline compile failed", "error", err)
|
||||
return nil, fmt.Errorf("compile graph: %w", err)
|
||||
}
|
||||
|
||||
output, err := r.Invoke(ctx, in)
|
||||
if err != nil {
|
||||
l.Error("pipeline invoke failed", "error", err)
|
||||
return nil, fmt.Errorf("invoke pipeline: %w", err)
|
||||
}
|
||||
|
||||
l.Info("pipeline completed", "asset_count", len(output.Assets))
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gen2d/internal/config"
|
||||
"gen2d/internal/logger"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
@@ -130,8 +130,10 @@ type chatResponse struct {
|
||||
|
||||
// callLLMRefine 调用 LLM 生成规范化提示词。未配置 API key 时回退到模板生成。
|
||||
func callLLMRefine(ctx context.Context, metaPrompt string) (PromptAgentOutput, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
if llmCfg.APIKey == "" {
|
||||
log.Println("[prompt_agent] LLM API key not configured, using template fallback")
|
||||
l.Warn("LLM API key not configured, using template fallback")
|
||||
return fallbackRefine(metaPrompt), nil
|
||||
}
|
||||
|
||||
@@ -140,12 +142,14 @@ func callLLMRefine(ctx context.Context, metaPrompt string) (PromptAgentOutput, e
|
||||
{Role: "user", Content: metaPrompt},
|
||||
}
|
||||
|
||||
l.Info("calling LLM API", "model", llmCfg.Model)
|
||||
result, err := chatCompletion(ctx, messages)
|
||||
if err != nil {
|
||||
log.Printf("[prompt_agent] LLM API call failed: %v, using template fallback", err)
|
||||
l.Error("LLM API call failed, using template fallback", "error", err)
|
||||
return fallbackRefine(metaPrompt), nil
|
||||
}
|
||||
|
||||
l.Info("LLM API succeeded", "response_length", len(result))
|
||||
return PromptAgentOutput{
|
||||
Prompt: result,
|
||||
RawText: result,
|
||||
@@ -175,6 +179,8 @@ func chatCompletion(ctx context.Context, messages []chatMessage) (string, error)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+llmCfg.APIKey)
|
||||
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send request: %w", err)
|
||||
@@ -183,6 +189,7 @@ func chatCompletion(ctx context.Context, messages []chatMessage) (string, error)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
l.Error("LLM API error", "status", resp.StatusCode, "body", string(b))
|
||||
return "", fmt.Errorf("llm api error %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gen2d/internal/config"
|
||||
"gen2d/internal/logger"
|
||||
|
||||
"github.com/qiniu/go-sdk/v7/storagev2/credentials"
|
||||
"github.com/qiniu/go-sdk/v7/storagev2/objects"
|
||||
"github.com/qiniu/go-sdk/v7/storagev2/uptoken"
|
||||
"github.com/qiniu/go-sdk/v7/storagev2/uploader"
|
||||
)
|
||||
|
||||
// StorageService 七牛云对象存储服务。
|
||||
type StorageService struct {
|
||||
creds *credentials.Credentials
|
||||
cfg config.QiniuConfig
|
||||
objectsMgr *objects.ObjectsManager
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewStorageService 创建 StorageService 实例。
|
||||
func NewStorageService(cfg config.QiniuConfig) *StorageService {
|
||||
creds := credentials.NewCredentials(cfg.AccessKey, cfg.SecretKey)
|
||||
objectsMgr := objects.NewObjectsManager(&objects.ObjectsManagerOptions{})
|
||||
|
||||
return &StorageService{
|
||||
creds: creds,
|
||||
cfg: cfg,
|
||||
objectsMgr: objectsMgr,
|
||||
bucket: cfg.Bucket,
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传图片字节到七牛云,返回 CDN 访问 URL。
|
||||
func (s *StorageService) Upload(ctx context.Context, key string, data []byte) (string, error) {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
putPolicy, err := uptoken.NewPutPolicy(s.bucket, time.Now().Add(1*time.Hour))
|
||||
if err != nil {
|
||||
l.Error("qiniu put policy failed", "key", key, "error", err)
|
||||
return "", fmt.Errorf("qiniu put policy: %w", err)
|
||||
}
|
||||
upToken := uptoken.NewSigner(putPolicy, s.creds)
|
||||
|
||||
uploadMgr := uploader.NewUploadManager(&uploader.UploadManagerOptions{
|
||||
UpTokenProvider: upToken,
|
||||
})
|
||||
|
||||
l.Info("uploading to qiniu", "key", key, "size", len(data))
|
||||
err = uploadMgr.UploadReader(ctx, bytes.NewReader(data), &uploader.ObjectOptions{
|
||||
BucketName: s.bucket,
|
||||
ObjectName: &key,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
l.Error("qiniu upload failed", "key", key, "error", err)
|
||||
return "", fmt.Errorf("qiniu upload: %w", err)
|
||||
}
|
||||
|
||||
cdnURL := s.buildURL(key)
|
||||
l.Info("upload succeeded", "key", key, "url", cdnURL)
|
||||
return cdnURL, nil
|
||||
}
|
||||
|
||||
// GetDownloadURL 生成素材的下载 URL。
|
||||
// 公开 bucket 直接返回 CDN URL;私有 bucket 生成带签名的临时 URL。
|
||||
func (s *StorageService) GetDownloadURL(_ context.Context, key string) (string, error) {
|
||||
return s.buildURL(key), nil
|
||||
}
|
||||
|
||||
// Delete 从七牛云删除指定对象。
|
||||
func (s *StorageService) Delete(ctx context.Context, key string) error {
|
||||
l := logger.FromCtx(ctx)
|
||||
|
||||
bucket := s.objectsMgr.Bucket(s.bucket)
|
||||
err := bucket.Object(key).Delete().Call(ctx)
|
||||
if err != nil {
|
||||
l.Error("qiniu delete failed", "key", key, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
l.Info("delete succeeded", "key", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildURL 根据配置构建完整的 CDN URL。
|
||||
func (s *StorageService) buildURL(key string) string {
|
||||
host := strings.TrimRight(s.cfg.CDNHost, "/")
|
||||
return fmt.Sprintf("%s/%s", host, url.PathEscape(key))
|
||||
}
|
||||
Reference in New Issue
Block a user