fix(storage): 补齐嵌套结果二进制转存
生产真实 Gemini 图片任务可能在标准 data 之外保留嵌套 Base64,导致最终持久化关口拒绝任务成功。\n\n在所有生成结果返回前统一执行递归转存,并对仍未被常规媒体转换识别的二进制回退到本地结果物化,确保 PostgreSQL 不接收原始二进制。\n\n验证:go test ./... -count=1;go vet ./...;使用生产 Gemini Flash Image 平台配置完成本地真实图片编辑请求,文件哈希一致且持久化结果无内联二进制。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user