fix(storage): 补齐嵌套结果二进制转存

生产真实 Gemini 图片任务可能在标准 data 之外保留嵌套 Base64,导致最终持久化关口拒绝任务成功。\n\n在所有生成结果返回前统一执行递归转存,并对仍未被常规媒体转换识别的二进制回退到本地结果物化,确保 PostgreSQL 不接收原始二进制。\n\n验证:go test ./... -count=1;go vet ./...;使用生产 Gemini Flash Image 平台配置完成本地真实图片编辑请求,文件哈希一致且持久化结果无内联二进制。
This commit is contained in:
2026-07-24 21:57:52 +08:00
parent 4f163ea6d7
commit a2ed32447a
2 changed files with 103 additions and 6 deletions
+57 -6
View File
@@ -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 {
+46
View File
@@ -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}