Merge branch 'develop' of gitee.com:hezhaohui123/gen2d into feat/async-generate-pipeline
Signed-off-by: 何朝晖 <hezhaohui0807@163.com>
This commit is contained in:
@@ -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