fix: add local static file storage fallback
This commit is contained in:
parent
fc5dfd6bc5
commit
62d9170e65
@ -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",
|
||||
|
||||
@ -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)))
|
||||
|
||||
37
apps/api/internal/httpapi/static_assets.go
Normal file
37
apps/api/internal/httpapi/static_assets.go
Normal 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)
|
||||
}
|
||||
65
apps/api/internal/httpapi/static_assets_test.go
Normal file
65
apps/api/internal/httpapi/static_assets_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user