Expire local static assets after 24 hours

This commit is contained in:
wangbo 2026-06-07 19:29:04 +08:00
parent f47132a653
commit 4d1a01ec71
5 changed files with 395 additions and 30 deletions

View File

@ -29,9 +29,35 @@ func (s *Server) startLocalTempAssetCleanup(ctx context.Context) {
} }
func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Time) int { func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Time) int {
storageDir := strings.TrimSpace(s.cfg.LocalUploadedStorageDir) targets := []localTempAssetCleanupTarget{
{
StorageDir: s.cfg.LocalGeneratedStorageDir,
FallbackDir: config.DefaultLocalGeneratedStorageDir,
MarkRequestAsset: false,
},
{
StorageDir: s.cfg.LocalUploadedStorageDir,
FallbackDir: config.DefaultLocalUploadedStorageDir,
MarkRequestAsset: true,
},
}
deleted := 0
for _, target := range targets {
deleted += s.cleanupExpiredLocalTempAssetsInDir(ctx, now, target)
}
return deleted
}
type localTempAssetCleanupTarget struct {
StorageDir string
FallbackDir string
MarkRequestAsset bool
}
func (s *Server) cleanupExpiredLocalTempAssetsInDir(ctx context.Context, now time.Time, target localTempAssetCleanupTarget) int {
storageDir := strings.TrimSpace(target.StorageDir)
if storageDir == "" { if storageDir == "" {
storageDir = config.DefaultLocalUploadedStorageDir storageDir = target.FallbackDir
} }
entries, err := os.ReadDir(storageDir) entries, err := os.ReadDir(storageDir)
if err != nil { if err != nil {
@ -44,7 +70,7 @@ func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Tim
expiredBefore := now.Add(-ttl) expiredBefore := now.Add(-ttl)
deleted := 0 deleted := 0
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), requestAssetFilePrefix) { if entry.IsDir() {
continue continue
} }
info, err := entry.Info() info, err := entry.Info()
@ -60,7 +86,7 @@ func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Tim
continue continue
} }
deleted++ deleted++
if s.store != nil { if target.MarkRequestAsset && strings.HasPrefix(entry.Name(), requestAssetFilePrefix) && s.store != nil {
if err := s.store.MarkRequestAssetExpiredByLocalPath(ctx, localPath, now); err != nil && !store.IsUndefinedDatabaseObject(err) { if err := s.store.MarkRequestAssetExpiredByLocalPath(ctx, localPath, now); err != nil && !store.IsUndefinedDatabaseObject(err) {
s.logger.Warn("mark local temp asset expired failed", "path", localPath, "error", err) s.logger.Warn("mark local temp asset expired failed", "path", localPath, "error", err)
} }

View File

@ -75,42 +75,51 @@ func TestCanonicalConversationMessageHashUsesTextAndAssetRefs(t *testing.T) {
} }
} }
func TestCleanupExpiredLocalTempAssetsOnlyDeletesExpiredPrefixedFiles(t *testing.T) { func TestCleanupExpiredLocalTempAssetsDeletesExpiredStaticFiles(t *testing.T) {
storageDir := t.TempDir() uploadedDir := t.TempDir()
oldTemp := filepath.Join(storageDir, requestAssetFilePrefix+"old.png") generatedDir := t.TempDir()
freshTemp := filepath.Join(storageDir, requestAssetFilePrefix+"fresh.png") oldUploaded := filepath.Join(uploadedDir, requestAssetFilePrefix+"old.png")
oldGenerated := filepath.Join(storageDir, "gateway-result-old.png") freshUploaded := filepath.Join(uploadedDir, requestAssetFilePrefix+"fresh.png")
for _, path := range []string{oldTemp, freshTemp, oldGenerated} { oldGenerated := filepath.Join(generatedDir, "gateway-result-old.png")
freshGenerated := filepath.Join(generatedDir, "gateway-result-fresh.png")
for _, path := range []string{oldUploaded, freshUploaded, oldGenerated, freshGenerated} {
if err := os.WriteFile(path, []byte("asset"), 0o644); err != nil { if err := os.WriteFile(path, []byte("asset"), 0o644); err != nil {
t.Fatalf("write fixture %s: %v", path, err) t.Fatalf("write fixture %s: %v", path, err)
} }
} }
now := time.Now() now := time.Now()
if err := os.Chtimes(oldTemp, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { for _, path := range []string{oldUploaded, oldGenerated} {
t.Fatalf("touch old temp: %v", err) if err := os.Chtimes(path, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil {
t.Fatalf("touch old static asset %s: %v", path, err)
}
} }
if err := os.Chtimes(freshTemp, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil { for _, path := range []string{freshUploaded, freshGenerated} {
t.Fatalf("touch fresh temp: %v", err) if err := os.Chtimes(path, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil {
} t.Fatalf("touch fresh static asset %s: %v", path, err)
if err := os.Chtimes(oldGenerated, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { }
t.Fatalf("touch old generated: %v", err)
} }
server := &Server{ server := &Server{
cfg: config.Config{LocalUploadedStorageDir: storageDir, LocalTempAssetTTLHours: 24}, cfg: config.Config{
LocalGeneratedStorageDir: generatedDir,
LocalUploadedStorageDir: uploadedDir,
LocalTempAssetTTLHours: 24,
},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)), logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now) deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now)
if deleted != 1 { if deleted != 2 {
t.Fatalf("expected one expired temp asset delete, got %d", deleted) t.Fatalf("expected two expired static asset deletes, got %d", deleted)
} }
if _, err := os.Stat(oldTemp); !os.IsNotExist(err) { for _, path := range []string{oldUploaded, oldGenerated} {
t.Fatalf("old prefixed temp asset should be deleted, stat err=%v", err) if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("old static asset should be deleted %s, stat err=%v", path, err)
}
} }
for _, path := range []string{freshTemp, oldGenerated} { for _, path := range []string{freshUploaded, freshGenerated} {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
t.Fatalf("non-expired or non-prefixed file should remain %s: %v", path, err) t.Fatalf("fresh static asset should remain %s: %v", path, err)
} }
} }
} }

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -76,6 +77,7 @@ func defaultGeneratedAssetUploadPolicy() generatedAssetUploadPolicy {
func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, taskKind string, result map[string]any) (map[string]any, error) { func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, taskKind string, result map[string]any) (map[string]any, error) {
data, _ := result["data"].([]any) data, _ := result["data"].([]any)
if len(data) == 0 { if len(data) == 0 {
redactGeneratedResultRawData(result)
return result, nil return result, nil
} }
policy, err := s.generatedAssetUploadPolicy(ctx) policy, err := s.generatedAssetUploadPolicy(ctx)
@ -103,6 +105,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
} }
} }
if !needsUpload && !changed { if !needsUpload && !changed {
redactGeneratedResultRawData(result)
return result, nil return result, nil
} }
var channels []store.FileStorageChannel var channels []store.FileStorageChannel
@ -165,6 +168,9 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if kind == "image" { if kind == "image" {
merged["image_url"] = urlValue merged["image_url"] = urlValue
} }
if kind == "audio" {
merged["audio_url"] = urlValue
}
} }
if kind != "" && stringFromAny(merged["type"]) == "" { if kind != "" && stringFromAny(merged["type"]) == "" {
merged["type"] = kind merged["type"] = kind
@ -176,9 +182,185 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
nextData = append(nextData, merged) nextData = append(nextData, merged)
} }
next["data"] = nextData next["data"] = nextData
redactGeneratedResultRawData(next)
return next, nil return next, nil
} }
func redactGeneratedResultRawData(result map[string]any) bool {
if result == nil {
return false
}
changed := false
for _, key := range []string{"raw_data", "rawData"} {
value, ok := result[key]
if !ok || value == nil {
continue
}
next, nextChanged := redactGeneratedRawDataValue(value, key, nil)
if nextChanged {
result[key] = next
changed = true
}
}
return changed
}
func redactGeneratedRawDataValue(value any, key string, siblings map[string]any) (any, bool) {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
changed := false
for childKey, childValue := range typed {
redacted, childChanged := redactGeneratedRawDataValue(childValue, childKey, typed)
next[childKey] = redacted
if childChanged {
changed = true
}
}
if changed {
return next, true
}
return value, false
case []any:
next := make([]any, len(typed))
changed := false
for index, item := range typed {
redacted, itemChanged := redactGeneratedRawDataValue(item, key, siblings)
next[index] = redacted
if itemChanged {
changed = true
}
}
if changed {
return next, true
}
return value, false
case string:
redacted, ok := generatedRawDataMediaRedaction(key, typed, siblings)
if ok {
return redacted, true
}
return value, false
default:
return value, false
}
}
func generatedRawDataMediaRedaction(key string, value string, siblings map[string]any) (map[string]any, bool) {
raw := strings.TrimSpace(value)
if raw == "" {
return nil, false
}
contentType := firstNonEmptyString(mediaContentTypeFromItem(siblings), defaultContentTypeForRawMediaKey(key))
if strings.HasPrefix(strings.ToLower(raw), "data:") {
declared, encoded, ok, err := parseBase64DataURL(raw)
if err != nil || !ok || !generatedContentTypeIsMedia(declared) {
return nil, false
}
payload, err := decodeBase64Payload(encoded)
if err != nil {
return nil, false
}
return generatedRawDataRedaction("data_url", declared, payload, raw), true
}
if len(raw) < 128 {
return nil, false
}
keyLooksLikeMediaPayload := generatedRawDataMediaPayloadKey(key)
if !keyLooksLikeMediaPayload && !generatedContentTypeIsMedia(contentType) {
return nil, false
}
if keyLooksLikeMediaPayload && looksHexMediaPayload(raw) {
normalized := removeASCIIWhitespace(raw)
payload, err := hex.DecodeString(normalized)
if err == nil && len(payload) > 0 {
return generatedRawDataRedaction("hex", contentType, payload, raw), true
}
}
if payload, err := decodeBase64Payload(raw); err == nil && len(payload) > 0 {
return generatedRawDataRedaction("base64", contentType, payload, raw), true
}
if keyLooksLikeMediaPayload && len(raw) > 2048 {
return generatedRawDataRedaction("unknown", contentType, nil, raw), true
}
return nil, false
}
func generatedRawDataMediaPayloadKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "_")
return normalized == "audio" ||
normalized == "image" ||
normalized == "video" ||
normalized == "b64_json" ||
normalized == "base64" ||
normalized == "b64" ||
normalized == "binary_data_base64" ||
strings.Contains(normalized, "base64") ||
strings.Contains(normalized, "_b64") ||
strings.Contains(normalized, "audio_data") ||
strings.Contains(normalized, "image_data") ||
strings.Contains(normalized, "video_data")
}
func defaultContentTypeForRawMediaKey(key string) string {
normalized := strings.ToLower(strings.TrimSpace(key))
if strings.Contains(normalized, "audio") {
return "audio/mpeg"
}
if strings.Contains(normalized, "video") {
return "video/mp4"
}
if strings.Contains(normalized, "image") || strings.Contains(normalized, "b64_json") {
return "image/png"
}
return ""
}
func generatedRawDataRedaction(encoding string, contentType string, payload []byte, raw string) map[string]any {
out := map[string]any{
"redacted": true,
"reason": "inline_media_payload",
"encoding": encoding,
}
if contentType = normalizeGeneratedContentType(contentType); contentType != "" {
out["contentType"] = contentType
}
if len(payload) > 0 {
digest := sha256.Sum256(payload)
out["size"] = len(payload)
out["sha256"] = hex.EncodeToString(digest[:])
} else {
out["size"] = len(raw)
}
return out
}
func looksHexMediaPayload(value string) bool {
normalized := removeASCIIWhitespace(value)
if len(normalized) < 128 || len(normalized)%2 != 0 {
return false
}
for _, item := range normalized {
if (item >= '0' && item <= '9') || (item >= 'a' && item <= 'f') || (item >= 'A' && item <= 'F') {
continue
}
return false
}
return true
}
func removeASCIIWhitespace(value string) string {
return strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', '\t', ' ':
return -1
default:
return r
}
}, value)
}
func (s *Service) generatedAssetUploadPolicy(ctx context.Context) (generatedAssetUploadPolicy, error) { func (s *Service) generatedAssetUploadPolicy(ctx context.Context) (generatedAssetUploadPolicy, error) {
settings, err := s.store.GetFileStorageSettings(ctx) settings, err := s.store.GetFileStorageSettings(ctx)
if err != nil { if err != nil {
@ -282,11 +464,13 @@ func (s *Service) storeFileLocally(payload FileUploadPayload, storageDir string,
_ = os.Remove(targetPath) _ = os.Remove(targetPath)
return nil, &clients.ClientError{Code: "local_static_store_failed", Message: closeErr.Error(), Retryable: true} return nil, &clients.ClientError{Code: "local_static_store_failed", Message: closeErr.Error(), Retryable: true}
} }
expiresAt := time.Now().Add(time.Duration(s.localStaticAssetTTLHours()) * time.Hour).UTC().Format(time.RFC3339)
return map[string]any{ return map[string]any{
"url": s.localStaticFileURL(fileName, pathPrefix), "url": s.localStaticFileURL(fileName, pathPrefix),
"fileName": fileName, "fileName": fileName,
"contentType": payload.ContentType, "contentType": payload.ContentType,
"size": len(payload.Bytes), "size": len(payload.Bytes),
"expiresAt": expiresAt,
"storageChannel": map[string]any{ "storageChannel": map[string]any{
"id": "local-static", "id": "local-static",
"channelKey": "local-static", "channelKey": "local-static",
@ -296,6 +480,13 @@ func (s *Service) storeFileLocally(payload FileUploadPayload, storageDir string,
}, nil }, nil
} }
func (s *Service) localStaticAssetTTLHours() int {
if s.cfg.LocalTempAssetTTLHours <= 0 {
return 24
}
return s.cfg.LocalTempAssetTTLHours
}
func (s *Service) localStaticFileURL(fileName string, pathPrefix string) string { func (s *Service) localStaticFileURL(fileName string, pathPrefix string) string {
if strings.TrimSpace(pathPrefix) == "" { if strings.TrimSpace(pathPrefix) == "" {
pathPrefix = localStaticUploadedPathPrefix pathPrefix = localStaticUploadedPathPrefix
@ -643,6 +834,9 @@ func inlineAssetFromItem(taskKind string, item map[string]any) (*generatedInline
if !ok || value == nil { if !ok || value == nil {
continue continue
} }
if !inlineMediaKeyAllowedForItem(item, key, value) {
continue
}
strictBase64 := inlineMediaKeyIsStrictBase64(key) strictBase64 := inlineMediaKeyIsStrictBase64(key)
payload, contentType, ok, err := inlineMediaPayload(value, strictBase64) payload, contentType, ok, err := inlineMediaPayload(value, strictBase64)
if err != nil { if err != nil {
@ -818,6 +1012,9 @@ func inlineMediaKeys(item map[string]any) []string {
if !ok || value == nil { if !ok || value == nil {
continue continue
} }
if !inlineMediaKeyAllowedForItem(item, key, value) {
continue
}
strictBase64 := inlineMediaKeyIsStrictBase64(key) strictBase64 := inlineMediaKeyIsStrictBase64(key)
if strictBase64 && stringFromAny(value) != "" { if strictBase64 && stringFromAny(value) != "" {
keys = append(keys, key) keys = append(keys, key)
@ -835,6 +1032,8 @@ func inlineMediaCandidateKeys() []string {
"b64_json", "b64_json",
"image_base64", "image_base64",
"image_b64", "image_b64",
"audio_base64",
"audio_b64",
"video_base64", "video_base64",
"video_b64", "video_b64",
"base64", "base64",
@ -842,24 +1041,56 @@ func inlineMediaCandidateKeys() []string {
"url", "url",
"image_url", "image_url",
"imageUrl", "imageUrl",
"audio_url",
"audioUrl",
"video_url", "video_url",
"videoUrl", "videoUrl",
"output_url", "output_url",
"outputUrl", "outputUrl",
"output_audio_url",
"outputAudioUrl",
"output_video_url", "output_video_url",
"outputVideoUrl", "outputVideoUrl",
"image", "image",
"audio",
"video", "video",
"image_buffer", "image_buffer",
"image_bytes", "image_bytes",
"audio_buffer",
"audio_bytes",
"video_buffer", "video_buffer",
"video_bytes", "video_bytes",
"buffer", "buffer",
"bytes", "bytes",
"data", "data",
"content",
} }
} }
func inlineMediaKeyAllowedForItem(item map[string]any, key string, value any) bool {
if !strings.EqualFold(strings.TrimSpace(key), "content") {
return true
}
raw, ok := value.(string)
if !ok {
return generatedContentTypeIsMedia(mediaContentTypeFromItem(item)) || generatedResultItemIsMedia(item)
}
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if strings.HasPrefix(strings.ToLower(raw), "data:") {
contentType, _, ok, err := parseBase64DataURL(raw)
return err == nil && ok && generatedContentTypeIsMedia(contentType)
}
return generatedContentTypeIsMedia(mediaContentTypeFromItem(item)) || generatedResultItemIsMedia(item)
}
func generatedResultItemIsMedia(item map[string]any) bool {
itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"])))
return strings.Contains(itemType, "image") || strings.Contains(itemType, "video") || strings.Contains(itemType, "audio")
}
func inlineMediaKeyIsStrictBase64(key string) bool { func inlineMediaKeyIsStrictBase64(key string) bool {
lower := strings.ToLower(key) lower := strings.ToLower(key)
return lower == "b64_json" || lower == "base64" || lower == "b64" || strings.Contains(lower, "base64") || strings.Contains(lower, "_b64") return lower == "b64_json" || lower == "base64" || lower == "b64" || strings.Contains(lower, "base64") || strings.Contains(lower, "_b64")
@ -887,7 +1118,7 @@ func mediaURLKeys(item map[string]any) []string {
} }
func mediaURLCandidateKeys() []string { func mediaURLCandidateKeys() []string {
return []string{"url", "image_url", "imageUrl", "video_url", "videoUrl", "output_url", "outputUrl", "output_video_url", "outputVideoUrl", "download_url", "downloadUrl", "file_url", "fileUrl"} return []string{"url", "image_url", "imageUrl", "audio_url", "audioUrl", "video_url", "videoUrl", "output_url", "outputUrl", "output_audio_url", "outputAudioUrl", "output_video_url", "outputVideoUrl", "download_url", "downloadUrl", "file_url", "fileUrl"}
} }
func mediaURLString(value string) bool { func mediaURLString(value string) bool {

View File

@ -93,6 +93,43 @@ func TestGeneratedAssetDecisionUploadsDataURL(t *testing.T) {
} }
} }
func TestGeneratedAssetDecisionUploadsAudioContentDataURL(t *testing.T) {
item := map[string]any{
"type": "audio",
"content": "data:audio/mpeg;base64," + base64.StdEncoding.EncodeToString([]byte("inline audio")),
"mime_type": "audio/mpeg",
}
decision, err := generatedAssetDecisionForItem("speech.generations", item, defaultGeneratedAssetUploadPolicy())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decision.Inline == nil {
t.Fatalf("expected inline audio content to be uploaded")
}
if decision.Inline.Kind != "audio" || decision.Inline.ContentType != "audio/mpeg" || decision.Inline.SourceKey != "content" {
t.Fatalf("unexpected inline audio metadata: %+v", decision.Inline)
}
if !containsString(decision.StripKeys, "content") {
t.Fatalf("uploaded audio content should be stripped: %+v", decision.StripKeys)
}
}
func TestGeneratedAssetDecisionDoesNotUploadPlainTextContent(t *testing.T) {
item := map[string]any{
"type": "text",
"content": base64.StdEncoding.EncodeToString([]byte(strings.Repeat("plain text ", 20))),
}
decision, err := generatedAssetDecisionForItem("speech.generations", item, defaultGeneratedAssetUploadPolicy())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decision.Inline != nil || len(decision.StripKeys) > 0 {
t.Fatalf("plain text content should not be treated as generated media: %+v", decision)
}
}
func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) { func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) {
item := map[string]any{ item := map[string]any{
"type": "video", "type": "video",
@ -216,6 +253,9 @@ func TestUploadGeneratedAssetStoresLocalWhenNoChannels(t *testing.T) {
if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-123-01-") || !strings.HasSuffix(urlValue, ".png") { if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-123-01-") || !strings.HasSuffix(urlValue, ".png") {
t.Fatalf("unexpected local static URL: %s", urlValue) t.Fatalf("unexpected local static URL: %s", urlValue)
} }
if stringFromAny(upload["expiresAt"]) == "" {
t.Fatalf("local static upload should expose expiresAt: %+v", upload)
}
entries, err := os.ReadDir(storageDir) entries, err := os.ReadDir(storageDir)
if err != nil { if err != nil {
t.Fatalf("failed to read local static dir: %v", err) t.Fatalf("failed to read local static dir: %v", err)
@ -232,6 +272,39 @@ func TestUploadGeneratedAssetStoresLocalWhenNoChannels(t *testing.T) {
} }
} }
func TestUploadGeneratedAssetStoresAudioLocalWhenNoChannels(t *testing.T) {
storageDir := t.TempDir()
service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
asset := &generatedInlineAsset{
Bytes: []byte("inline audio payload"),
ContentType: "audio/mpeg",
Kind: "audio",
SourceKey: "content",
}
upload, contentType, kind, strategy, err := service.uploadGeneratedAsset(context.Background(), "task-tts", asset, 0, nil, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if contentType != "audio/mpeg" || kind != "audio" || strategy != "local_static_inline_media" {
t.Fatalf("unexpected local audio metadata: contentType=%s kind=%s strategy=%s", contentType, kind, strategy)
}
urlValue := stringFromAny(upload["url"])
if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-tts-01-") || !strings.HasSuffix(urlValue, ".mp3") {
t.Fatalf("unexpected local audio URL: %s", urlValue)
}
if stringFromAny(upload["expiresAt"]) == "" {
t.Fatalf("local audio static upload should expose expiresAt: %+v", upload)
}
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(), ".mp3") {
t.Fatalf("expected one MP3 file in local static dir, got %+v", entries)
}
}
func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) { func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) {
storageDir := t.TempDir() storageDir := t.TempDir()
service := &Service{cfg: config.Config{ service := &Service{cfg: config.Config{
@ -254,6 +327,9 @@ func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) {
if !strings.HasPrefix(urlValue, "/static/uploaded/") || !strings.HasSuffix(urlValue, ".pdf") { if !strings.HasPrefix(urlValue, "/static/uploaded/") || !strings.HasSuffix(urlValue, ".pdf") {
t.Fatalf("unexpected uploaded local static URL: %s", urlValue) t.Fatalf("unexpected uploaded local static URL: %s", urlValue)
} }
if stringFromAny(upload["expiresAt"]) == "" {
t.Fatalf("local uploaded static file should expose expiresAt: %+v", upload)
}
storageChannel, _ := upload["storageChannel"].(map[string]any) storageChannel, _ := upload["storageChannel"].(map[string]any)
if stringFromAny(storageChannel["provider"]) != "local_static" { if stringFromAny(storageChannel["provider"]) != "local_static" {
t.Fatalf("expected local static provider metadata, got %+v", upload["storageChannel"]) t.Fatalf("expected local static provider metadata, got %+v", upload["storageChannel"])
@ -277,3 +353,26 @@ func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) {
t.Fatalf("stored uploaded payload does not match source payload") t.Fatalf("stored uploaded payload does not match source payload")
} }
} }
func TestRedactGeneratedResultRawDataAudioPayload(t *testing.T) {
audioHex := strings.Repeat("49443304", 40)
result := map[string]any{
"raw_data": map[string]any{
"base_resp": map[string]any{"status_code": float64(0)},
"data": map[string]any{"audio": audioHex},
},
}
if !redactGeneratedResultRawData(result) {
t.Fatalf("expected raw audio payload to be redacted")
}
rawData, _ := result["raw_data"].(map[string]any)
data, _ := rawData["data"].(map[string]any)
audio, _ := data["audio"].(map[string]any)
if audio["redacted"] != true || audio["encoding"] != "hex" || audio["contentType"] != "audio/mpeg" {
t.Fatalf("unexpected redacted audio payload: %+v", audio)
}
if _, ok := audio["sha256"].(string); !ok {
t.Fatalf("expected redacted audio payload to include sha256: %+v", audio)
}
}

View File

@ -37,13 +37,13 @@ const providerOptions = [
const defaultScenes = ['upload', 'image_result']; const defaultScenes = ['upload', 'image_result'];
const sceneOptions = [ const sceneOptions = [
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' }, { value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
{ value: 'image_result', label: '返图', description: '模型返回 base64 / buffer 图片或视频后的转存' }, { value: 'image_result', label: '生成媒体结果', description: '模型返回 base64 / buffer 图片、音频或视频后的转存' },
]; ];
const resultUploadPolicyOptions = [ const resultUploadPolicyOptions = [
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存base64 / buffer 等结果转存后保存 URL' }, { value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存base64 / buffer 等结果转存后保存 URL' },
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' }, { value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等生成媒体结果都会转存到当前文件渠道' },
{ value: 'upload_none', label: '全部不转存', description: '链接结果直接保存base64 / buffer 结果写入网关本地静态托管后保存 URL' }, { value: 'upload_none', label: '全部不转存', description: '链接结果直接保存base64 / buffer 结果写入 24 小时本地静态托管后保存 URL' },
]; ];
export function SystemSettingsPanel(props: { export function SystemSettingsPanel(props: {
@ -146,8 +146,8 @@ export function SystemSettingsPanel(props: {
<section className="fileStoragePanel"> <section className="fileStoragePanel">
<div className="fileStorageSettingsCard"> <div className="fileStorageSettingsCard">
<div> <div>
<strong></strong> <strong></strong>
<span></span> <span> 24 </span>
</div> </div>
<Label> <Label>