From ad53047210277b743ecfa7689c0d5e3f70caeb38 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:51:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(provider):=20=E7=81=AB=E5=B1=B1=E6=96=B9?= =?UTF-8?q?=E8=88=9F=E5=9B=BE=E7=89=87=20-=20=E5=85=B3=E9=97=AD=E6=B0=B4?= =?UTF-8?q?=E5=8D=B0=E5=B9=B6=E6=8C=81=E4=B9=85=E5=8C=96=20CDN=20=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + backend/internal/service/provider.go | 39 ++++++++++++++++++++--- backend/internal/service/provider_test.go | 37 +++++++++++++++++++++ web/src/services/api/image.ts | 2 ++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44da3ea67..3fd85ead4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- 火山方舟 Seedream 图片生成显式关闭“AI 生成”水印,并由后端下载方舟 CDN 结果后写入服务器或对象存储,恢复素材库自动入库。 - 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。 - 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。 diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 11866b29d..2392dbf2c 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -881,18 +881,49 @@ func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil { return nil, err } - images, err := imageDataURLs(payload) + images, err := volcengineArkImageDataURLs(ctx, input.Config, payload) if err != nil { return nil, err } return map[string]interface{}{"mode": "image", "images": images}, nil } +func volcengineArkImageDataURLs(ctx context.Context, config providerConfig, payload imageResponse) ([]map[string]string, error) { + images, err := imageDataURLs(payload) + if err != nil { + return nil, err + } + for _, image := range images { + value := strings.TrimSpace(image["dataUrl"]) + if strings.HasPrefix(value, "data:image/") { + continue + } + if !isPublicMediaURL(value) { + return nil, errors.New("火山方舟图片接口没有返回可下载的图片") + } + // 方舟默认返回临时 CDN 地址。必须由后端下载成内联结果,后续资源持久化才能 + // 原子地写入服务器或用户配置的对象存储,且不依赖浏览器跨域访问方舟 CDN。 + data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) + if err != nil { + return nil, fmt.Errorf("火山方舟图片结果下载失败:%w", err) + } + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + mimeType = strings.ToLower(normalizedMediaMimeType(mimeType, data)) + if len(data) == 0 || strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || !strings.HasPrefix(mimeType, "image/") { + return nil, fmt.Errorf("火山方舟图片结果无效:%s", defaultString(detected, mimeType)) + } + image["dataUrl"] = dataURL(mimeType, data) + image["mimeType"] = mimeType + } + return images, nil +} + func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) { body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": withSystemPrompt(input.Config, input.Prompt), - "n": 1, + "model": input.Config.Model, + "prompt": withSystemPrompt(input.Config, input.Prompt), + "n": 1, + "watermark": false, } if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { if key == "size" { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0ff88ef91..48ae5dee6 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -151,6 +151,9 @@ func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) if body["prompt"] != "keep the subject\n\ncombine the references" { t.Fatalf("prompt = %q", body["prompt"]) } + if watermark, ok := body["watermark"].(bool); !ok || watermark { + t.Fatalf("watermark = %#v, want false", body["watermark"]) + } size, _ := body["size"].(string) parts := strings.Split(size, "x") if len(parts) != 2 { @@ -184,6 +187,40 @@ func TestVolcengineArkImageBodyUpscalesPresetBelowMinimumPixels(t *testing.T) { } } +func TestVolcengineArkImageDataURLsDownloadsRemoteResult(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + imageBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + })) + defer server.Close() + + images, err := volcengineArkImageDataURLs(context.Background(), providerConfig{}, imageResponse{ + Data: []map[string]interface{}{{"url": server.URL + "/generated.png"}}, + }) + if err != nil { + t.Fatalf("volcengineArkImageDataURLs() error = %v", err) + } + if len(images) != 1 || !strings.HasPrefix(images[0]["dataUrl"], "data:image/png;base64,") { + t.Fatalf("images = %#v", images) + } + + svc := newResourceTestService(t) + stored, err := svc.persistGeneratedMediaResult("user-1", map[string]interface{}{"mode": "image", "images": images}) + if err != nil { + t.Fatalf("persistGeneratedMediaResult() error = %v", err) + } + storedImages, ok := stored["images"].([]interface{}) + if !ok || len(storedImages) != 1 { + t.Fatalf("stored images = %#v", stored["images"]) + } + storedImage, ok := storedImages[0].(map[string]interface{}) + if !ok || !strings.HasPrefix(stringField(storedImage, "storageKey"), "resource:") || !strings.HasPrefix(stringField(storedImage, "dataUrl"), "/api/resources/") { + t.Fatalf("stored image = %#v", storedImages[0]) + } +} + func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) { _, err := runImageTask(context.Background(), canvasGenerationInput{ Prompt: "edit only the masked area", diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index df4eaed3c..be199f220 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -886,6 +886,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option model: requestConfig.model, prompt: withSystemPrompt(requestConfig, prompt), n, + watermark: false, ...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}), } : { @@ -980,6 +981,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: model: requestConfig.model, prompt: withSystemPrompt(requestConfig, requestPrompt), n, + watermark: false, ...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}), ...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}), },