Files
new-api/controller/image_studio.go
OpenClaw Task Bot 92ee465a41 Expose image studio flow and related admin controls
Bundle the current backend and web changes into one publishable snapshot
from the server worktree so the remote mirror reflects the deployed source.

Constraint: Push the existing mixed worktree from /docker/new-api/src without reshaping files
Rejected: Split into feature-specific commits | current worktree is already a single undifferentiated snapshot
Confidence: medium
Scope-risk: moderate
Directive: Split future feature work before deployment to preserve clearer history
Tested: git status inspection; git diff --stat review
Not-tested: build, lint, unit tests, integration tests
2026-05-05 12:16:54 +08:00

1088 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
)
const (
imageStudioGroup = "image"
imageStudioTaskStatusPending = "pending"
imageStudioTaskStatusRunning = "running"
imageStudioTaskStatusSuccess = "success"
imageStudioTaskStatusFailure = "failure"
imageStudioTaskTTL = 30 * time.Minute
imageStudioTaskCleanupEvery = 5 * time.Minute
imageStudioHTTPTimeout = 10 * time.Minute
imageStudioGenerateAction = "generate"
imageStudioEditAction = "edit"
imageStudioResultBaseDir = "/data/image-studio-results"
imageStudioResultURLPrefix = "/image-studio-results"
)
type imageStudioGenerateRequest struct {
TokenID int `json:"token_id"`
Model string `json:"model"`
Prompt string `json:"prompt"`
Size string `json:"size"`
Quality string `json:"quality"`
}
type imageStudioUploadedImage struct {
Filename string
ContentType string
Data []byte
}
type imageStudioTask struct {
ID string `json:"task_id"`
UserID int `json:"-"`
Mode string `json:"mode"`
Model string `json:"model"`
Size string `json:"size"`
Quality string `json:"quality"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ResultDir string `json:"-"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
var (
imageStudioTasks = struct {
sync.RWMutex
items map[string]*imageStudioTask
}{items: make(map[string]*imageStudioTask)}
imageStudioCleanupOnce sync.Once
)
func ensureImageStudioTaskCleanup() {
imageStudioCleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(imageStudioTaskCleanupEvery)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-imageStudioTaskTTL).Unix()
imageStudioTasks.Lock()
for id, task := range imageStudioTasks.items {
if task.UpdatedAt < cutoff {
cleanupImageStudioTaskArtifacts(task)
delete(imageStudioTasks.items, id)
}
}
imageStudioTasks.Unlock()
}
}()
})
}
func newImageStudioTask(userID int, mode string, req imageStudioGenerateRequest) (*imageStudioTask, error) {
ensureImageStudioTaskCleanup()
key, err := common.GenerateRandomCharsKey(24)
if err != nil {
return nil, err
}
now := time.Now().Unix()
task := &imageStudioTask{
ID: "imgtask_" + key,
UserID: userID,
Mode: mode,
Model: strings.TrimSpace(req.Model),
Size: resolveImageStudioSize(req.Model, req.Size),
Quality: normalizeImageStudioQuality(req.Quality),
Status: imageStudioTaskStatusPending,
CreatedAt: now,
UpdatedAt: now,
}
imageStudioTasks.Lock()
imageStudioTasks.items[task.ID] = task
imageStudioTasks.Unlock()
return task, nil
}
func getImageStudioTask(taskID string) (*imageStudioTask, bool) {
imageStudioTasks.RLock()
defer imageStudioTasks.RUnlock()
task, ok := imageStudioTasks.items[taskID]
return task, ok
}
func updateImageStudioTask(taskID string, updateFn func(task *imageStudioTask)) {
imageStudioTasks.Lock()
defer imageStudioTasks.Unlock()
if task, ok := imageStudioTasks.items[taskID]; ok {
updateFn(task)
task.UpdatedAt = time.Now().Unix()
}
}
func cleanupImageStudioTaskArtifacts(task *imageStudioTask) {
if task == nil || strings.TrimSpace(task.ResultDir) == "" {
return
}
baseDir := filepath.Clean(imageStudioResultBaseDir)
resultDir := filepath.Clean(task.ResultDir)
prefix := baseDir + string(os.PathSeparator)
if resultDir == baseDir || !strings.HasPrefix(resultDir, prefix) {
return
}
_ = os.RemoveAll(resultDir)
task.ResultDir = ""
}
func normalizeImageStudioSize(v string) string {
value := strings.TrimSpace(strings.ToLower(v))
switch value {
case "", "auto":
return "auto"
case "1024x1024", "1536x1024", "1024x1536", "2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840":
return value
default:
return "auto"
}
}
func normalizeImageStudioModel(v string) string {
return strings.TrimSpace(strings.ToLower(v))
}
func isGPTImage2Model(modelName string) bool {
return normalizeImageStudioModel(modelName) == "gpt-image-2"
}
func isValidGPTImage2Size(size string) bool {
if size == "" || size == "auto" {
return true
}
parts := strings.Split(size, "x")
if len(parts) != 2 {
return false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return false
}
if width <= 0 || height <= 0 {
return false
}
if width > 3840 || height > 3840 {
return false
}
if width%16 != 0 || height%16 != 0 {
return false
}
longEdge := width
shortEdge := height
if height > width {
longEdge = height
shortEdge = width
}
if longEdge > shortEdge*3 {
return false
}
pixels := width * height
if pixels < 655360 || pixels > 8294400 {
return false
}
return true
}
func resolveImageStudioSize(modelName string, size string) string {
normalizedSize := normalizeImageStudioSize(size)
if isGPTImage2Model(modelName) {
candidate := strings.TrimSpace(strings.ToLower(size))
if isValidGPTImage2Size(candidate) {
return candidate
}
if normalizedSize != "auto" {
return normalizedSize
}
}
return normalizedSize
}
func normalizeImageStudioQuality(v string) string {
value := strings.TrimSpace(strings.ToLower(v))
switch value {
case "", "auto":
return "auto"
case "low", "medium", "high":
return value
default:
return "auto"
}
}
func normalizeImageStudioBearerToken(key string) string {
trimmed := strings.TrimSpace(key)
if strings.HasPrefix(trimmed, "sk-") {
return trimmed
}
return "sk-" + trimmed
}
var imageStudioDisallowedKeywords = []string{
"暴力",
"血腥",
"伤害",
"虐待",
"自残",
"自杀",
"恐怖",
"战争",
"武器",
"尸体",
"残肢",
"恶心",
"色情",
"裸露",
"性暗示",
"卖淫",
"约炮",
"成人视频",
"强奷",
"sm",
"性暴力",
"性服务",
"赌博",
"博彩",
"赌场",
"下注",
"洗钱",
"毒品",
"制毒",
"吸毒",
"贩毒",
"违禁药物",
"政治敏感",
"反动",
"分裂",
"颠覆",
"煽动",
"国家领导人",
"国旗",
"国徽",
"国歌",
"恐怖主义",
"暴恐",
}
func buildImageStudioSafetyPrompt() string {
return setting.ImageStudioSafetyPrompt
}
func normalizeImageStudioPromptForSafety(prompt string) string {
replacer := strings.NewReplacer(
" ", "",
"\n", "",
"\r", "",
"\t", "",
"-", "",
"_", "",
"/", "",
"\\", "",
"|", "",
"*", "",
".", "",
",", "",
"", "",
"。", "",
":", "",
"", "",
";", "",
"", "",
"(", "",
")", "",
"", "",
"", "",
"[", "",
"]", "",
"【", "",
"】", "",
"{", "",
"}", "",
"<", "",
">", "",
"《", "",
"》", "",
"'", "",
"\"", "",
"`", "",
"!", "",
"", "",
"?", "",
"", "",
)
return strings.ToLower(replacer.Replace(strings.TrimSpace(prompt)))
}
func containsImageStudioDisallowedKeyword(prompt string) (bool, string) {
checkText := normalizeImageStudioPromptForSafety(prompt)
for _, keyword := range setting.ImageStudioBannedWords {
k := normalizeImageStudioPromptForSafety(keyword)
if k == "" {
continue
}
if strings.Contains(checkText, k) {
return true, keyword
}
}
return false, ""
}
func validateImageStudioPromptSafety(prompt string) error {
trimmed := strings.TrimSpace(prompt)
if trimmed == "" {
return fmt.Errorf("请先填写提示词")
}
if ok, keyword := containsImageStudioDisallowedKeyword(trimmed); ok {
return fmt.Errorf("提示词包含禁止内容:%s", keyword)
}
if ok, words := service.SensitiveWordContains(trimmed); ok {
if len(words) > 0 {
return fmt.Errorf("提示词包含敏感内容:%s", strings.Join(words, ", "))
}
return fmt.Errorf("提示词包含敏感内容,请修改后重试")
}
return nil
}
func imageStudioModeEndpointType(mode string) constant.EndpointType {
if mode == imageStudioEditAction {
return constant.EndpointTypeImageEdit
}
return constant.EndpointTypeImageGeneration
}
func imageStudioModePath(mode string) string {
if mode == imageStudioEditAction {
return "/v1/images/edits"
}
return "/v1/images/generations"
}
func resolveImageStudioGenerateRelayAction(images []imageStudioUploadedImage) string {
if len(images) > 0 {
return imageStudioEditAction
}
return imageStudioGenerateAction
}
func validateImageStudioTokenAndModel(userID int, req imageStudioGenerateRequest, mode string) (*model.Token, error) {
token, err := model.GetTokenByIds(req.TokenID, userID)
if err != nil {
return nil, fmt.Errorf("未找到所选令牌,请刷新后重试")
}
if token.Status != common.TokenStatusEnabled {
return nil, fmt.Errorf("当前令牌状态不可用,请去控制台-令牌管理检查该令牌")
}
if strings.TrimSpace(token.Group) != imageStudioGroup {
return nil, fmt.Errorf("所选令牌不是 image 分组,请去控制台-令牌管理创建或选择 image 分组令牌")
}
modelName := strings.TrimSpace(req.Model)
if modelName == "" {
return nil, fmt.Errorf("请选择图片模型")
}
if isGPTImage2Model(modelName) && !isValidGPTImage2Size(strings.TrimSpace(strings.ToLower(req.Size))) {
return nil, fmt.Errorf("gpt-image-2 尺寸不合法:最长边不能超过 3840边长需为 16 的倍数,长宽比不能超过 3:1总像素需在 655360 到 8294400 之间")
}
if token.ModelLimitsEnabled {
limits := token.GetModelLimitsMap()
if len(limits) == 0 || !limits[modelName] {
return nil, fmt.Errorf("当前令牌没有放行该图片模型,请去控制台-令牌管理调整模型限制")
}
}
pricingItems := model.GetPricing()
var matched *model.Pricing
for index := range pricingItems {
item := pricingItems[index]
if item.ModelName != modelName {
continue
}
if !containsString(item.EnableGroup, imageStudioGroup) {
continue
}
matched = &item
break
}
if matched == nil {
return nil, fmt.Errorf("当前 image 分组未配置该图片模型,请联系管理员检查 image 分组和模型配置")
}
if !containsEndpointType(matched.SupportedEndpointTypes, imageStudioModeEndpointType(mode)) {
if mode == imageStudioEditAction {
return nil, fmt.Errorf("当前模型不支持编辑图片,请联系管理员检查模型能力配置")
}
return nil, fmt.Errorf("当前模型不支持生成图片,请联系管理员检查模型能力配置")
}
return token, nil
}
func containsString(items []string, target string) bool {
for _, item := range items {
if strings.TrimSpace(item) == target {
return true
}
}
return false
}
func containsEndpointType(items []constant.EndpointType, target constant.EndpointType) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
func readImageStudioUploads(c *gin.Context, required bool) ([]imageStudioUploadedImage, error) {
form, err := c.MultipartForm()
if err != nil {
return nil, err
}
fileHeaders := form.File["image[]"]
if len(fileHeaders) == 0 {
fileHeaders = form.File["images[]"]
}
if len(fileHeaders) == 0 {
fileHeaders = form.File["image"]
}
if len(fileHeaders) == 0 {
if required {
return nil, fmt.Errorf("编辑图片至少需要上传一张参考图")
}
return nil, nil
}
images := make([]imageStudioUploadedImage, 0, len(fileHeaders))
for _, header := range fileHeaders {
image, err := readImageStudioUpload(header)
if err != nil {
return nil, err
}
images = append(images, image)
}
return images, nil
}
func readImageStudioUpload(header *multipart.FileHeader) (imageStudioUploadedImage, error) {
file, err := header.Open()
if err != nil {
return imageStudioUploadedImage{}, err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return imageStudioUploadedImage{}, err
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(data)
}
return imageStudioUploadedImage{
Filename: header.Filename,
ContentType: contentType,
Data: data,
}, nil
}
func buildImageStudioInternalURL(mode string) string {
return fmt.Sprintf("http://127.0.0.1:%d%s", *common.Port, imageStudioModePath(mode))
}
func decodeImageStudioError(body []byte, fallback string) string {
if len(body) == 0 {
return fallback
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err == nil {
if errorPart, ok := payload["error"].(map[string]any); ok {
if message, ok := errorPart["message"].(string); ok && strings.TrimSpace(message) != "" {
return message
}
}
if message, ok := payload["message"].(string); ok && strings.TrimSpace(message) != "" {
return message
}
}
return fallback
}
func getImageStudioResultExtension(outputFormat string) string {
switch strings.ToLower(strings.TrimSpace(outputFormat)) {
case "jpg", "jpeg":
return "jpg"
case "webp":
return "webp"
default:
return "png"
}
}
func signImageStudioResult(relativePath string, expiresAt int64) string {
mac := hmac.New(sha256.New, []byte(common.CryptoSecret))
_, _ = mac.Write([]byte(relativePath))
_, _ = mac.Write([]byte("\n"))
_, _ = mac.Write([]byte(strconv.FormatInt(expiresAt, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
func buildImageStudioResultFileURL(taskID string, index int, outputFormat string) string {
relativePath := fmt.Sprintf("%s/%d.%s", taskID, index, getImageStudioResultExtension(outputFormat))
expiresAt := time.Now().Add(imageStudioTaskTTL).Unix()
signature := signImageStudioResult(relativePath, expiresAt)
return fmt.Sprintf("%s/%s?expires=%d&sig=%s", imageStudioResultURLPrefix, relativePath, expiresAt, signature)
}
func persistImageStudioTaskResult(taskID string, respBody []byte) (json.RawMessage, string, error) {
var payload map[string]any
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, "", err
}
data, ok := payload["data"].([]any)
if !ok || len(data) == 0 {
return json.RawMessage(respBody), "", nil
}
resultDir := filepath.Join(imageStudioResultBaseDir, taskID)
wroteFiles := false
for index, rawItem := range data {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
b64, _ := item["b64_json"].(string)
b64 = strings.TrimSpace(b64)
if b64 == "" {
continue
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
_ = os.RemoveAll(resultDir)
return nil, "", fmt.Errorf("图片结果解码失败")
}
if !wroteFiles {
_ = os.RemoveAll(resultDir)
if err := os.MkdirAll(resultDir, 0o755); err != nil {
return nil, "", fmt.Errorf("图片结果目录创建失败")
}
}
outputFormat, _ := item["output_format"].(string)
ext := getImageStudioResultExtension(outputFormat)
filename := fmt.Sprintf("%d.%s", index, ext)
fullPath := filepath.Join(resultDir, filename)
if err := os.WriteFile(fullPath, decoded, 0o644); err != nil {
_ = os.RemoveAll(resultDir)
return nil, "", fmt.Errorf("图片结果写入失败")
}
delete(item, "b64_json")
item["url"] = buildImageStudioResultFileURL(taskID, index, outputFormat)
wroteFiles = true
}
if !wroteFiles {
return json.RawMessage(respBody), "", nil
}
encoded, err := json.Marshal(payload)
if err != nil {
_ = os.RemoveAll(resultDir)
return nil, "", fmt.Errorf("图片结果序列化失败")
}
return json.RawMessage(encoded), resultDir, nil
}
func executeImageStudioGenerateTask(taskID string, tokenKey string, req imageStudioGenerateRequest, images []imageStudioUploadedImage) {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusRunning
task.Error = ""
})
relayAction := resolveImageStudioGenerateRelayAction(images)
var body bytes.Buffer
contentType := "application/json"
if len(images) > 0 {
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", strings.TrimSpace(req.Model))
_ = writer.WriteField("prompt", buildImageStudioSafetyPrompt()+"\n\n"+strings.TrimSpace(req.Prompt))
_ = writer.WriteField("size", resolveImageStudioSize(req.Model, req.Size))
_ = writer.WriteField("quality", normalizeImageStudioQuality(req.Quality))
_ = writer.WriteField("response_format", "b64_json")
_ = writer.WriteField("output_format", "png")
for _, image := range images {
headers := make(textproto.MIMEHeader)
headers.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image[]"; filename="%s"`, image.Filename))
headers.Set("Content-Type", image.ContentType)
part, err := writer.CreatePart(headers)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成参考图封装失败"
})
_ = writer.Close()
return
}
if _, err = part.Write(image.Data); err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成参考图写入失败"
})
_ = writer.Close()
return
}
}
if err := writer.Close(); err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成请求构建失败"
})
return
}
contentType = writer.FormDataContentType()
} else {
payload, err := json.Marshal(gin.H{
"model": strings.TrimSpace(req.Model),
"prompt": buildImageStudioSafetyPrompt() + "\n\n" + strings.TrimSpace(req.Prompt),
"size": resolveImageStudioSize(req.Model, req.Size),
"quality": normalizeImageStudioQuality(req.Quality),
"response_format": "b64_json",
"output_format": "png",
})
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成请求构建失败"
})
return
}
body.Write(payload)
}
httpReq, err := http.NewRequest(http.MethodPost, buildImageStudioInternalURL(relayAction), bytes.NewReader(body.Bytes()))
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成请求创建失败"
})
return
}
httpReq.Header.Set("Authorization", "Bearer "+normalizeImageStudioBearerToken(tokenKey))
httpReq.Header.Set("Content-Type", contentType)
client := &http.Client{Timeout: imageStudioHTTPTimeout}
resp, err := client.Do(httpReq)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "图片生成请求失败:" + err.Error()
})
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "读取图片生成结果失败"
})
return
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = decodeImageStudioError(respBody, "图片生成失败")
})
return
}
sanitizedResult, resultDir, err := persistImageStudioTaskResult(taskID, respBody)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = err.Error()
})
return
}
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusSuccess
cleanupImageStudioTaskArtifacts(task)
task.Result = sanitizedResult
task.ResultDir = resultDir
task.Error = ""
})
}
func executeImageStudioEditTask(taskID string, tokenKey string, req imageStudioGenerateRequest, images []imageStudioUploadedImage) {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusRunning
task.Error = ""
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", strings.TrimSpace(req.Model))
_ = writer.WriteField("prompt", buildImageStudioSafetyPrompt()+"\n\n"+strings.TrimSpace(req.Prompt))
_ = writer.WriteField("size", resolveImageStudioSize(req.Model, req.Size))
_ = writer.WriteField("quality", normalizeImageStudioQuality(req.Quality))
_ = writer.WriteField("response_format", "b64_json")
_ = writer.WriteField("output_format", "png")
for _, image := range images {
headers := make(textproto.MIMEHeader)
headers.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image[]"; filename="%s"`, image.Filename))
headers.Set("Content-Type", image.ContentType)
part, err := writer.CreatePart(headers)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "编辑图片参考图封装失败"
})
_ = writer.Close()
return
}
if _, err = part.Write(image.Data); err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "编辑图片参考图写入失败"
})
_ = writer.Close()
return
}
}
if err := writer.Close(); err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "编辑图片请求构建失败"
})
return
}
httpReq, err := http.NewRequest(http.MethodPost, buildImageStudioInternalURL(imageStudioEditAction), bytes.NewReader(body.Bytes()))
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "编辑图片请求创建失败"
})
return
}
httpReq.Header.Set("Authorization", "Bearer "+normalizeImageStudioBearerToken(tokenKey))
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: imageStudioHTTPTimeout}
resp, err := client.Do(httpReq)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "编辑图片请求失败:" + err.Error()
})
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = "读取编辑图片结果失败"
})
return
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = decodeImageStudioError(respBody, "图片编辑失败")
})
return
}
sanitizedResult, resultDir, err := persistImageStudioTaskResult(taskID, respBody)
if err != nil {
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusFailure
task.Error = err.Error()
})
return
}
updateImageStudioTask(taskID, func(task *imageStudioTask) {
task.Status = imageStudioTaskStatusSuccess
cleanupImageStudioTaskArtifacts(task)
task.Result = sanitizedResult
task.ResultDir = resultDir
task.Error = ""
})
}
func SubmitImageStudioGenerate(c *gin.Context) {
userID := c.GetInt("id")
var req imageStudioGenerateRequest
var images []imageStudioUploadedImage
var err error
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "multipart/form-data") {
req = imageStudioGenerateRequest{
Model: c.PostForm("model"),
Prompt: strings.TrimSpace(c.PostForm("prompt")),
Size: c.PostForm("size"),
Quality: c.PostForm("quality"),
}
if tokenID := strings.TrimSpace(c.PostForm("token_id")); tokenID != "" {
_, _ = fmt.Sscanf(tokenID, "%d", &req.TokenID)
}
images, err = readImageStudioUploads(c, false)
if err != nil {
common.ApiErrorMsg(c, "图片生成参考图读取失败,请重新上传后重试")
return
}
} else if err = c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "图片生成参数格式不正确,请刷新后重试")
return
}
req.Prompt = strings.TrimSpace(req.Prompt)
if err := validateImageStudioPromptSafety(req.Prompt); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
validationMode := imageStudioGenerateAction
if len(images) > 0 {
validationMode = imageStudioEditAction
}
token, err := validateImageStudioTokenAndModel(userID, req, validationMode)
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
task, err := newImageStudioTask(userID, imageStudioGenerateAction, req)
if err != nil {
common.ApiErrorMsg(c, "创建图片任务失败,请稍后重试")
return
}
go executeImageStudioGenerateTask(task.ID, token.GetFullKey(), req, images)
common.ApiSuccess(c, task)
}
func SubmitImageStudioEdit(c *gin.Context) {
userID := c.GetInt("id")
req := imageStudioGenerateRequest{
Model: c.PostForm("model"),
Prompt: strings.TrimSpace(c.PostForm("prompt")),
Size: c.PostForm("size"),
Quality: c.PostForm("quality"),
}
if tokenID := strings.TrimSpace(c.PostForm("token_id")); tokenID != "" {
_, _ = fmt.Sscanf(tokenID, "%d", &req.TokenID)
}
if err := validateImageStudioPromptSafety(req.Prompt); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
images, err := readImageStudioUploads(c, true)
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
token, err := validateImageStudioTokenAndModel(userID, req, imageStudioEditAction)
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
task, err := newImageStudioTask(userID, imageStudioEditAction, req)
if err != nil {
common.ApiErrorMsg(c, "创建图片任务失败,请稍后重试")
return
}
go executeImageStudioEditTask(task.ID, token.GetFullKey(), req, images)
common.ApiSuccess(c, task)
}
func buildImageStudioTaskResultURL(taskID string, index int) string {
return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index)
}
func getImageStudioResultContentType(outputFormat string) string {
switch strings.ToLower(strings.TrimSpace(outputFormat)) {
case "jpg", "jpeg":
return "image/jpeg"
case "webp":
return "image/webp"
default:
return "image/png"
}
}
func sanitizeImageStudioTaskForResponse(task *imageStudioTask) *imageStudioTask {
if task == nil {
return nil
}
clone := *task
if clone.Status != imageStudioTaskStatusSuccess || len(clone.Result) == 0 {
return &clone
}
var payload map[string]any
if err := json.Unmarshal(clone.Result, &payload); err != nil {
return &clone
}
data, ok := payload["data"].([]any)
if !ok || len(data) == 0 {
return &clone
}
changed := false
for index, rawItem := range data {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" {
delete(item, "b64_json")
item["url"] = buildImageStudioTaskResultURL(task.ID, index)
changed = true
}
}
if !changed {
return &clone
}
if encoded, err := json.Marshal(payload); err == nil {
clone.Result = json.RawMessage(encoded)
}
return &clone
}
func GetImageStudioTask(c *gin.Context) {
userID := c.GetInt("id")
taskID := strings.TrimSpace(c.Param("id"))
if taskID == "" {
common.ApiErrorMsg(c, "任务不存在")
return
}
task, ok := getImageStudioTask(taskID)
if !ok || task == nil || task.UserID != userID {
common.ApiErrorMsg(c, "任务不存在或已过期,请重新提交")
return
}
common.ApiSuccess(c, sanitizeImageStudioTaskForResponse(task))
}
func GetImageStudioTaskResult(c *gin.Context) {
userID := c.GetInt("id")
taskID := strings.TrimSpace(c.Param("id"))
if taskID == "" {
common.ApiErrorMsg(c, "任务不存在")
return
}
index, err := strconv.Atoi(strings.TrimSpace(c.Param("index")))
if err != nil || index < 0 {
common.ApiErrorMsg(c, "图片结果不存在")
return
}
task, ok := getImageStudioTask(taskID)
if !ok || task == nil || task.UserID != userID {
common.ApiErrorMsg(c, "任务不存在或已过期,请重新提交")
return
}
if task.Status != imageStudioTaskStatusSuccess || len(task.Result) == 0 {
common.ApiErrorMsg(c, "图片结果尚未生成完成")
return
}
var payload map[string]any
if err := json.Unmarshal(task.Result, &payload); err != nil {
common.ApiErrorMsg(c, "图片结果解析失败")
return
}
data, ok := payload["data"].([]any)
if !ok || index >= len(data) {
common.ApiErrorMsg(c, "图片结果不存在")
return
}
item, ok := data[index].(map[string]any)
if !ok {
common.ApiErrorMsg(c, "图片结果不存在")
return
}
if url, _ := item["url"].(string); strings.TrimSpace(url) != "" {
c.Redirect(http.StatusTemporaryRedirect, url)
return
}
b64, _ := item["b64_json"].(string)
if strings.TrimSpace(b64) == "" {
common.ApiErrorMsg(c, "图片结果不存在")
return
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
common.ApiErrorMsg(c, "图片结果解码失败")
return
}
contentType := getImageStudioResultContentType(fmt.Sprintf("%v", item["output_format"]))
c.Header("Cache-Control", "private, no-store, max-age=0")
c.Data(http.StatusOK, contentType, decoded)
}
func GetImageStudioResultFile(c *gin.Context) {
relativePath := strings.TrimPrefix(c.Param("filepath"), "/")
relativePath = strings.TrimSpace(relativePath)
if relativePath == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
expiresAt, err := strconv.ParseInt(strings.TrimSpace(c.Query("expires")), 10, 64)
if err != nil || expiresAt <= 0 || time.Now().Unix() > expiresAt {
c.AbortWithStatus(http.StatusForbidden)
return
}
sig := strings.TrimSpace(c.Query("sig"))
if sig == "" {
c.AbortWithStatus(http.StatusForbidden)
return
}
expected := signImageStudioResult(relativePath, expiresAt)
if !hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) {
c.AbortWithStatus(http.StatusForbidden)
return
}
baseDir := filepath.Clean(imageStudioResultBaseDir)
fullPath := filepath.Clean(filepath.Join(baseDir, filepath.Clean(relativePath)))
prefix := baseDir + string(os.PathSeparator)
if fullPath == baseDir || !strings.HasPrefix(fullPath, prefix) {
c.AbortWithStatus(http.StatusNotFound)
return
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Header("Cache-Control", "private, no-store, no-cache, max-age=0")
c.Header("Pragma", "no-cache")
c.File(fullPath)
}