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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user