From a2ed32447abc58b85b10ae30a8430c0a8b2613ac Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 24 Jul 2026 21:57:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20=E8=A1=A5=E9=BD=90=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E7=BB=93=E6=9E=9C=E4=BA=8C=E8=BF=9B=E5=88=B6=E8=BD=AC?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产真实 Gemini 图片任务可能在标准 data 之外保留嵌套 Base64,导致最终持久化关口拒绝任务成功。\n\n在所有生成结果返回前统一执行递归转存,并对仍未被常规媒体转换识别的二进制回退到本地结果物化,确保 PostgreSQL 不接收原始二进制。\n\n验证:go test ./... -count=1;go vet ./...;使用生产 Gemini Flash Image 平台配置完成本地真实图片编辑请求,文件哈希一致且持久化结果无内联二进制。 --- apps/api/internal/runner/upload.go | 63 ++++++++++++++++++++++--- apps/api/internal/runner/upload_test.go | 46 ++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/apps/api/internal/runner/upload.go b/apps/api/internal/runner/upload.go index aed2f6d..526748d 100644 --- a/apps/api/internal/runner/upload.go +++ b/apps/api/internal/runner/upload.go @@ -98,8 +98,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task return next, nil } if len(data) == 0 && !rawNeedsUpload { - redactGeneratedResultRawData(result) - return result, nil + return s.finalizeGeneratedAssets(ctx, taskID, taskKind, result, policy, nil, false, 0) } // Topaz download URLs are short-lived. Persist them before the task can be // marked succeeded even when the global policy normally keeps URL media. @@ -128,15 +127,16 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task } rawNeedsUpload = rawNeedsUpload && policy.UploadInlineMedia if !needsUpload && !changed && !rawNeedsUpload { - redactGeneratedResultRawData(result) - return result, nil + return s.finalizeGeneratedAssets(ctx, taskID, taskKind, result, policy, nil, false, 0) } var channels []store.FileStorageChannel + channelsLoaded := false if needsUpload || rawNeedsUpload { channels, err = s.activeFileStorageChannels(ctx, store.FileStorageSceneImageResult) if err != nil { return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true} } + channelsLoaded = true } next := map[string]any{} for key, value := range result { @@ -217,8 +217,59 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task next["raw"] = raw } } - redactGeneratedResultRawData(next) - return next, nil + return s.finalizeGeneratedAssets(ctx, taskID, taskKind, next, policy, channels, channelsLoaded, len(nextData)) +} + +func (s *Service) finalizeGeneratedAssets( + ctx context.Context, + taskID string, + taskKind string, + result map[string]any, + policy generatedAssetUploadPolicy, + channels []store.FileStorageChannel, + channelsLoaded bool, + startIndex int, +) (map[string]any, error) { + redactGeneratedResultRawData(result) + if !TaskResultHasInlineBinary(result) { + return result, nil + } + next := result + if policy.UploadInlineMedia { + var err error + if !channelsLoaded { + channels, err = s.activeFileStorageChannels(ctx, store.FileStorageSceneImageResult) + if err != nil { + return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true} + } + } + index := startIndex + uploaded, changed, err := s.uploadGeneratedRawMediaValue(ctx, taskID, taskKind, next, "", nil, policy, channels, &index) + if err != nil { + return nil, err + } + if changed { + mapped, ok := uploaded.(map[string]any) + if !ok { + return nil, &clients.ClientError{ + Code: "result_binary_not_materialized", + Message: "generated result is not a JSON object", + StatusCode: 500, + Retryable: false, + } + } + next = mapped + } + } + if !TaskResultHasInlineBinary(next) { + return next, nil + } + persistent, _, err := s.materializeLocalBinaryResult(ctx, taskID, next) + if err != nil { + return nil, err + } + redactGeneratedResultRawData(persistent) + return persistent, nil } func generatedRawValueHasInlineMedia(value any, key string, siblings map[string]any) bool { diff --git a/apps/api/internal/runner/upload_test.go b/apps/api/internal/runner/upload_test.go index 1859fb9..ee97fd4 100644 --- a/apps/api/internal/runner/upload_test.go +++ b/apps/api/internal/runner/upload_test.go @@ -236,6 +236,52 @@ func TestGeneratedAssetUploadPolicyFromName(t *testing.T) { } } +func TestFinalizeGeneratedAssetsUploadsNestedInlineBinaryUnderDefaultPolicy(t *testing.T) { + storageDir := t.TempDir() + service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}} + payload := append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 160)...) + encoded := base64.StdEncoding.EncodeToString(payload) + result := map[string]any{ + "id": "image-edit", + "provider_result": map[string]any{ + "binary_data_base64": encoded, + "mime_type": "image/png", + }, + } + + finalized, err := service.finalizeGeneratedAssets( + context.Background(), + "task-nested-binary", + "images.edits", + result, + defaultGeneratedAssetUploadPolicy(), + nil, + true, + 0, + ) + if err != nil { + t.Fatalf("finalize generated assets: %v", err) + } + if TaskResultHasInlineBinary(finalized) { + t.Fatal("finalized result still contains inline binary") + } + nested := finalized["provider_result"].(map[string]any) + reference, ok := nested["binary_data_base64"].(map[string]any) + if !ok { + t.Fatalf("nested binary should be replaced by an asset reference: %+v", nested) + } + if urlValue := stringFromAny(reference["url"]); !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-nested-binary-01-") { + t.Fatalf("unexpected local static URL: %s", urlValue) + } + entries, err := os.ReadDir(storageDir) + if err != nil { + t.Fatalf("read generated storage: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected one localized result file, got %d", len(entries)) + } +} + func TestResolvedGeneratedAssetContentTypePrefersDetectedMedia(t *testing.T) { pngPayload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0}