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:
ikmkj
2026-05-05 15:27:51 +08:00
parent 92ee465a41
commit 8ee89478a9
46 changed files with 1341 additions and 289 deletions

View File

@@ -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

View File

@@ -89,6 +89,32 @@ func GetOptions(c *gin.Context) {
Key: "CompletionRatioMeta",
Value: buildCompletionRatioMetaValue(optionValues),
})
pricingItems := model.GetPricing()
imageStudioModels := make([]string, 0)
seenImageStudioModels := make(map[string]struct{})
for _, item := range pricingItems {
enabledForImage := false
for _, group := range item.EnableGroup {
if strings.TrimSpace(group) == "image" {
enabledForImage = true
break
}
}
if !enabledForImage {
continue
}
if _, ok := seenImageStudioModels[item.ModelName]; ok {
continue
}
seenImageStudioModels[item.ModelName] = struct{}{}
imageStudioModels = append(imageStudioModels, item.ModelName)
}
if encoded, err := common.Marshal(imageStudioModels); err == nil {
options = append(options, &model.Option{
Key: "ImageStudioAvailableModels",
Value: string(encoded),
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",