Align console behavior with operator routing and image workflow expectations
Constraint: Must preserve current exchange-rate logic, same-group routing boundary, and temporary image-file behavior while fixing UX-visible inconsistencies. Rejected: Large frontend chunk refactor | introduced risky circular chunking and did not improve deliverability for this rollout. Confidence: medium Scope-risk: moderate Directive: Keep channel failover constrained to the selected group and preserve Beijing-day semantics for user cache dashboard metrics. Tested: go test ./controller ./model ./service ./common -count=1; npm run build; remote smoke on source deployment at http://38.76.218.56:18081/ plus /api/status and /api/image-studio/settings. Not-tested: Browser-driven end-to-end verification of every console page interaction.
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -28,18 +29,17 @@ import (
|
||||
)
|
||||
|
||||
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"
|
||||
imageStudioGroup = "image"
|
||||
imageStudioTaskStatusPending = "pending"
|
||||
imageStudioTaskStatusRunning = "running"
|
||||
imageStudioTaskStatusSuccess = "success"
|
||||
imageStudioTaskStatusFailure = "failure"
|
||||
imageStudioTaskCleanupEvery = 5 * time.Minute
|
||||
imageStudioHTTPTimeout = 10 * time.Minute
|
||||
imageStudioGenerateAction = "generate"
|
||||
imageStudioEditAction = "edit"
|
||||
imageStudioResultBaseDir = "/data/image-studio-results"
|
||||
imageStudioResultURLPrefix = "/image-studio-results"
|
||||
)
|
||||
|
||||
type imageStudioGenerateRequest struct {
|
||||
@@ -71,21 +71,325 @@ type imageStudioTask struct {
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type imageStudioModelSetting struct {
|
||||
Model string `json:"model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowGenerate bool `json:"allow_generate"`
|
||||
AllowEdit bool `json:"allow_edit"`
|
||||
AllowRatio bool `json:"allow_ratio"`
|
||||
AllowResolution bool `json:"allow_resolution"`
|
||||
AllowQuality bool `json:"allow_quality"`
|
||||
AllowedSizes []string `json:"allowed_sizes,omitempty"`
|
||||
AllowedQualities []string `json:"allowed_qualities,omitempty"`
|
||||
DefaultSize string `json:"default_size,omitempty"`
|
||||
DefaultQuality string `json:"default_quality,omitempty"`
|
||||
}
|
||||
|
||||
type imageStudioSettingsPayload struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultModel string `json:"default_model,omitempty"`
|
||||
AllowOpenOriginal bool `json:"allow_open_original"`
|
||||
RetentionMinutes int `json:"retention_minutes"`
|
||||
AllowedModels []string `json:"allowed_models,omitempty"`
|
||||
ModelSettings []imageStudioModelSetting `json:"model_settings,omitempty"`
|
||||
}
|
||||
|
||||
var imageStudioSizeRatios = map[string]string{
|
||||
"auto": "auto",
|
||||
"1024x1024": "1:1",
|
||||
"1536x1024": "3:2",
|
||||
"1024x1536": "2:3",
|
||||
"2048x2048": "1:1",
|
||||
"2048x1152": "16:9",
|
||||
"1152x2048": "9:16",
|
||||
"3840x2160": "16:9",
|
||||
"2160x3840": "9:16",
|
||||
}
|
||||
|
||||
var (
|
||||
imageStudioTasks = struct {
|
||||
sync.RWMutex
|
||||
items map[string]*imageStudioTask
|
||||
}{items: make(map[string]*imageStudioTask)}
|
||||
imageStudioCleanupOnce sync.Once
|
||||
imageStudioSettingsCache = struct {
|
||||
sync.RWMutex
|
||||
raw string
|
||||
settings imageStudioSettingsPayload
|
||||
}{}
|
||||
imageStudioTTLCache = struct {
|
||||
sync.RWMutex
|
||||
raw string
|
||||
ttl time.Duration
|
||||
}{}
|
||||
)
|
||||
|
||||
func getImageStudioTaskTTL() time.Duration {
|
||||
if raw, ok := common.OptionMap["ImageStudioRetentionMinutes"]; ok {
|
||||
normalized := strings.TrimSpace(raw)
|
||||
imageStudioTTLCache.RLock()
|
||||
if imageStudioTTLCache.raw == normalized && imageStudioTTLCache.ttl > 0 {
|
||||
ttl := imageStudioTTLCache.ttl
|
||||
imageStudioTTLCache.RUnlock()
|
||||
return ttl
|
||||
}
|
||||
imageStudioTTLCache.RUnlock()
|
||||
minutes := 30
|
||||
if parsed, err := strconv.Atoi(normalized); err == nil && parsed > 0 {
|
||||
minutes = parsed
|
||||
}
|
||||
ttl := time.Duration(minutes) * time.Minute
|
||||
imageStudioTTLCache.Lock()
|
||||
imageStudioTTLCache.raw = normalized
|
||||
imageStudioTTLCache.ttl = ttl
|
||||
imageStudioTTLCache.Unlock()
|
||||
return ttl
|
||||
}
|
||||
return 30 * time.Minute
|
||||
}
|
||||
|
||||
func normalizeImageStudioSizeList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
size := normalizeImageStudioSize(item)
|
||||
if size == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[size]; ok {
|
||||
continue
|
||||
}
|
||||
seen[size] = struct{}{}
|
||||
result = append(result, size)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeImageStudioQualityList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
quality := normalizeImageStudioQuality(item)
|
||||
if quality == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[quality]; ok {
|
||||
continue
|
||||
}
|
||||
seen[quality] = struct{}{}
|
||||
result = append(result, quality)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeImageStudioModelSetting(item imageStudioModelSetting) imageStudioModelSetting {
|
||||
item.Model = strings.TrimSpace(item.Model)
|
||||
item.AllowedSizes = normalizeImageStudioSizeList(item.AllowedSizes)
|
||||
item.AllowedQualities = normalizeImageStudioQualityList(item.AllowedQualities)
|
||||
item.DefaultSize = normalizeImageStudioSize(item.DefaultSize)
|
||||
item.DefaultQuality = normalizeImageStudioQuality(item.DefaultQuality)
|
||||
if item.DefaultSize == "" {
|
||||
item.DefaultSize = "auto"
|
||||
}
|
||||
if item.DefaultQuality == "" {
|
||||
item.DefaultQuality = "auto"
|
||||
}
|
||||
if !item.AllowGenerate && !item.AllowEdit {
|
||||
item.AllowGenerate = true
|
||||
}
|
||||
if len(item.AllowedSizes) == 0 {
|
||||
item.AllowedSizes = []string{"auto"}
|
||||
}
|
||||
if len(item.AllowedQualities) == 0 {
|
||||
item.AllowedQualities = []string{"auto", "low", "medium", "high"}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func getDefaultImageStudioSettings() imageStudioSettingsPayload {
|
||||
return imageStudioSettingsPayload{
|
||||
Enabled: true,
|
||||
DefaultModel: "gpt-image-2",
|
||||
AllowOpenOriginal: true,
|
||||
RetentionMinutes: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func getImageStudioSettings() imageStudioSettingsPayload {
|
||||
raw, _ := common.OptionMap["ImageStudioSettings"]
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
imageStudioSettingsCache.RLock()
|
||||
if imageStudioSettingsCache.raw == raw {
|
||||
cached := imageStudioSettingsCache.settings
|
||||
imageStudioSettingsCache.RUnlock()
|
||||
return cached
|
||||
}
|
||||
imageStudioSettingsCache.RUnlock()
|
||||
|
||||
settings := getDefaultImageStudioSettings()
|
||||
if raw == "" {
|
||||
imageStudioSettingsCache.Lock()
|
||||
imageStudioSettingsCache.raw = raw
|
||||
imageStudioSettingsCache.settings = settings
|
||||
imageStudioSettingsCache.Unlock()
|
||||
return settings
|
||||
}
|
||||
var parsed imageStudioSettingsPayload
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
imageStudioSettingsCache.Lock()
|
||||
imageStudioSettingsCache.raw = raw
|
||||
imageStudioSettingsCache.settings = settings
|
||||
imageStudioSettingsCache.Unlock()
|
||||
return settings
|
||||
}
|
||||
settings.Enabled = parsed.Enabled
|
||||
if strings.TrimSpace(parsed.DefaultModel) != "" {
|
||||
settings.DefaultModel = strings.TrimSpace(parsed.DefaultModel)
|
||||
}
|
||||
settings.AllowOpenOriginal = parsed.AllowOpenOriginal
|
||||
if parsed.RetentionMinutes > 0 {
|
||||
settings.RetentionMinutes = parsed.RetentionMinutes
|
||||
}
|
||||
if len(parsed.AllowedModels) > 0 {
|
||||
seen := make(map[string]struct{}, len(parsed.AllowedModels))
|
||||
settings.AllowedModels = make([]string, 0, len(parsed.AllowedModels))
|
||||
for _, item := range parsed.AllowedModels {
|
||||
modelName := strings.TrimSpace(item)
|
||||
if modelName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[modelName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[modelName] = struct{}{}
|
||||
settings.AllowedModels = append(settings.AllowedModels, modelName)
|
||||
}
|
||||
}
|
||||
if len(parsed.ModelSettings) > 0 {
|
||||
seen := make(map[string]struct{}, len(parsed.ModelSettings))
|
||||
settings.ModelSettings = make([]imageStudioModelSetting, 0, len(parsed.ModelSettings))
|
||||
for _, item := range parsed.ModelSettings {
|
||||
normalized := normalizeImageStudioModelSetting(item)
|
||||
if normalized.Model == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized.Model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized.Model] = struct{}{}
|
||||
settings.ModelSettings = append(settings.ModelSettings, normalized)
|
||||
}
|
||||
}
|
||||
imageStudioSettingsCache.Lock()
|
||||
imageStudioSettingsCache.raw = raw
|
||||
imageStudioSettingsCache.settings = settings
|
||||
imageStudioSettingsCache.Unlock()
|
||||
return settings
|
||||
}
|
||||
|
||||
func getImageStudioModelSetting(modelName string) (imageStudioModelSetting, bool) {
|
||||
settings := getImageStudioSettings()
|
||||
for _, item := range settings.ModelSettings {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Model), strings.TrimSpace(modelName)) {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return imageStudioModelSetting{}, false
|
||||
}
|
||||
|
||||
func shouldAllowImageStudioModel(modelName string) bool {
|
||||
settings := getImageStudioSettings()
|
||||
if len(settings.AllowedModels) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range settings.AllowedModels {
|
||||
if strings.EqualFold(strings.TrimSpace(item), strings.TrimSpace(modelName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getImageStudioAllowedSizesForModel(modelName string) []string {
|
||||
if setting, ok := getImageStudioModelSetting(modelName); ok && len(setting.AllowedSizes) > 0 {
|
||||
return setting.AllowedSizes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getImageStudioAllowedQualitiesForModel(modelName string) []string {
|
||||
if setting, ok := getImageStudioModelSetting(modelName); ok && len(setting.AllowedQualities) > 0 {
|
||||
return setting.AllowedQualities
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildImageStudioPublicSettings() gin.H {
|
||||
settings := getImageStudioSettings()
|
||||
pricingItems := model.GetPricing()
|
||||
allowedModels := make([]string, 0)
|
||||
modelSettings := make([]imageStudioModelSetting, 0)
|
||||
models := make([]gin.H, 0)
|
||||
seenModels := make(map[string]struct{})
|
||||
for _, item := range pricingItems {
|
||||
if !containsString(item.EnableGroup, imageStudioGroup) {
|
||||
continue
|
||||
}
|
||||
if !shouldAllowImageStudioModel(item.ModelName) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenModels[item.ModelName]; ok {
|
||||
continue
|
||||
}
|
||||
seenModels[item.ModelName] = struct{}{}
|
||||
allowedModels = append(allowedModels, item.ModelName)
|
||||
models = append(models, gin.H{
|
||||
"model_name": item.ModelName,
|
||||
"enable_groups": item.EnableGroup,
|
||||
"supported_endpoint_types": item.SupportedEndpointTypes,
|
||||
})
|
||||
if ms, ok := getImageStudioModelSetting(item.ModelName); ok {
|
||||
modelSettings = append(modelSettings, ms)
|
||||
}
|
||||
}
|
||||
sort.Strings(allowedModels)
|
||||
sort.SliceStable(models, func(i, j int) bool {
|
||||
return fmt.Sprintf("%v", models[i]["model_name"]) < fmt.Sprintf("%v", models[j]["model_name"])
|
||||
})
|
||||
sort.SliceStable(modelSettings, func(i, j int) bool {
|
||||
return modelSettings[i].Model < modelSettings[j].Model
|
||||
})
|
||||
return gin.H{
|
||||
"enabled": settings.Enabled,
|
||||
"default_model": settings.DefaultModel,
|
||||
"allow_open_original": settings.AllowOpenOriginal,
|
||||
"retention_minutes": settings.RetentionMinutes,
|
||||
"allowed_models": allowedModels,
|
||||
"models": models,
|
||||
"model_settings": modelSettings,
|
||||
}
|
||||
}
|
||||
|
||||
func ensureImageStudioTaskCleanup() {
|
||||
imageStudioCleanupOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(imageStudioTaskCleanupEvery)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-imageStudioTaskTTL).Unix()
|
||||
cutoff := time.Now().Add(-getImageStudioTaskTTL()).Unix()
|
||||
imageStudioTasks.Lock()
|
||||
for id, task := range imageStudioTasks.items {
|
||||
if task.UpdatedAt < cutoff {
|
||||
@@ -410,6 +714,50 @@ func validateImageStudioTokenAndModel(userID int, req imageStudioGenerateRequest
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("请选择图片模型")
|
||||
}
|
||||
settings := getImageStudioSettings()
|
||||
if !settings.Enabled {
|
||||
return nil, fmt.Errorf("图片制作功能暂未开放")
|
||||
}
|
||||
if !shouldAllowImageStudioModel(modelName) {
|
||||
return nil, fmt.Errorf("当前模型暂未开放图片制作,请联系管理员调整设置")
|
||||
}
|
||||
if modelSetting, ok := getImageStudioModelSetting(modelName); ok {
|
||||
if !modelSetting.Enabled {
|
||||
return nil, fmt.Errorf("当前模型已被管理员禁用")
|
||||
}
|
||||
if mode == imageStudioEditAction && !modelSetting.AllowEdit {
|
||||
return nil, fmt.Errorf("当前模型未开放图片编辑")
|
||||
}
|
||||
if mode != imageStudioEditAction && !modelSetting.AllowGenerate {
|
||||
return nil, fmt.Errorf("当前模型未开放图片生成")
|
||||
}
|
||||
}
|
||||
if allowedSizes := getImageStudioAllowedSizesForModel(modelName); len(allowedSizes) > 0 {
|
||||
resolvedSize := resolveImageStudioSize(modelName, req.Size)
|
||||
allowed := false
|
||||
for _, size := range allowedSizes {
|
||||
if size == resolvedSize {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("当前模型不允许使用所选尺寸")
|
||||
}
|
||||
}
|
||||
if allowedQualities := getImageStudioAllowedQualitiesForModel(modelName); len(allowedQualities) > 0 {
|
||||
resolvedQuality := normalizeImageStudioQuality(req.Quality)
|
||||
allowed := false
|
||||
for _, quality := range allowedQualities {
|
||||
if quality == resolvedQuality {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
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 之间")
|
||||
}
|
||||
@@ -556,7 +904,7 @@ func signImageStudioResult(relativePath string, expiresAt int64) string {
|
||||
|
||||
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()
|
||||
expiresAt := time.Now().Add(getImageStudioTaskTTL()).Unix()
|
||||
signature := signImageStudioResult(relativePath, expiresAt)
|
||||
return fmt.Sprintf("%s/%s?expires=%d&sig=%s", imageStudioResultURLPrefix, relativePath, expiresAt, signature)
|
||||
}
|
||||
@@ -925,6 +1273,10 @@ func SubmitImageStudioEdit(c *gin.Context) {
|
||||
common.ApiSuccess(c, task)
|
||||
}
|
||||
|
||||
func GetImageStudioSettings(c *gin.Context) {
|
||||
common.ApiSuccess(c, buildImageStudioPublicSettings())
|
||||
}
|
||||
|
||||
func buildImageStudioTaskResultURL(taskID string, index int) string {
|
||||
return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index)
|
||||
}
|
||||
@@ -962,11 +1314,18 @@ func sanitizeImageStudioTaskForResponse(task *imageStudioTask) *imageStudioTask
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
taskResultURL := buildImageStudioTaskResultURL(task.ID, index)
|
||||
if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" {
|
||||
delete(item, "b64_json")
|
||||
item["url"] = buildImageStudioTaskResultURL(task.ID, index)
|
||||
changed = true
|
||||
}
|
||||
if url, _ := item["url"].(string); strings.TrimSpace(url) != "" && strings.TrimSpace(url) != taskResultURL {
|
||||
item["raw_url"] = url
|
||||
changed = true
|
||||
}
|
||||
item["url"] = taskResultURL
|
||||
item["task_result_url"] = taskResultURL
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return &clone
|
||||
@@ -1028,6 +1387,27 @@ func GetImageStudioTaskResult(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "图片结果不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(task.ResultDir) != "" {
|
||||
outputFormat, _ := item["output_format"].(string)
|
||||
filename := fmt.Sprintf("%d.%s", index, getImageStudioResultExtension(outputFormat))
|
||||
fullPath := filepath.Clean(filepath.Join(task.ResultDir, filename))
|
||||
resultDir := filepath.Clean(task.ResultDir)
|
||||
prefix := resultDir + string(os.PathSeparator)
|
||||
if fullPath == resultDir || !strings.HasPrefix(fullPath, prefix) {
|
||||
common.ApiErrorMsg(c, "图片结果不存在")
|
||||
return
|
||||
}
|
||||
info, statErr := os.Stat(fullPath)
|
||||
if statErr == nil && !info.IsDir() {
|
||||
c.Header("Cache-Control", "private, no-store, no-cache, max-age=0")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Content-Type", getImageStudioResultContentType(outputFormat))
|
||||
c.File(fullPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if url, _ := item["url"].(string); strings.TrimSpace(url) != "" {
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user