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:
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -209,6 +210,181 @@ func TestHydrateGeneratedResultRefreshesPrivateObjectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateCanonicalUploadedResultForExplicitSynchronousBase64(t *testing.T) {
|
||||
payload := []byte("canonical uploaded image")
|
||||
digest := sha256.Sum256(payload)
|
||||
getCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
getCount++
|
||||
_, _ = w.Write(payload)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := config.Config{
|
||||
MediaOSSDirectEnabled: true, MediaOSSEndpoint: server.URL, MediaOSSBucket: "media-bucket",
|
||||
MediaOSSAccessKeyID: "access-id", MediaOSSAccessKeySecret: "access-secret", MediaOSSObjectPrefix: "media",
|
||||
}
|
||||
service := &Service{cfg: cfg, directOSS: newDirectOSSUploader(cfg)}
|
||||
result := map[string]any{"thinking_bytes": strings.Repeat("opaque", 1024), "thought_signature": "signature", "data": []any{map[string]any{
|
||||
"type": "image", "url": "https://expired.example/result.png", "mime_type": "image/png",
|
||||
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
|
||||
"upload": map[string]any{
|
||||
"url": "https://expired.example/result.png", "objectKey": "media/image_result/hash.png", "accessScope": "private",
|
||||
"sha256": hex.EncodeToString(digest[:]), "size": len(payload), "contentType": "image/png",
|
||||
"storageChannel": map[string]any{"channelKey": "environment-direct-oss", "provider": "aliyun_oss"},
|
||||
},
|
||||
}}}
|
||||
projected, err := service.ProjectTaskResultURLs(t.Context(), "task-sync-b64", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectedItem := projected["data"].([]any)[0].(map[string]any)
|
||||
if stringFromAny(projectedItem["url"]) == "" || getCount != 0 {
|
||||
t.Fatalf("URL projection read object: item=%#v getCount=%d", projectedItem, getCount)
|
||||
}
|
||||
if projected["thinking_bytes"] != nil || projected["thought_signature"] != nil {
|
||||
t.Fatalf("provider metadata leaked: %#v", projected)
|
||||
}
|
||||
|
||||
hydrated, err := service.HydrateTaskResult(t.Context(), "task-sync-b64", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := hydrated["data"].([]any)[0].(map[string]any)
|
||||
if got := stringFromAny(item["b64_json"]); got != base64.StdEncoding.EncodeToString(payload) {
|
||||
t.Fatalf("Base64=%q", got)
|
||||
}
|
||||
if item["url"] != nil || item["upload"] != nil || getCount != 1 {
|
||||
t.Fatalf("unexpected hydrated item=%#v getCount=%d", item, getCount)
|
||||
}
|
||||
if hydrated["thinking_bytes"] != nil || hydrated["thought_signature"] != nil {
|
||||
t.Fatalf("provider metadata leaked into synchronous response: %#v", hydrated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronousInlineResultBytesUsesMetadataBeforeObjectRead(t *testing.T) {
|
||||
result := map[string]any{"data": []any{
|
||||
map[string]any{
|
||||
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
|
||||
"upload": map[string]any{
|
||||
"objectKey": "media/image_result/large.png", "sha256": strings.Repeat("a", 64),
|
||||
"size": MaxSynchronousInlineResponseBytes + 1, "contentType": "image/png",
|
||||
"storageChannel": map[string]any{"channelKey": "environment-direct-oss"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
if got := SynchronousInlineResultBytes(result); got != MaxSynchronousInlineResponseBytes+1 {
|
||||
t.Fatalf("stored bytes=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTaskResultURLsConvertsLegacyAssetWithoutReadingObject(t *testing.T) {
|
||||
payload := []byte("must never be downloaded")
|
||||
digest := sha256.Sum256(payload)
|
||||
getCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
getCount++
|
||||
}
|
||||
http.Error(w, "object read is forbidden", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &Service{}
|
||||
result := map[string]any{"data": []any{map[string]any{
|
||||
"b64_json": map[string]any{
|
||||
"assetRef": map[string]any{
|
||||
"sha256": hex.EncodeToString(digest[:]), "contentType": "image/png", "size": len(payload), "url": server.URL + "/result.png",
|
||||
},
|
||||
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
|
||||
},
|
||||
}}}
|
||||
|
||||
projected, err := service.ProjectTaskResultURLs(t.Context(), "task-url-only", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := projected["data"].([]any)[0].(map[string]any)
|
||||
if got := stringFromAny(item["url"]); got != server.URL+"/result.png" {
|
||||
t.Fatalf("projected URL=%q", got)
|
||||
}
|
||||
if item["b64_json"] != nil || item["assetRef"] != nil || item["assetStorage"] != nil || item["upload"] != nil {
|
||||
t.Fatalf("internal or inline fields leaked: %#v", item)
|
||||
}
|
||||
if getCount != 0 {
|
||||
t.Fatalf("projector downloaded object %d time(s)", getCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTaskResultURLsRejectsHistoricalInlinePayload(t *testing.T) {
|
||||
service := &Service{}
|
||||
_, err := service.ProjectTaskResultURLs(t.Context(), "task-inline", map[string]any{
|
||||
"data": []any{map[string]any{"b64_json": base64.StdEncoding.EncodeToString([]byte("inline"))}},
|
||||
})
|
||||
assertClientErrorCode(t, err, "result_materialization_required")
|
||||
}
|
||||
|
||||
func TestMigrateTaskResultToURLsRewritesLegacyAssetReference(t *testing.T) {
|
||||
payload := []byte("legacy")
|
||||
digest := sha256.Sum256(payload)
|
||||
service := &Service{}
|
||||
result := map[string]any{"data": []any{map[string]any{
|
||||
"b64_json": map[string]any{
|
||||
"assetRef": map[string]any{
|
||||
"sha256": hex.EncodeToString(digest[:]), "contentType": "image/png", "size": len(payload), "url": "https://cdn.example/result.png",
|
||||
},
|
||||
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
|
||||
},
|
||||
}}}
|
||||
|
||||
migrated, changed, err := service.MigrateTaskResultToURLs(t.Context(), "task-legacy", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || TaskResultNeedsURLMigration(migrated) {
|
||||
t.Fatalf("legacy result was not fully migrated: %#v", migrated)
|
||||
}
|
||||
item := migrated["data"].([]any)[0].(map[string]any)
|
||||
if stringFromAny(item["url"]) != "https://cdn.example/result.png" || item["upload"] == nil || item["b64_json"] != nil {
|
||||
t.Fatalf("unexpected migrated result: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateTaskResultToURLsUploadsActiveLocalPlaceholder(t *testing.T) {
|
||||
payload := []byte("historical local image")
|
||||
digest := sha256.Sum256(payload)
|
||||
putCount := 0
|
||||
storageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
putCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}))
|
||||
defer storageServer.Close()
|
||||
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.directOSS = &directOSSUploader{
|
||||
endpoint: storageServer.URL, bucket: "bucket", accessKeyID: "access-id", accessKeySecret: "access-secret", objectPrefix: "media",
|
||||
}
|
||||
writeHistoricalLocalBinaryFixture(t, service, "task-local-migrate", payload)
|
||||
placeholder := fmt.Sprintf("%ssha256=%s;bytes=%d;mime=image/png;encoding=base64]", localBinaryPlaceholderPrefix, hex.EncodeToString(digest[:]), len(payload))
|
||||
result := map[string]any{"data": []any{map[string]any{"b64_json": placeholder}}}
|
||||
|
||||
migrated, changed, err := service.MigrateTaskResultToURLs(t.Context(), "task-local-migrate", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || TaskResultNeedsURLMigration(migrated) || putCount != 1 {
|
||||
t.Fatalf("placeholder migration changed=%t putCount=%d result=%#v", changed, putCount, migrated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateLocalBinaryResultReturnsExpiredAndCorruptedErrors(t *testing.T) {
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultTTLHours = 1
|
||||
|
||||
Reference in New Issue
Block a user