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
+496 -2
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
@@ -21,6 +22,9 @@ import (
)
const (
// MaxSynchronousInlineResponseBytes bounds the raw media payload restored for
// an explicit synchronous OpenAI-compatible b64_json response.
MaxSynchronousInlineResponseBytes = int64(20 << 20)
localBinaryResultDirName = "results"
localBinaryPlaceholderPrefix = "[GatewayBinary:v1;"
localBinaryGenericBase64MinLength = 4096
@@ -96,6 +100,225 @@ func (s *Service) MaterializeTaskResultForStorage(ctx context.Context, taskID st
return next, hadInline && !TaskResultHasInlineBinary(next), nil
}
// MigrateTaskResultToURLs materializes historical inline payloads and rewrites
// legacy assetRef wrappers into the canonical URL-plus-upload storage shape.
func (s *Service) MigrateTaskResultToURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) {
next := result
changed := false
if TaskResultHasLocalPlaceholder(next) {
restored, restoredChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, next, 0)
if err != nil {
return nil, false, err
}
mapped, ok := restored.(map[string]any)
if !ok {
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
next = mapped
changed = restoredChanged
}
if TaskResultHasInlineBinary(next) {
materialized, materializedChanged, err := s.MaterializeTaskResultForStorage(ctx, taskID, next)
if err != nil {
return nil, false, err
}
next = materialized
changed = materializedChanged
}
rewritten, rewrittenChanged := migrateStoredResultURLValue(next, 0)
mapped, ok := rewritten.(map[string]any)
if !ok {
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
return mapped, changed || rewrittenChanged, nil
}
// TaskResultNeedsURLMigration reports whether a stored result contains inline
// binary, a local placeholder, or the legacy assetRef representation.
func TaskResultNeedsURLMigration(result map[string]any) bool {
if TaskResultHasInlineBinary(result) {
return true
}
return storedResultURLValueNeedsMigration(result, 0)
}
// TaskResultHasLocalPlaceholder reports whether a historical result still
// references a node-local GatewayBinary file.
func TaskResultHasLocalPlaceholder(result map[string]any) bool {
return localBinaryResultHasPlaceholders(result)
}
func (s *Service) restoreLocalPlaceholderValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
if depth >= localBinaryMaxDepth {
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
}
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case string:
descriptor, ok := parseLocalBinaryPlaceholder(typed)
if !ok {
return value, false, nil
}
payload, err := s.readLocalBinaryResult(taskID, descriptor)
if err != nil {
return nil, false, err
}
encoded := base64.StdEncoding.EncodeToString(payload)
if descriptor.Encoding == "data-uri" {
return "data:" + descriptor.ContentType + ";base64," + encoded, true, nil
}
return encoded, true, nil
default:
return value, false, nil
}
}
func storedResultURLValueNeedsMigration(value any, depth int) bool {
if depth >= localBinaryMaxDepth {
return true
}
switch typed := value.(type) {
case map[string]any:
if _, ok := typed["assetRef"].(map[string]any); ok {
return true
}
for _, child := range typed {
if storedResultURLValueNeedsMigration(child, depth+1) {
return true
}
}
case []any:
for _, child := range typed {
if storedResultURLValueNeedsMigration(child, depth+1) {
return true
}
}
case string:
_, ok := parseLocalBinaryPlaceholder(typed)
return ok
}
return false
}
func migrateStoredResultURLValue(value any, depth int) (any, bool) {
if depth >= localBinaryMaxDepth {
return value, false
}
switch typed := value.(type) {
case map[string]any:
if asset, ok := generatedResultAssetReference(typed); ok {
upload, _ := typed["upload"].(map[string]any)
if upload == nil {
upload = uploadMetadataFromLegacyAsset(asset)
}
accessURL := firstNonEmptyString(stringFromAny(upload["url"]), asset.URL)
next := map[string]any{
"url": accessURL,
"upload": upload,
"assetStorage": map[string]any{
"scene": store.FileStorageSceneImageResult,
"source": stringFromAny(typed["assetStorage"].(map[string]any)["source"]),
"strategy": "migrate_asset_ref",
"contentType": asset.ContentType,
},
}
for _, key := range []string{"type", "mime_type", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if item, exists := typed[key]; exists {
next[key] = item
}
}
if stringFromAny(next["mime_type"]) == "" && asset.ContentType != "" {
next["mime_type"] = asset.ContentType
}
return next, true
}
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
if resultCanonicalInlineField(key) {
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
mergeProjectedResultURL(next, item)
changed = true
continue
}
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false
}
return next, true
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false
}
return next, true
default:
return value, false
}
}
func uploadMetadataFromLegacyAsset(asset store.RequestAsset) map[string]any {
upload := map[string]any{
"url": asset.URL,
"objectKey": asset.ObjectKey,
"contentType": asset.ContentType,
"size": asset.ByteSize,
"sha256": asset.SHA256,
"accessScope": asset.AccessScope,
"storageChannel": map[string]any{
"id": asset.StorageChannelID,
"channelKey": asset.StorageChannelKey,
"provider": asset.StorageProvider,
},
}
if asset.ExpiresAt != nil {
upload["objectExpiresAt"] = asset.ExpiresAt.Format(time.RFC3339)
}
return upload
}
// CompactExpiredTaskResultForStorage keeps the historical maintenance API but
// now uses the same object-storage path as live results. New GatewayBinary
// placeholders are never created; their parser remains read-only compatibility.
@@ -325,8 +548,9 @@ func verifyLocalBinaryFile(path string, expectedHash string, expectedSize int64)
return nil
}
// HydrateTaskResult restores placeholders from verified local files. It is only
// used by result/detail/replay endpoints, never by task lists or callbacks.
// HydrateTaskResult restores verified stored payloads only for bounded,
// explicit synchronous inline responses. Asynchronous readers use
// ProjectTaskResultURLs and never call this path.
func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
next, changed, err := s.hydrateLocalBinaryValue(ctx, taskID, result, 0)
if err != nil {
@@ -342,6 +566,244 @@ func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result m
return mapped, nil
}
// SynchronousInlineResultBytes returns the stored raw byte count that the
// synchronous hydration path would read. It deliberately inspects metadata
// only, so callers can reject oversized responses before any object GET or
// local file read occurs.
func SynchronousInlineResultBytes(result map[string]any) int64 {
return synchronousInlineResultValueBytes(result, 0)
}
func synchronousInlineResultValueBytes(value any, depth int) int64 {
if depth >= localBinaryMaxDepth {
return 0
}
switch typed := value.(type) {
case map[string]any:
if asset, _, ok := generatedResultUploadReference(typed); ok {
storage, _ := typed["assetStorage"].(map[string]any)
if resultCanonicalInlineField(stringFromAny(storage["source"])) {
return asset.ByteSize
}
}
if asset, ok := generatedResultAssetReference(typed); ok {
return asset.ByteSize
}
var total int64
for _, child := range typed {
total += synchronousInlineResultValueBytes(child, depth+1)
if total > MaxSynchronousInlineResponseBytes {
return total
}
}
return total
case []any:
var total int64
for _, child := range typed {
total += synchronousInlineResultValueBytes(child, depth+1)
if total > MaxSynchronousInlineResponseBytes {
return total
}
}
return total
case string:
if descriptor, ok := parseLocalBinaryPlaceholder(typed); ok {
return descriptor.Size
}
}
return 0
}
// ProjectTaskResultURLs returns the public, URL-only representation of a
// stored task result. It may refresh a signed object-storage URL, but it never
// reads object bytes or restores historical inline binary payloads.
func (s *Service) ProjectTaskResultURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
next, changed, err := s.projectTaskResultURLValue(ctx, taskID, result, 0)
if err != nil {
return nil, err
}
if !changed {
return result, nil
}
mapped, ok := next.(map[string]any)
if !ok {
return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
return mapped, nil
}
func (s *Service) projectTaskResultURLValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
if depth >= localBinaryMaxDepth {
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
}
switch typed := value.(type) {
case map[string]any:
if asset, _, ok := generatedResultUploadReference(typed); ok {
accessURL, err := s.resultAssetAccessURL(ctx, asset)
if err != nil {
return nil, false, err
}
next := publicResultURLItem(typed, accessURL, asset.ContentType)
return next, true, nil
}
if asset, ok := generatedResultAssetReference(typed); ok {
accessURL, err := s.resultAssetAccessURL(ctx, asset)
if err != nil {
return nil, false, err
}
next := publicResultURLItem(typed, accessURL, asset.ContentType)
return next, true, nil
}
if _, _, ok := localBufferObjectBytes(typed); ok {
return nil, false, resultMaterializationRequired(taskID)
}
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
if resultInternalField(key) {
changed = true
continue
}
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
if resultCanonicalInlineField(key) {
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
mergeProjectedResultURL(next, item)
changed = true
continue
}
if raw, ok := child.(string); ok {
if requestAssetStringIsHTTPURL(raw) {
next["url"] = strings.TrimSpace(raw)
changed = true
continue
}
if strings.TrimSpace(raw) != "" {
return nil, false, resultMaterializationRequired(taskID)
}
}
}
if localBinaryKey(key) {
switch inline := child.(type) {
case map[string]any:
if stringFromAny(inline["url"]) != "" {
mergeProjectedResultURL(next, inline)
changed = true
continue
}
case string:
if strings.TrimSpace(inline) != "" {
return nil, false, resultMaterializationRequired(taskID)
}
case []any:
if len(inline) > 0 {
return nil, false, resultMaterializationRequired(taskID)
}
}
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []byte:
if len(typed) > 0 {
return nil, false, resultMaterializationRequired(taskID)
}
return value, false, nil
case string:
if _, ok := parseLocalBinaryPlaceholder(typed); ok {
return nil, false, resultMaterializationRequired(taskID)
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(typed)), "data:") {
return nil, false, resultMaterializationRequired(taskID)
}
return value, false, nil
default:
return value, false, nil
}
}
func (s *Service) resultAssetAccessURL(ctx context.Context, asset store.RequestAsset) (string, error) {
accessURL, err := s.requestAssetAccessURL(ctx, asset)
if err != nil || strings.TrimSpace(accessURL) == "" {
message := "stored result URL is unavailable"
if err != nil {
message = err.Error()
}
return "", &clients.ClientError{Code: "result_url_unavailable", Message: message, StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
return accessURL, nil
}
func resultMaterializationRequired(taskID string) error {
return &clients.ClientError{
Code: "result_materialization_required",
Message: "stored result must be migrated to object storage before it can be returned",
Details: map[string]any{"task_id": strings.TrimSpace(taskID)},
StatusCode: http.StatusServiceUnavailable,
Retryable: true,
}
}
func publicResultURLItem(source map[string]any, accessURL string, contentType string) map[string]any {
next := map[string]any{"url": accessURL}
for _, key := range []string{"type", "mime_type", "mimeType", "content_type", "contentType", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if value, ok := source[key]; ok && value != nil {
next[key] = value
}
}
if stringFromAny(next["mime_type"]) == "" && strings.TrimSpace(contentType) != "" {
next["mime_type"] = strings.TrimSpace(contentType)
}
return next
}
func mergeProjectedResultURL(target map[string]any, item map[string]any) {
for key, value := range item {
if _, exists := target[key]; !exists || key == "url" {
target[key] = value
}
}
}
func resultInternalField(key string) bool {
switch normalizeLocalBinaryKey(key) {
case "assetref", "assetstorage", "upload", "raw", "rawdata", "rawresponse", "providerpayload", "providerresponse", "providerraw", "upstreamresponse", "thinkingbytes", "thoughtsignature", "signaturebuffer":
return true
default:
return false
}
}
func resultCanonicalInlineField(key string) bool {
normalized := normalizeLocalBinaryKey(key)
return normalized == "b64json" || normalized == "base64" || normalized == "b64" ||
normalized == "datauri" || strings.Contains(normalized, "base64") || strings.HasSuffix(normalized, "b64")
}
func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
@@ -374,6 +836,31 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
}
typed = next
refreshedAccessURL = true
storage, _ := typed["assetStorage"].(map[string]any)
sourceKey := strings.TrimSpace(stringFromAny(storage["source"]))
if resultCanonicalInlineField(sourceKey) && asset.SHA256 != "" && asset.ByteSize > 0 {
payload, contentType, err := s.readGeneratedResultAsset(ctx, asset)
if err != nil {
return nil, false, err
}
encoded := base64.StdEncoding.EncodeToString(payload)
inline := make(map[string]any, len(typed))
for key, item := range typed {
if resultInternalField(key) || key == "url" || key == "image_url" || key == "video_url" || key == "audio_url" {
continue
}
inline[key] = item
}
if generatedResultAssetUsesDataURI(typed) {
inline[sourceKey] = "data:" + contentType + ";base64," + encoded
} else {
inline[sourceKey] = encoded
}
if stringFromAny(inline["mime_type"]) == "" {
inline["mime_type"] = contentType
}
return inline, true, nil
}
}
if ref, ok := generatedResultAssetReference(typed); ok {
payload, contentType, err := s.readGeneratedResultAsset(ctx, ref)
@@ -389,6 +876,10 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
next := make(map[string]any, len(typed))
changed := refreshedAccessURL
for key, childValue := range typed {
if resultInternalField(key) {
changed = true
continue
}
child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
@@ -446,6 +937,9 @@ func generatedResultUploadReference(value map[string]any) (store.RequestAsset, m
return store.RequestAsset{}, nil, false
}
return store.RequestAsset{
SHA256: strings.ToLower(strings.TrimSpace(stringFromAny(upload["sha256"]))),
ContentType: stringFromAny(upload["contentType"]),
ByteSize: int64(floatFromAny(upload["size"])),
URL: stringFromAny(upload["url"]),
StorageProvider: stringFromAny(channel["provider"]),
StorageChannelID: stringFromAny(channel["id"]),