fix(media): 统一图片结果 URL 化并限制同步 Base64
将上游 URL 直接持久化,内联媒体经对象存储后仅保留 URL 与内部定位元数据;异步轮询、任务详情和幂等重放统一使用零对象读取的 URL 投影,并增加 64KiB 响应门禁。 OpenAI 图片接口接受 url 与 b64_json,同步 Base64 限制为 20MiB 和每 Pod 2 并发;新增历史结果迁移清零门禁、结果指标和 API GOMEMLIMIT。 验证:API go test ./...、go vet、聚焦 race、pnpm openapi、pnpm lint/test/build、迁移安全检查与 docker compose config 均通过。
This commit is contained in:
@@ -55,6 +55,43 @@ func TestGeneratedAssetDecisionUploadsInlineImageBase64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadGeneratedAssetsReusesDuplicateSHAWithinTask(t *testing.T) {
|
||||
putCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
service := &Service{}
|
||||
service.directOSS = &directOSSUploader{
|
||||
endpoint: server.URL, bucket: "bucket", accessKeyID: "access-id", accessKeySecret: "access-secret", objectPrefix: "media",
|
||||
}
|
||||
payload := append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)
|
||||
encoded := base64.StdEncoding.EncodeToString(payload)
|
||||
result := map[string]any{"data": []any{
|
||||
map[string]any{"b64_json": encoded, "mime_type": "image/png"},
|
||||
map[string]any{"b64_json": encoded, "mime_type": "image/png"},
|
||||
}}
|
||||
|
||||
stored, err := service.uploadGeneratedAssets(t.Context(), "task-duplicate", "images.generations", "", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := stored["data"].([]any)
|
||||
first := items[0].(map[string]any)
|
||||
second := items[1].(map[string]any)
|
||||
if putCount != 1 || stringFromAny(first["url"]) == "" || first["url"] != second["url"] {
|
||||
t.Fatalf("duplicate SHA was not reused: puts=%d first=%#v second=%#v", putCount, first, second)
|
||||
}
|
||||
if TaskResultHasInlineBinary(stored) || first["b64_json"] != nil || second["b64_json"] != nil {
|
||||
t.Fatalf("inline payload remained after deduplicated upload: %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaResultMaterializationConcurrencyIsBounded(t *testing.T) {
|
||||
service := New(config.Config{MediaMaterializationConcurrency: 1}, nil, nil)
|
||||
result := map[string]any{
|
||||
@@ -227,6 +264,15 @@ func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedAssetDecisionRejectsUnsupportedURLScheme(t *testing.T) {
|
||||
_, err := generatedAssetDecisionForItem("images.generations", map[string]any{
|
||||
"url": "ftp://files.example/result.png", "type": "image",
|
||||
}, defaultGeneratedAssetUploadPolicy())
|
||||
if clients.ErrorCode(err) != "invalid_upstream_result" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -329,13 +375,15 @@ func TestFinalizeGeneratedAssetsUploadsNestedInlineBinaryUnderDefaultPolicy(t *t
|
||||
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 _, exists := nested["binary_data_base64"]; exists {
|
||||
t.Fatalf("nested Base64 field was retained: %+v", nested)
|
||||
}
|
||||
if urlValue := stringFromAny(reference["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") {
|
||||
if urlValue := stringFromAny(nested["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") {
|
||||
t.Fatalf("unexpected object storage URL: %s", urlValue)
|
||||
}
|
||||
if nested["upload"] == nil {
|
||||
t.Fatalf("nested upload metadata was not retained: %+v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(t *testing.T) {
|
||||
@@ -368,7 +416,7 @@ func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(
|
||||
t.Fatalf("finalized result still contains generated media: %#v", finalized)
|
||||
}
|
||||
reference, ok := finalized["provider_payload"].(map[string]any)
|
||||
if !ok || reference["assetRef"] == nil || reference["upload"] == nil {
|
||||
if !ok || reference["url"] == nil || reference["upload"] == nil || reference["assetRef"] != nil {
|
||||
t.Fatalf("detected media was not objectified: %#v", finalized["provider_payload"])
|
||||
}
|
||||
if finalized["thought_signature"] != opaque {
|
||||
@@ -407,9 +455,11 @@ func TestFinalizeGeneratedAssetsMaterializesGeminiImageData(t *testing.T) {
|
||||
}
|
||||
data := finalized["data"].([]any)
|
||||
item := data[0].(map[string]any)
|
||||
reference, ok := item["b64_json"].(map[string]any)
|
||||
if !ok || reference["assetRef"] == nil || reference["upload"] == nil {
|
||||
t.Fatalf("Gemini b64_json was not replaced by an object reference: %#v", item)
|
||||
if _, exists := item["b64_json"]; exists {
|
||||
t.Fatalf("Gemini b64_json was retained: %#v", item)
|
||||
}
|
||||
if stringFromAny(item["url"]) == "" || item["upload"] == nil || item["assetRef"] != nil {
|
||||
t.Fatalf("Gemini b64_json was not replaced by a URL result: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,7 +524,7 @@ func TestUploadGeneratedAudioFailsWithoutObjectStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *testing.T) {
|
||||
func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
service := &Service{}
|
||||
@@ -516,9 +566,9 @@ func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *tes
|
||||
if !ok {
|
||||
t.Fatalf("inlineData.data should be an asset reference, got %+v", inlineData["data"])
|
||||
}
|
||||
ref, _ := data["assetRef"].(map[string]any)
|
||||
if ref["sha256"] == "" || ref["contentType"] != "image/png" || ref["size"] != len(payload) {
|
||||
t.Fatalf("unexpected asset ref: %+v", ref)
|
||||
upload, _ := data["upload"].(map[string]any)
|
||||
if upload["sha256"] == "" || upload["contentType"] != "image/png" || upload["size"] != len(payload) || data["assetRef"] != nil {
|
||||
t.Fatalf("unexpected URL storage metadata: %+v", data)
|
||||
}
|
||||
if urlValue := stringFromAny(data["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") || !strings.HasSuffix(urlValue, ".png") {
|
||||
t.Fatalf("unexpected raw media URL: %s", urlValue)
|
||||
@@ -528,7 +578,7 @@ func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithAssetRefs(t *testing.T) {
|
||||
func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithURLs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
service := &Service{}
|
||||
@@ -551,7 +601,7 @@ func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithAssetRefs(t *test
|
||||
next := uploaded.(map[string]any)
|
||||
for _, key := range []string{"buffer", "audio_bytes", "direct"} {
|
||||
item, ok := next[key].(map[string]any)
|
||||
if !ok || item["assetRef"] == nil || item["upload"] == nil {
|
||||
if !ok || item["url"] == nil || item["upload"] == nil || item["assetRef"] != nil {
|
||||
t.Fatalf("%s was not objectified: %#v", key, next[key])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user