fix: add local static file storage fallback

This commit is contained in:
wangbo 2026-05-13 20:53:07 +08:00
parent fc5dfd6bc5
commit 62d9170e65
7 changed files with 395 additions and 75 deletions

View File

@ -7,6 +7,11 @@ import (
"strings"
)
const (
DefaultLocalGeneratedStorageDir = "data/static/generated"
DefaultLocalUploadedStorageDir = "data/static/uploaded"
)
type Config struct {
AppEnv string
HTTPAddr string
@ -15,6 +20,9 @@ type Config struct {
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
PublicBaseURL string
LocalGeneratedStorageDir string
LocalUploadedStorageDir string
TaskProgressCallbackEnabled bool
TaskProgressCallbackURL string
TaskProgressCallbackTimeoutMS string
@ -38,6 +46,9 @@ func Load() Config {
"/",
),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",

View File

@ -41,6 +41,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))

View File

@ -0,0 +1,37 @@
package httpapi
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) {
s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir)
}
func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) {
s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir)
}
func (s *Server) serveLocalStaticAsset(w http.ResponseWriter, r *http.Request, storageDir string, fallbackStorageDir string) {
fileName := filepath.Base(strings.TrimSpace(r.PathValue("asset")))
if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) {
http.NotFound(w, r)
return
}
storageDir = strings.TrimSpace(storageDir)
if storageDir == "" {
storageDir = fallbackStorageDir
}
filePath := filepath.Join(storageDir, fileName)
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filePath)
}

View File

@ -0,0 +1,65 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func TestServeGeneratedStaticAsset(t *testing.T) {
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "result.png"), []byte("png"), 0o644); err != nil {
t.Fatalf("failed to write generated asset fixture: %v", err)
}
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/generated/result.png", nil)
request.SetPathValue("asset", "result.png")
response := httptest.NewRecorder()
server.serveGeneratedStaticAsset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected generated asset to be served, got status %d", response.Code)
}
if response.Body.String() != "png" {
t.Fatalf("unexpected generated asset payload: %q", response.Body.String())
}
}
func TestServeUploadedStaticAsset(t *testing.T) {
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "upload.pdf"), []byte("pdf"), 0o644); err != nil {
t.Fatalf("failed to write uploaded asset fixture: %v", err)
}
server := &Server{cfg: config.Config{LocalUploadedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/uploaded/upload.pdf", nil)
request.SetPathValue("asset", "upload.pdf")
response := httptest.NewRecorder()
server.serveUploadedStaticAsset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected uploaded asset to be served, got status %d", response.Code)
}
if response.Body.String() != "pdf" {
t.Fatalf("unexpected uploaded asset payload: %q", response.Body.String())
}
}
func TestServeLocalStaticAssetRejectsTraversal(t *testing.T) {
storageDir := t.TempDir()
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
request := httptest.NewRequest(http.MethodGet, "/static/generated/..", nil)
request.SetPathValue("asset", "..")
response := httptest.NewRecorder()
server.serveGeneratedStaticAsset(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("expected traversal-like generated asset name to 404, got status %d", response.Code)
}
}

View File

