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
+69 -49
View File
@@ -50,6 +50,13 @@ type generatedAssetDecision struct {
StripKeys []string
}
type generatedAssetUploadResult struct {
upload map[string]any
contentType string
kind string
strategy string
}
type generatedInlineAsset struct {
Bytes []byte
ContentType string
@@ -150,6 +157,9 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
return nil, err
}
decisions[index] = decision
if _, mediaURL := mediaURLSourceFromItem(item); mediaURL != "" && decision.URL == nil {
s.observeResultStorage("upstream_url")
}
if decision.Inline != nil || decision.URL != nil {
needsUpload = true
}
@@ -175,6 +185,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
next[key] = value
}
nextData := make([]any, 0, len(data))
uploadCache := make(map[string]generatedAssetUploadResult)
for index, rawItem := range data {
item, _ := rawItem.(map[string]any)
if item == nil {
@@ -197,7 +208,15 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
var contentType string
var err error
if decision.Inline != nil {
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
cacheKey := generatedInlineAssetCacheKey(decision.Inline)
if cached, ok := uploadCache[cacheKey]; ok {
upload, contentType, kind, strategy = cached.upload, cached.contentType, cached.kind, cached.strategy
} else {
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
if err == nil {
uploadCache[cacheKey] = generatedAssetUploadResult{upload: upload, contentType: contentType, kind: kind, strategy: strategy}
}
}
sourceKey = decision.Inline.SourceKey
} else {
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels, acceptanceEmulatorBaseURL)
@@ -233,9 +252,6 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if contentType != "" && stringFromAny(merged["mime_type"]) == "" {
merged["mime_type"] = contentType
}
if decision.Inline != nil && strings.TrimSpace(sourceKey) != "" {
merged[sourceKey] = generatedRawMediaReference(decision.Inline, upload, contentType, kind, strategy)
}
}
nextData = append(nextData, merged)
}
@@ -255,6 +271,15 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
return s.finalizeGeneratedAssets(ctx, taskID, taskKind, next, policy, channels, channelsLoaded, len(nextData))
}
func generatedInlineAssetCacheKey(asset *generatedInlineAsset) string {
if asset == nil {
return ""
}
digest := sha256.Sum256(asset.Bytes)
contentType := resolvedGeneratedAssetContentType(asset.ContentType, asset.Kind, asset.Bytes)
return hex.EncodeToString(digest[:]) + ":" + contentType
}
func generatedAssetUploadPolicyForAcceptanceRun(policy generatedAssetUploadPolicy, acceptanceRunID string) generatedAssetUploadPolicy {
if strings.TrimSpace(acceptanceRunID) != "" {
policy.UploadURLMedia = true
@@ -274,7 +299,7 @@ func (s *Service) finalizeGeneratedAssets(
) (map[string]any, error) {
redactGeneratedResultRawData(result)
if !TaskResultHasInlineBinary(result) {
return result, nil
return canonicalStoredResultURLs(result), nil
}
next := result
if policy.UploadInlineMedia {
@@ -304,7 +329,7 @@ func (s *Service) finalizeGeneratedAssets(
}
}
if !TaskResultHasInlineBinary(next) {
return next, nil
return canonicalStoredResultURLs(next), nil
}
diagnostics := taskResultInlineBinaryDiagnostics(next)
if s.logger != nil {
@@ -322,6 +347,18 @@ func (s *Service) finalizeGeneratedAssets(
}
}
func canonicalStoredResultURLs(result map[string]any) map[string]any {
next, changed := migrateStoredResultURLValue(result, 0)
if !changed {
return result
}
mapped, ok := next.(map[string]any)
if !ok {
return result
}
return mapped
}
func generatedRawValueHasInlineMedia(value any, key string, siblings map[string]any) bool {
switch typed := value.(type) {
case map[string]any:
@@ -448,42 +485,9 @@ func generatedRawInlineMediaAsset(key string, value string, siblings map[string]
}
func generatedRawMediaReference(asset *generatedInlineAsset, upload map[string]any, contentType string, kind string, strategy string) map[string]any {
digest := sha256.Sum256(asset.Bytes)
urlValue := stringFromAny(upload["url"])
ref := map[string]any{
"sha256": hex.EncodeToString(digest[:]),
"contentType": contentType,
"size": len(asset.Bytes),
}
if urlValue != "" {
ref["url"] = urlValue
}
if fileName := stringFromAny(upload["fileName"]); fileName != "" {
ref["fileName"] = fileName
}
if expiresAt := stringFromAny(upload["expiresAt"]); expiresAt != "" {
ref["expiresAt"] = expiresAt
}
if channel, ok := upload["storageChannel"].(map[string]any); ok {
if provider := stringFromAny(channel["provider"]); provider != "" {
ref["storageProvider"] = provider
}
if id := stringFromAny(channel["id"]); id != "" {
ref["storageChannelId"] = id
}
if key := stringFromAny(channel["channelKey"]); key != "" {
ref["storageChannelKey"] = key
}
}
if objectKey := stringFromAny(upload["objectKey"]); objectKey != "" {
ref["objectKey"] = objectKey
}
if accessScope := stringFromAny(upload["accessScope"]); accessScope != "" {
ref["accessScope"] = accessScope
}
out := map[string]any{
"assetRef": ref,
"upload": upload,
"upload": upload,
"assetStorage": map[string]any{
"scene": store.FileStorageSceneImageResult,
"source": asset.SourceKey,
@@ -497,6 +501,9 @@ func generatedRawMediaReference(asset *generatedInlineAsset, upload map[string]a
if kind != "" {
out["type"] = kind
}
if contentType != "" {
out["mime_type"] = contentType
}
return out
}
@@ -713,6 +720,9 @@ func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset
return nil, "", "", "", &clients.ClientError{Code: "storage_write_failed", Message: "no enabled object storage channel", StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
upload, err := s.uploadFileWithFailover(ctx, payload, channels)
if err == nil {
s.observeResultStorage("uploaded")
}
return upload, contentType, kind, "upload_inline_media", err
}
@@ -734,6 +744,9 @@ func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, as
return nil, "", "", "", &clients.ClientError{Code: "storage_write_failed", Message: "no enabled object storage channel", StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
upload, err := s.uploadFileWithFailover(ctx, uploadPayload, channels)
if err == nil {
s.observeResultStorage("uploaded")
}
return upload, contentType, kind, "upload_url_media", err
}
@@ -984,7 +997,7 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload
func storageFailureAllowsFailover(channel store.FileStorageChannel, err error) bool {
code := strings.ToLower(strings.TrimSpace(clients.ErrorCode(err)))
switch code {
case "upload_source_too_large", "upload_decode_failed", "invalid_multipart_file", "invalid_multipart_image", "invalid_multipart_audio":
case "upload_source_too_large", "upload_decode_failed", "invalid_upstream_result", "invalid_multipart_file", "invalid_multipart_image", "invalid_multipart_audio":
return false
}
var clientErr *clients.ClientError
@@ -1120,6 +1133,12 @@ func stripDataURLPrefix(value string) string {
func generatedAssetDecisionForItem(taskKind string, item map[string]any, policy generatedAssetUploadPolicy) (generatedAssetDecision, error) {
decision := generatedAssetDecision{}
for _, key := range mediaURLCandidateKeys() {
value := strings.TrimSpace(stringFromAny(item[key]))
if value != "" && strings.Contains(value, "://") && !mediaURLString(value) {
return decision, &clients.ClientError{Code: "invalid_upstream_result", Message: "generated media URL must use http or https", StatusCode: http.StatusBadGateway, Retryable: false}
}
}
urlKey, mediaURL := mediaURLSourceFromItem(item)
if mediaURL != "" {
if !policy.UploadURLMedia {
@@ -1225,7 +1244,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
}
payload, err := decodeBase64Payload(encoded)
if err != nil {
return nil, "", false, &clients.ClientError{Code: "upload_decode_failed", Message: err.Error(), Retryable: false}
return nil, "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: err.Error(), StatusCode: http.StatusBadGateway, Retryable: false}
}
return payload, contentType, true, nil
}
@@ -1235,7 +1254,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
payload, err := decodeBase64Payload(raw)
if err != nil {
if strictBase64 {
return nil, "", false, &clients.ClientError{Code: "upload_decode_failed", Message: err.Error(), Retryable: false}
return nil, "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: err.Error(), StatusCode: http.StatusBadGateway, Retryable: false}
}
return nil, "", false, nil
}
@@ -1245,7 +1264,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
func parseBase64DataURL(value string) (string, string, bool, error) {
prefix, payload, ok := strings.Cut(value, ",")
if !ok {
return "", "", false, &clients.ClientError{Code: "upload_decode_failed", Message: "invalid data URL media payload", Retryable: false}
return "", "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: "invalid data URL media payload", StatusCode: http.StatusBadGateway, Retryable: false}
}
meta := strings.TrimPrefix(prefix, "data:")
meta = strings.TrimPrefix(meta, "DATA:")
@@ -1259,7 +1278,7 @@ func parseBase64DataURL(value string) (string, string, bool, error) {
}
}
if !isBase64 {
return "", "", false, &clients.ClientError{Code: "upload_decode_failed", Message: "data URL media payload is not base64 encoded", Retryable: false}
return "", "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: "data URL media payload is not base64 encoded", StatusCode: http.StatusBadGateway, Retryable: false}
}
return contentType, payload, true, nil
}
@@ -1456,10 +1475,11 @@ func mediaURLString(value string) bool {
if strings.HasPrefix(lower, "data:") {
return false
}
return strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "/") ||
strings.Contains(lower, "://")
if strings.HasPrefix(lower, "/") {
return true
}
parsed, err := url.Parse(raw)
return err == nil && parsed.User == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func mediaContentTypeFromItem(item map[string]any) string {