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:
2026-08-05 18:15:06 +08:00
parent f9b945e4aa
commit b13392ef50
22 changed files with 1367 additions and 176 deletions
@@ -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