@ -9,20 +9,29 @@ import (
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const defaultServerMainOpenAPIUploadURL = "http://127.0.0.1:3001/v1/files/upload"
const maxGeneratedAssetFetchBytes = 256 << 20
const (
localStaticGeneratedPathPrefix = "/static/generated/"
localStaticUploadedPathPrefix = "/static/uploaded/"
)
type FileUploadPayload struct {
ContentType string
FileName string
@ -32,8 +41,9 @@ type FileUploadPayload struct {
}
type generatedAssetUploadPolicy struct {
UploadInlineMedia bool
UploadURLMedia bool
UploadInlineMedia bool
UploadURLMedia bool
StoreInlineMediaLocally bool
}
type generatedAssetDecision struct {
@ -96,14 +106,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
return result, nil
}
var channels []store.FileStorageChannel
if needsUpload {
if needsUpload && generatedAssetNeedsChannelLookup(policy, decisions) {
channels, err = s.activeFileStorageChannels(ctx, store.FileStorageSceneImageResult)
if err != nil {
return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true}
}
if len(channels) == 0 {
return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel for generated media results", Retryable: false}
}
}
next := map[string]any{}
for key, value := range result {
@ -132,13 +139,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
var contentType string
var err error
if decision.Inline != nil {
upload, contentType, kind, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels, policy.StoreInlineMediaLocally)
sourceKey = decision.Inline.SourceKey
strategy = "upload_inline_media"
} else {
upload, contentType, kind, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels)
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels)
sourceKey = decision.URL.SourceKey
strategy = "upload_url_media"
}
if err != nil {
return nil, err
@ -191,40 +196,174 @@ func generatedAssetUploadPolicyFromName(policyName string) generatedAssetUploadP
case store.FileStorageResultUploadPolicyUploadAll:
return generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true}
case store.FileStorageResultUploadPolicyUploadNone:
return generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false}
return generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: true}
default:
return defaultGeneratedAssetUploadPolicy()
}
}
func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset *generatedInlineAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, error) {
func generatedAssetNeedsChannelLookup(policy generatedAssetUploadPolicy, decisions []generatedAssetDecision) bool {
for _, decision := range decisions {
if decision.URL != nil {
return true
}
if decision.Inline != nil && !policy.StoreInlineMediaLocally {
return true
}
}
return false
}
func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset *generatedInlineAsset, index int, channels []store.FileStorageChannel, forceLocal bool) (map[string]any, string, string, string, error) {
contentType := resolvedGeneratedAssetContentType(asset.ContentType, asset.Kind, asset.Bytes)
kind := generatedAssetKindFromContentType(asset.Kind, contentType)
upload, err := s.uploadFileWithFailover(ctx, FileUploadPayload{
payload := FileUploadPayload{
Bytes: asset.Bytes,
ContentType: contentType,
FileName: generatedAssetFileName(taskID, index, contentType, kind),
Scene: store.FileStorageSceneImageResult,
Source: "ai-gateway",
}, channels)
return upload, contentType, kind, err
}
if forceLocal || len(channels) == 0 {
upload, err := s.storeFileLocally(payload, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir, localStaticGeneratedPathPrefix)
return upload, contentType, kind, "local_static_inline_media", err
}
upload, err := s.uploadFileWithFailover(ctx, payload, channels)
return upload, contentType, kind, "upload_inline_media", err
}
func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, error) {
func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, string, error) {
payload, contentType, err := s.readGeneratedURLAsset(ctx, asset)
if err != nil {
return nil, "", "", err
return nil, "", "", "", err
}
contentType = resolvedGeneratedAssetContentType(firstNonEmptyString(contentType, asset.ContentType), asset.Kind, payload)
kind := generatedAssetKindFromContentType(asset.Kind, contentType)
upload, err := s.uploadFileWithFailover(ctx, FileUploadPayload{
uploadPayload := FileUploadPayload{
Bytes: payload,
ContentType: contentType,
FileName: generatedAssetFileName(taskID, index, contentType, kind),
Scene: store.FileStorageSceneImageResult,
Source: "ai-gateway",
}, channels)
return upload, contentType, kind, err
}
if len(channels) == 0 {
upload, err := s.storeFileLocally(uploadPayload, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir, localStaticGeneratedPathPrefix)
return upload, contentType, kind, "local_static_url_media", err
}
upload, err := s.uploadFileWithFailover(ctx, uploadPayload, channels)
return upload, contentType, kind, "upload_url_media", err
}
func (s *Service) storeFileLocally(payload FileUploadPayload, storageDir string, fallbackStorageDir string, pathPrefix string) (map[string]any, error) {
storageDir = strings.TrimSpace(storageDir)
if storageDir == "" {
storageDir = fallbackStorageDir
}
if err := os.MkdirAll(storageDir, 0o755); err != nil {
return nil, &clients.ClientError{Code: "local_static_store_failed", Message: err.Error(), Retryable: true}
}
fileName := filepath.Base(strings.TrimSpace(payload.FileName))
if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) {
kind := generatedAssetKindFromContentType("", payload.ContentType)
fileName = generatedAssetFileName("generated", 0, payload.ContentType, kind)
}
targetPath := filepath.Join(storageDir, fileName)
file, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return nil, &clients.ClientError{Code: "local_static_store_failed", Message: err.Error(), Retryable: true}
}
_, writeErr := file.Write(payload.Bytes)
closeErr := file.Close()
if writeErr != nil {
_ = os.Remove(targetPath)
return nil, &clients.ClientError{Code: "local_static_store_failed", Message: writeErr.Error(), Retryable: true}
}
if closeErr != nil {
_ = os.Remove(targetPath)
return nil, &clients.ClientError{Code: "local_static_store_failed", Message: closeErr.Error(), Retryable: true}
}
return map[string]any{
"url": s.localStaticFileURL(fileName, pathPrefix),
"fileName": fileName,
"contentType": payload.ContentType,
"size": len(payload.Bytes),
"storageChannel": map[string]any{
"id": "local-static",
"channelKey": "local-static",
"name": "AI Gateway local static storage",
"provider": "local_static",
},
}, nil
}
func (s *Service) localStaticFileURL(fileName string, pathPrefix string) string {
if strings.TrimSpace(pathPrefix) == "" {
pathPrefix = localStaticUploadedPathPrefix
}
path := pathPrefix + url.PathEscape(filepath.Base(fileName))
baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.PublicBaseURL), "/")
if baseURL == "" {
return path
}
return baseURL + path
}
func localStaticUploadFileName(originalName string, contentType string) string {
baseName := filepath.Base(strings.TrimSpace(originalName))
originalExt := strings.ToLower(filepath.Ext(baseName))
namePart := strings.TrimSuffix(baseName, originalExt)
namePart = sanitizeGeneratedAssetNamePart(namePart)
if namePart == "" {
namePart = "gateway-upload"
}
if len(namePart) > 48 {
namePart = namePart[:48]
}
return fmt.Sprintf("%s-%s%s", namePart, randomHexSuffix(6), uploadFileExtension(contentType, originalExt))
}
func uploadFileExtension(contentType string, fallbackExt string) string {
normalized := normalizeGeneratedContentType(contentType)
if generatedContentTypeIsMedia(normalized) {
return fileExtensionForContentType(normalized, generatedAssetKindFromContentType("", normalized))
}
if normalized != "" && normalized != "application/octet-stream" {
if extensions, err := mime.ExtensionsByType(normalized); err == nil && len(extensions) > 0 {
if ext := sanitizeFileExtension(extensions[0]); ext != "" {
return ext
}
}
}
if ext := sanitizeFileExtension(fallbackExt); ext != "" {
return ext
}
if normalized == "application/json" {
return ".json"
}
if strings.HasPrefix(normalized, "text/") {
return ".txt"
}
return ".bin"
}
func sanitizeFileExtension(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
if !strings.HasPrefix(value, ".") {
value = "." + value
}
if len(value) > 16 {
return ""
}
for _, item := range value[1:] {
if (item >= 'a' && item <= 'z') || (item >= '0' && item <= '9') {
continue
}
return ""
}
return value
}
func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURLAsset) ([]byte, string, error) {
@ -316,12 +455,25 @@ func (s *Service) UploadFile(ctx context.Context, payload FileUploadPayload) (ma
return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true}
}
if len(channels) == 0 {
return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel", Retryable: false}
payload.FileName = localStaticUploadFileName(payload.FileName, payload.ContentType)
upload, err := s.storeFileLocally(payload, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir, localStaticUploadedPathPrefix)
if err != nil {
return nil, err
}
upload["assetStorage"] = map[string]any{
"scene": payload.Scene,
"source": firstNonEmptyString(payload.Source, "ai-gateway-openapi"),
"strategy": "local_static_upload",
}
return upload, nil
}
return s.uploadFileWithFailover(ctx, payload, channels)
}
func (s *Service) activeFileStorageChannels(ctx context.Context, scene string) ([]store.FileStorageChannel, error) {
if s.store == nil {
return nil, nil
}
channels, err := s.store.ListEnabledFileStorageChannelsForScene(ctx, scene)
if err != nil && !store.IsUndefinedDatabaseObject(err) {
return nil, err
@ -329,33 +481,7 @@ func (s *Service) activeFileStorageChannels(ctx context.Context, scene string) (
if len(channels) > 0 {
return channels, nil
}
fallback := s.fallbackFileStorageChannel()
if fallback == nil {
return nil, nil
}
if !fileStorageChannelSupportsScene(*fallback, scene) {
return nil, nil
}
return []store.FileStorageChannel{*fallback}, nil
}
func (s *Service) fallbackFileStorageChannel() *store.FileStorageChannel {
baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.ServerMainBaseURL), "/")
apiKey := strings.TrimSpace(s.cfg.ServerMainInternalToken)
if baseURL == "" || apiKey == "" {
return nil
}
return &store.FileStorageChannel{
ChannelKey: "server-main-env-fallback",
Name: "server-main env fallback",
Provider: "server_main_openapi",
UploadURL: baseURL + "/v1/files/upload",
APIKey: apiKey,
Scenes: []string{store.FileStorageSceneUpload, store.FileStorageSceneImageResult},
RetryPolicy: defaultUploadRetryPolicy(),
Priority: 100,
Status: "enabled",
}
return nil, nil
}
func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUploadPayload, channels []store.FileStorageChannel) (map[string]any, error) {
@ -363,11 +489,15 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload
for _, channel := range channels {
upload, err := s.uploadWithChannelRetries(ctx, payload, channel)
if err == nil {
_ = s.store.MarkFileStorageChannelSuccess(context.WithoutCancel(ctx), channel.ID)
if s.store != nil {
_ = s.store.MarkFileStorageChannelSuccess(context.WithoutCancel(ctx), channel.ID)
}
return upload, nil
}
lastErr = err
_ = s.store.MarkFileStorageChannelFailure(context.WithoutCancel(ctx), channel.ID, err.Error())
if s.store != nil {
_ = s.store.MarkFileStorageChannelFailure(context.WithoutCancel(ctx), channel.ID, err.Error())
}
}
if lastErr != nil {
return nil, lastErr
@ -375,23 +505,6 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload
return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel", Retryable: false}
}
func fileStorageChannelSupportsScene(channel store.FileStorageChannel, scene string) bool {
scene = strings.TrimSpace(scene)
if scene == "" {
return true
}
scenes := channel.Scenes
if len(scenes) == 0 {
scenes = []string{store.FileStorageSceneUpload, store.FileStorageSceneImageResult}
}
for _, item := range scenes {
if strings.TrimSpace(item) == scene {
return true
}
}
return false
}
func (s *Service) uploadWithChannelRetries(ctx context.Context, payload FileUploadPayload, channel store.FileStorageChannel) (map[string]any, error) {
maxRetries, delays := uploadRetrySchedule(channel.RetryPolicy)
var lastErr error

View File

@ -1,10 +1,15 @@
package runner
import (
"bytes"
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@ -109,17 +114,20 @@ func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) {
}
}
func TestGeneratedAssetDecisionSkipsAllWhenPolicyUploadNone(t *testing.T) {
func TestGeneratedAssetDecisionStoresInlineLocallyWhenPolicyUploadNone(t *testing.T) {
item := map[string]any{
"b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")),
}
decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false})
decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicyFromName(store.FileStorageResultUploadPolicyUploadNone))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decision.Inline != nil || decision.URL != nil || len(decision.StripKeys) != 0 {
t.Fatalf("upload_none should keep the result unchanged: %+v", decision)
if decision.Inline == nil || decision.URL != nil {
t.Fatalf("upload_none should still turn inline payloads into static URLs: %+v", decision)
}
if !containsString(decision.StripKeys, "b64_json") {
t.Fatalf("inline payload should be stripped before persistence: %+v", decision.StripKeys)
}
}
@ -132,17 +140,17 @@ func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
{
name: "default",
policyName: store.FileStorageResultUploadPolicyDefault,
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false},
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: false},
},
{
name: "upload all",
policyName: store.FileStorageResultUploadPolicyUploadAll,
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true},
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true, StoreInlineMediaLocally: false},
},
{
name: "upload none",
policyName: store.FileStorageResultUploadPolicyUploadNone,
want: generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false},
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: true},
},
}
@ -185,3 +193,87 @@ func TestGeneratedAssetFileNameIsUniqueAndTyped(t *testing.T) {
t.Fatalf("unexpected generated file name: %s", first)
}
}
func TestUploadGeneratedAssetStoresLocalWhenNoChannels(t *testing.T) {
storageDir := t.TempDir()
service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
payload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0}
asset := &generatedInlineAsset{
Bytes: payload,
ContentType: "image/jpeg",
Kind: "image",
SourceKey: "b64_json",
}
upload, contentType, kind, strategy, err := service.uploadGeneratedAsset(context.Background(), "task-123", asset, 0, nil, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if contentType != "image/png" || kind != "image" || strategy != "local_static_inline_media" {
t.Fatalf("unexpected local upload metadata: contentType=%s kind=%s strategy=%s", contentType, kind, strategy)
}
urlValue := stringFromAny(upload["url"])
if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-123-01-") || !strings.HasSuffix(urlValue, ".png") {
t.Fatalf("unexpected local static URL: %s", urlValue)
}
entries, err := os.ReadDir(storageDir)
if err != nil {
t.Fatalf("failed to read local static dir: %v", err)
}
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".png") {
t.Fatalf("expected one PNG file in local static dir, got %+v", entries)
}
stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name()))
if err != nil {
t.Fatalf("failed to read local static file: %v", err)
}
if !bytes.Equal(stored, payload) {
t.Fatalf("stored payload does not match source payload")
}
}
func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) {
storageDir := t.TempDir()
service := &Service{cfg: config.Config{
LocalUploadedStorageDir: storageDir,
ServerMainBaseURL: "http://127.0.0.1:1",
ServerMainInternalToken: "change-me",
}}
payload := []byte("%PDF-1.4")
upload, err := service.UploadFile(context.Background(), FileUploadPayload{
Bytes: payload,
ContentType: "application/pdf",
FileName: "用户文件.png",
Source: "playground",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
urlValue := stringFromAny(upload["url"])
if !strings.HasPrefix(urlValue, "/static/uploaded/") || !strings.HasSuffix(urlValue, ".pdf") {
t.Fatalf("unexpected uploaded local static URL: %s", urlValue)
}
storageChannel, _ := upload["storageChannel"].(map[string]any)
if stringFromAny(storageChannel["provider"]) != "local_static" {
t.Fatalf("expected local static provider metadata, got %+v", upload["storageChannel"])
}
assetStorage, _ := upload["assetStorage"].(map[string]any)
if stringFromAny(assetStorage["strategy"]) != "local_static_upload" || stringFromAny(assetStorage["scene"]) != store.FileStorageSceneUpload {
t.Fatalf("unexpected upload asset storage metadata: %+v", assetStorage)
}
entries, err := os.ReadDir(storageDir)
if err != nil {
t.Fatalf("failed to read uploaded static dir: %v", err)
}
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".pdf") {
t.Fatalf("expected one PDF file in uploaded static dir, got %+v", entries)
}
stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name()))
if err != nil {
t.Fatalf("failed to read uploaded static file: %v", err)
}
if !bytes.Equal(stored, payload) {
t.Fatalf("stored uploaded payload does not match source payload")
}
}

View File

@ -43,7 +43,7 @@ const sceneOptions = [
const resultUploadPolicyOptions = [
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存base64 / buffer 等结果转存后保存 URL' },
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' },
{ value: 'upload_none', label: '全部不转存', description: '返图结果原样保存,不触发文件存储上传' },
{ value: 'upload_none', label: '全部不转存', description: '链接结果直接保存base64 / buffer 结果写入网关本地静态托管后保存 URL' },
];
export function SystemSettingsPanel(props: {