From 542246e4ceaa0aff303ab641578109e2356a445e Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 19 Aug 2026 17:58:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8D=B3=E6=A2=A6=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E5=92=8C=E7=94=9F=E8=A7=86=E9=A2=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- README.zh-CN.md | 3 +- backend/internal/application/runtime.go | 12 +- backend/internal/application/runtime_test.go | 15 + backend/internal/jobs/provider.go | 80 ++++- backend/internal/jobs/provider_test.go | 132 +++++++- backend/internal/providers/adapters.go | 6 +- backend/internal/providers/providers_test.go | 304 +++++++++++++++++- backend/internal/providers/volcengine.go | 292 ++++++++++++++++- contracts/providers/http-v1.json | 4 +- lib/prompt/assembler.ts | 6 +- lib/seedance/client.ts | 11 +- lib/video-settings.ts | 2 +- lib/volcengine/visual-client.ts | 36 ++- ...create-studio-template-interaction.test.ts | 10 + tests/providers-contract.test.ts | 23 +- tests/seedance-client.test.ts | 74 ++++- tests/task-management.test.ts | 4 +- tests/video-settings.test.ts | 1 + tests/volcengine-signature.test.ts | 38 ++- 20 files changed, 1008 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 521e7c1..a92542e 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ V1 接入图片生成能力: 后端统一走火山 Visual 异步任务: -- 提交:`CVSync2AsyncSubmitTask` -- 查询:`CVSync2AsyncGetResult` +- 提交:`JimengSeedream46CVToBSubmitTask` +- 查询:`JimengSeedream46CVToBGetResult` +- API Version:`2024-06-06` 未配置火山密钥时,服务会明确报告凭据未配置,不会生成占位图片。 @@ -166,6 +167,7 @@ V1 接入图片生成能力: - 一个提示词框:图片和视频都在同一创作面板内编辑最终提示词。 - 模式切换:图片模式走即梦图片生成 4.6;视频模式走 Seedance 视频生成。 - 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。 +- Seedance 2.0 单次最多提交 4 个素材(加上文本后 `content` 最多 5 项)。 - `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。 - 提示词校验:通过 `/api/prompt/assemble` 检查提示词中引用的素材是否已绑定。 - 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。 @@ -219,7 +221,7 @@ cp .env.example .env.local - `SEEDANCE_MODEL` - `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` - `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 -- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p` +- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p` - `ALI_OSS_*`:用于上传素材和生成结果转存 - `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local` - `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串 diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c45537..ad3d354 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -193,8 +193,9 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 - `duration`:`4` 到 `15` 的整数秒 - `duration=-1`:允许在环境变量或服务端归一化中表示模型自动选择 - `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` -- `resolution`:`480p`、`720p`、`1080p`、`4k` +- `resolution`:`480p`、`720p`、`1080p` - Seedance 2.0 fast 不支持 `1080p` +- 多模态输入最多 4 个素材(加上文本后 `content` 最多 5 项) ## 任务管理与开放 API diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index c0dc854..9499e65 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -39,7 +39,11 @@ func firstNonEmpty(values ...string) string { } func positiveInt64Env(getenv postgres.Getenv, name string, fallback int64) int64 { - value, err := strconv.ParseInt(strings.TrimSpace(getenv(name)), 10, 64) + return positiveInt64Value(getenv(name), fallback) +} + +func positiveInt64Value(raw string, fallback int64) int64 { + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || value <= 0 { return fallback } @@ -180,9 +184,9 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget Provider: "seedance", Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), Settings: map[string]any{ - "ratio": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"), - "duration": float64(positiveInt64Env(getenv, "SEEDANCE_DEFAULT_DURATION", 5)), - "resolution": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"), + "ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"), + "duration": float64(positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5)), + "resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"), }, }, "bailian": { diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index aabe4c5..0e1bed7 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -273,6 +273,21 @@ func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) { } } +func TestProviderVideoTargetsUseDocumentedSeedanceDefaults(t *testing.T) { + values := map[string]string{ + "SEEDANCE_RATIO": "16:9", + "SEEDANCE_DURATION": "12", + "SEEDANCE_RESOLUTION": "1080p", + } + target := providerVideoTargets(func(name string) string { return values[name] })["seedance"] + if target.Model != "doubao-seedance-2-0-260128" { + t.Fatalf("model=%q", target.Model) + } + if target.Settings["ratio"] != "16:9" || target.Settings["duration"] != float64(12) || target.Settings["resolution"] != "1080p" { + t.Fatalf("settings=%#v", target.Settings) + } +} + func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) { for _, test := range []struct { name, engine, wantEngine, wantProvider, wantModel string diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index bf64386..f10c74d 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "math" "net/url" "strings" @@ -62,9 +63,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { } } var result providers.Result + phase := "submit" expectedStatus := job.Status if job.ProviderTaskID == "" { if job.ProviderDispatchStartedAt != nil { + logProviderFailureDiagnostic(providerFailureDiagnostic{ + JobID: safeProviderLogToken(job.ID), + Provider: safeProviderLogToken(job.Provider), + Phase: "submit", + ErrorClass: "unknown_outcome", + }) failed := StatusFailed failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false} if p.Store == nil { @@ -83,12 +91,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { } result, err = adapter.Submit(ctx, request) } else { + phase = "query" if modeled, ok := adapter.(providers.ModelQueryAdapter); ok { result, err = modeled.QueryModel(ctx, job.ProviderTaskID, job.ReqKey) } else { result, err = adapter.Query(ctx, job.ProviderTaskID) } } + if err != nil { + logProviderFailure(job, phase, err) + } if err != nil && job.ProviderTaskID == "" && p.Store != nil { failed := StatusFailed failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false} @@ -140,6 +152,69 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { return job, nil } +type providerFailureDiagnostic struct { + JobID string + Provider string + Phase string + Status int + ErrorClass string +} + +func logProviderFailure(job Job, phase string, err error) { + diagnostic := providerFailureDiagnostic{ + JobID: safeProviderLogToken(job.ID), + Provider: safeProviderLogToken(job.Provider), + Phase: safeProviderPhase(phase), + ErrorClass: "provider", + } + if errors.Is(err, context.Canceled) { + diagnostic.ErrorClass = "canceled" + } else if errors.Is(err, context.DeadlineExceeded) { + diagnostic.ErrorClass = "timeout" + } else { + var providerError *providers.ProviderError + if errors.As(err, &providerError) { + diagnostic.Status = providerError.Status + if providerError.Status > 0 { + diagnostic.ErrorClass = "service" + } + } + } + logProviderFailureDiagnostic(diagnostic) +} + +func logProviderFailureDiagnostic(diagnostic providerFailureDiagnostic) { + log.Printf( + "zhinian-api generation provider failed jobId=%s provider=%s phase=%s status=%d errorClass=%s", + diagnostic.JobID, + diagnostic.Provider, + diagnostic.Phase, + diagnostic.Status, + diagnostic.ErrorClass, + ) +} + +func safeProviderPhase(phase string) string { + if phase == "query" { + return "query" + } + return "submit" +} + +func safeProviderLogToken(value string) string { + value = strings.TrimSpace(value) + if len(value) == 0 || len(value) > 96 { + return "invalid" + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) { + continue + } + return "invalid" + } + return value +} + func (p ProviderProcessor) refreshAssetURLs(ctx context.Context, job Job, request providers.Request) (providers.Request, error) { if p.AssetURLs == nil || len(job.InputAssetIDs) == 0 { return request, nil @@ -561,6 +636,9 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials [] if engine != "" && engine != "seedance" { return nil, invalidPreparation("unsupported video engine") } + if len(materials) > 4 { + return nil, invalidPreparation("seedance supports at most 4 materials") + } ratio := stringValue(input["ratio"]) if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") { return nil, invalidPreparation("unsupported video ratio") @@ -576,7 +654,7 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials [] settings["duration"] = duration } resolution := stringValue(input["resolution"]) - if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p", "4k") { + if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p") { return nil, invalidPreparation("unsupported video resolution") } if resolution != "" { diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 821bda3..2ef9d80 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -1,9 +1,13 @@ package jobs import ( + "bytes" "context" "encoding/json" "errors" + "log" + "net/http" + "net/http/httptest" "reflect" "strings" "testing" @@ -162,6 +166,16 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { {name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"}, {name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"}, {name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"}, + {name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"}, + {name: "seedance too many materials", capability: "video.generate", body: map[string]any{ + "engine": "seedance", "prompt": "p", "materials": []any{ + map[string]any{"url": "https://in.test/1.png", "type": "image"}, + map[string]any{"url": "https://in.test/2.png", "type": "image"}, + map[string]any{"url": "https://in.test/3.png", "type": "image"}, + map[string]any{"url": "https://in.test/4.png", "type": "image"}, + map[string]any{"url": "https://in.test/5.png", "type": "image"}, + }, + }, message: "at most 4 materials"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -246,6 +260,79 @@ func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing. } } +func TestProviderProcessorLogsSafeUnknownSubmissionOutcome(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + started := time.Now().UTC() + job := Job{ + ID: "job-unknown-outcome", OwnerID: "private-owner", Provider: "volcengine-visual", ReqKey: "private-model", + Capability: "image.generate", Status: StatusRunning, ProviderDispatchStartedAt: &started, + RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"private-model","prompt":"private prompt"}`), + } + adapter := &countingProvider{} + processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}} + + got, err := processor.Advance(context.Background(), job) + if err != nil || got.Status != StatusFailed || adapter.submits != 0 { + t.Fatalf("got=%#v err=%v submits=%d", got, err, adapter.submits) + } + logged := output.String() + for _, expected := range []string{"jobId=job-unknown-outcome", "provider=volcengine-visual", "phase=submit", "status=0", "errorClass=unknown_outcome"} { + if !strings.Contains(logged, expected) { + t.Fatalf("log %q does not contain %q", logged, expected) + } + } + for _, secret := range []string{"private-owner", "private-model", "private prompt"} { + if strings.Contains(logged, secret) { + t.Fatalf("log leaks %q: %s", secret, logged) + } + } +} + +func TestProviderProcessorLogsSafeJobCorrelationWhenSubmitFails(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + store := newMemoryJobStore() + job := Job{ + ID: "job-safe-log", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob", + Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", + RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"private prompt"}`), + } + store.jobs[job.ID] = job + adapter := &countingProvider{err: &providers.ProviderError{Operation: "volcengine request", Status: http.StatusForbidden}} + processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store} + + got, err := processor.Advance(context.Background(), job) + if err != nil || got.Status != StatusFailed { + t.Fatalf("got=%#v err=%v", got, err) + } + logged := output.String() + for _, expected := range []string{"jobId=job-safe-log", "provider=volcengine-visual", "phase=submit", "status=403", "errorClass=service"} { + if !strings.Contains(logged, expected) { + t.Fatalf("log %q does not contain %q", logged, expected) + } + } + for _, secret := range []string{"private prompt", "jimeng_seedream46_cvtob", "owner", "volcengine request"} { + if strings.Contains(logged, secret) { + t.Fatalf("log leaks %q: %s", secret, logged) + } + } +} + func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T) { store := newMemoryJobStore() job := Job{ID: "job-recovered", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", Error: &JobError{Message: "temporary timeout", Retryable: true}, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)} @@ -290,6 +377,46 @@ func TestProviderProcessorQueriesWithPersistedRequestModel(t *testing.T) { } } +func TestProviderProcessorCompletesOfficialVolcengineQueryResponse(t *testing.T) { + var action string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + action = request.URL.Query().Get("Action") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "code":10000, + "data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"}, + "message":"Success", + "request_id":"request-1", + "status":10000 + }`)) + })) + defer server.Close() + + store := newMemoryJobStore() + job := Job{ + ID: "job-jimeng", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob", + Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "task-1", + RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"hello"}`), + } + store.jobs[job.ID] = job + adapter := providers.NewVolcengine(providers.Config{ + BaseURL: server.URL, AccessKeyID: "ak", SecretAccessKey: "sk", Region: "cn-north-1", Service: "cv", Model: "jimeng_seedream46_cvtob", + }, server.Client(), func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) }) + processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store} + + got, err := processor.Advance(context.Background(), job) + if err != nil { + t.Fatal(err) + } + var persisted providers.HTTPResult + if err := json.Unmarshal(got.ResponsePayload, &persisted); err != nil { + t.Fatal(err) + } + if action != "JimengSeedream46CVToBGetResult" || got.Status != StatusSucceeded || got.ProviderTaskID != "task-1" || len(persisted.OutputURLs) != 1 || persisted.OutputURLs[0] != "https://cdn.test/generated.png" { + t.Fatalf("job=%#v response=%#v", got, persisted) + } +} + func TestProviderJobBuilderRejectsInvalidPublicWebhookURL(t *testing.T) { for _, value := range []string{"/internal/callback", "javascript:alert(1)", "https://user:pass@example.test/hook"} { _, err := testProviderBuilder().Build(context.Background(), "api:client", "client", "image.generate", "", map[string]any{"prompt": "hello", "webhookUrl": value}) @@ -320,6 +447,7 @@ type countingProvider struct { submits int result providers.Result request providers.Request + err error } type recordingProviderAssetURLResolver struct { @@ -352,10 +480,10 @@ func (provider *modelQueryProvider) QueryModel(_ context.Context, _ string, mode func (p *countingProvider) Submit(_ context.Context, request providers.Request) (providers.Result, error) { p.submits++ p.request = request - return p.result, nil + return p.result, p.err } func (p *countingProvider) Query(context.Context, string) (providers.Result, error) { - return p.result, nil + return p.result, p.err } func testProviderBuilder() ProviderJobBuilder { diff --git a/backend/internal/providers/adapters.go b/backend/internal/providers/adapters.go index 2e72952..6a02417 100644 --- a/backend/internal/providers/adapters.go +++ b/backend/internal/providers/adapters.go @@ -145,11 +145,7 @@ func NewSeedance(c Config, client HTTPClient) *Seedance { case MaterialAudio: materialType, urlKey, role = "audio_url", "audio_url", "reference_audio" } - item := map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role} - if material.Label != "" { - item["label"] = material.Label - } - content = append(content, item) + content = append(content, map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role}) } p := map[string]any{"model": requestModel(r, c.Model), "content": content, "generate_audio": true, "watermark": false} for k, v := range r.Settings { diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index df30b5c..8fe8bde 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -1,9 +1,11 @@ package providers import ( + "bytes" "context" "encoding/json" "io" + "log" "net/http" "strings" "testing" @@ -128,6 +130,10 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) { if bodies[0]["width"] != float64(1024) || bodies[0]["height"] != float64(768) || bodies[0]["force_single"] != true || bodies[0]["ignored"] != nil { t.Fatalf("submit body=%#v", bodies[0]) } + imageURLs, ok := bodies[0]["image_urls"].([]any) + if !ok || len(imageURLs) != 1 || imageURLs[0] != "https://cdn.test/ref.png" { + t.Fatalf("submit image_urls=%#v", bodies[0]["image_urls"]) + } queryJSON, ok := bodies[1]["req_json"].(string) if !ok || queryJSON == "" { t.Fatalf("query body=%#v", bodies[1]) @@ -142,6 +148,58 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) { } } +func TestVolcengineSeedream46UsesDedicatedActionsAndVersion(t *testing.T) { + var requests []*http.Request + var bodies []map[string]any + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request.Clone(request.Context())) + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + bodies = append(bodies, body) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"code":10000,"data":{"task_id":"task-1","status":"queued"}}`)), + Header: http.Header{}, + }, nil + }) + adapter := NewVolcengine(Config{ + BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng_seedream46_cvtob", + }, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) }) + + if _, err := adapter.Submit(context.Background(), Request{ + Prompt: "draw", Settings: map[string]any{"scale": 50, "width": 2048, "height": 2048, "force_single": true}, + }); err != nil { + t.Fatal(err) + } + if _, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob"); err != nil { + t.Fatal(err) + } + + wantActions := []string{"JimengSeedream46CVToBSubmitTask", "JimengSeedream46CVToBGetResult"} + if len(requests) != len(wantActions) { + t.Fatalf("requests=%d want=%d", len(requests), len(wantActions)) + } + for index, wantAction := range wantActions { + if got := requests[index].URL.Query().Get("Action"); got != wantAction { + t.Fatalf("request[%d] action=%q want=%q", index, got, wantAction) + } + if got := requests[index].URL.Query().Get("Version"); got != "2024-06-06" { + t.Fatalf("request[%d] version=%q want=2024-06-06", index, got) + } + } + if bodies[0]["req_key"] != "jimeng_seedream46_cvtob" || bodies[0]["scale"] != float64(50) { + t.Fatalf("submit body=%#v", bodies[0]) + } + if _, exists := bodies[0]["image_urls"]; exists { + t.Fatalf("text-to-image submit contains empty image_urls: %#v", bodies[0]) + } + if bodies[1]["req_key"] != "jimeng_seedream46_cvtob" || bodies[1]["task_id"] != "task-1" { + t.Fatalf("query body=%#v", bodies[1]) + } +} + func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testing.T) { var body map[string]any client := roundTripFunc(func(request *http.Request) (*http.Response, error) { @@ -162,6 +220,240 @@ func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testin } } +func TestVolcengineDecodesOfficialQueryResponse(t *testing.T) { + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Query().Get("Action") != seedream46QueryAction || request.URL.Query().Get("Version") != seedream46Version { + t.Fatalf("action=%q", request.URL.Query().Get("Action")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "code":10000, + "data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"}, + "message":"Success", + "request_id":"request-1", + "status":10000, + "time_elapsed":"508.312154ms" + }`)), + Header: http.Header{}, + }, nil + }) + adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time { + return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) + }) + + result, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob") + if err != nil { + t.Fatal(err) + } + if result.TaskID != "task-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/generated.png" { + t.Fatalf("result=%#v", result) + } +} + +func TestVolcengineDecodesGatewayWrappedBusinessResponse(t *testing.T) { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "ResponseMetadata":{"RequestId":"request-wrapper-1"}, + "Result":{ + "code":10000, + "data":{"task_id":"task-wrapper-1","status":"done","image_urls":["https://cdn.test/wrapped.png"]}, + "message":"Success", + "request_id":"request-business-1" + } + }`)), + Header: http.Header{}, + }, nil + }) + adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: seedream46Model}, client, func() time.Time { + return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) + }) + + result, err := adapter.Submit(context.Background(), Request{Prompt: "draw"}) + if err != nil { + t.Fatal(err) + } + if result.TaskID != "task-wrapper-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/wrapped.png" { + t.Fatalf("result=%#v", result) + } +} + +func TestVolcengineRejectsOfficialBusinessErrorOnHTTP200(t *testing.T) { + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "code":50413, + "data":null, + "message":"Post Text Risk Not Pass", + "request_id":"request-2", + "status":50413 + }`)), + Header: http.Header{}, + }, nil + }) + adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time { + return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) + }) + + if _, err := adapter.Submit(context.Background(), Request{Prompt: "blocked prompt"}); err == nil { + t.Fatal("expected Volcengine business error") + } +} + +func TestVolcengineServiceFailureLogsOnlySafeDiagnosticFields(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{ + "code":50400, + "message":"Authorization failed for private prompt", + "request_id":"request-safe-1", + "data":null + }`)), + Header: http.Header{}, + }, nil + }) + adapter := NewVolcengine(Config{ + BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng", + }, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) }) + + if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil { + t.Fatal("expected Volcengine service error") + } + got := output.String() + for _, expected := range []string{"operation=submit", "status=403", `code="50400"`, `requestId="request-safe-1"`, "errorClass=service", "elapsedMs="} { + if !strings.Contains(got, expected) { + t.Fatalf("log %q does not contain %q", got, expected) + } + } + for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization failed", "private prompt", "Signature=", "Credential="} { + if strings.Contains(got, secret) { + t.Fatalf("log leaks %q: %s", secret, got) + } + } +} + +func TestVolcengineServiceFailureLogsNestedGatewayDiagnosticFields(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{ + "ResponseMetadata": { + "RequestId": "request-nested-1", + "Error": { + "CodeN": 100025, + "Code": "InvalidCredential", + "Message": "Authorization contains private-secret-key and private prompt" + } + } + }`)), + Header: http.Header{"X-Tt-Logid": []string{"request-header-ignored"}}, + }, nil + }) + adapter := NewVolcengine(Config{ + BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model, + }, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) }) + + if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil { + t.Fatal("expected Volcengine gateway error") + } + got := output.String() + for _, expected := range []string{"operation=submit", "status=400", `code="InvalidCredential"`, `codeN="100025"`, `requestId="request-nested-1"`, "errorClass=service"} { + if !strings.Contains(got, expected) { + t.Fatalf("log %q does not contain %q", got, expected) + } + } + for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization contains", "private prompt", "Signature=", "Credential=", "request-header-ignored"} { + if strings.Contains(got, secret) { + t.Fatalf("log leaks %q: %s", secret, got) + } + } +} + +func TestVolcengineServiceFailureFallsBackToSafeResponseHeaderRequestID(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(strings.NewReader(`{"ResponseMetadata":{"Error":{"Code":"InternalServiceError","CodeN":"100023"}}}`)), + Header: http.Header{"X-Tt-Logid": []string{"request-header-1"}}, + }, nil + }) + adapter := NewVolcengine(Config{ + BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model, + }, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) }) + + if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil { + t.Fatal("expected Volcengine gateway error") + } + got := output.String() + for _, expected := range []string{`code="InternalServiceError"`, `codeN="100023"`, `requestId="request-header-1"`} { + if !strings.Contains(got, expected) { + t.Fatalf("log %q does not contain %q", got, expected) + } + } +} + +func TestVolcengineTimeoutLogsSafeTransportClassification(t *testing.T) { + var output bytes.Buffer + previousOutput, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetFlags(previousFlags) + }) + + client := roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + }) + adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng"}, client, nil) + + if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil { + t.Fatal("expected Volcengine timeout") + } + got := output.String() + for _, expected := range []string{"operation=submit", "status=0", `code=""`, `requestId=""`, "errorClass=timeout", "elapsedMs="} { + if !strings.Contains(got, expected) { + t.Fatalf("log %q does not contain %q", got, expected) + } + } + for _, secret := range []string{"private-access-key", "private-secret-key", "private prompt", "deadline exceeded"} { + if strings.Contains(got, secret) { + t.Fatalf("log leaks %q: %s", secret, got) + } + } +} + func TestProviderErrorsAreGenericAndDoNotLeakSecrets(t *testing.T) { client := roundTripFunc(func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader(`{"message":"secret upstream detail"}`)), Header: http.Header{}}, nil @@ -264,7 +556,7 @@ func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) { } } -func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) { +func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) { client := roundTripFunc(func(request *http.Request) (*http.Response, error) { var body map[string]any if err := json.NewDecoder(request.Body).Decode(&body); err != nil { @@ -281,13 +573,19 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) { } for index, check := range checks { item := content[index+1].(map[string]any) - if item["type"] != check.materialType || item["role"] != check.role || item["label"] != []string{"图片1", "视频1", "音频1"}[index] { + if item["type"] != check.materialType || item["role"] != check.role { t.Fatalf("content[%d]=%#v", index+1, item) } + if _, exists := item["label"]; exists { + t.Fatalf("content[%d] contains unsupported label: %#v", index+1, item) + } if object := item[check.urlKey].(map[string]any); object["url"] == "" { t.Fatalf("content[%d]=%#v", index+1, item) } } + if body["model"] != "doubao-seedance-2-0-260128" || body["generate_audio"] != true || body["watermark"] != false { + t.Fatalf("body=%#v", body) + } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"queued"}`)), Header: http.Header{}}, nil }) request := Request{ @@ -298,7 +596,7 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) { {URL: "https://cdn.test/audio.mp3", Type: MaterialAudio, Label: "音频1"}, }, } - if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, client).Submit(context.Background(), request); err != nil { + if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "doubao-seedance-2-0-260128"}, client).Submit(context.Background(), request); err != nil { t.Fatal(err) } } diff --git a/backend/internal/providers/volcengine.go b/backend/internal/providers/volcengine.go index 7e84817..6a51a1c 100644 --- a/backend/internal/providers/volcengine.go +++ b/backend/internal/providers/volcengine.go @@ -8,9 +8,12 @@ import ( "encoding/json" "errors" "io" + "log" + "net" "net/http" "net/url" "sort" + "strconv" "strings" "time" ) @@ -21,6 +24,20 @@ type Volcengine struct { now func() time.Time } +const ( + seedream46Model = "jimeng_seedream46_cvtob" + seedream46SubmitAction = "JimengSeedream46CVToBSubmitTask" + seedream46QueryAction = "JimengSeedream46CVToBGetResult" + seedream46Version = "2024-06-06" + visualLegacyVersion = "2022-08-31" +) + +type volcengineProtocol struct { + submitAction string + queryAction string + version string +} + func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengine { if client == nil { client = http.DefaultClient @@ -37,33 +54,57 @@ func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengin return &Volcengine{c, client, now} } func (v *Volcengine) Submit(ctx context.Context, r Request) (Result, error) { - p := map[string]any{"req_key": requestModel(r, v.config.Model), "prompt": r.Prompt, "image_urls": r.InputURLs} + model := requestModel(r, v.config.Model) + protocol := volcengineProtocolForModel(model) + p := map[string]any{"req_key": model, "prompt": r.Prompt} + if len(r.InputURLs) > 0 { + p["image_urls"] = r.InputURLs + } for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "force_single"} { if value, exists := r.Settings[key]; exists && value != nil && value != "" { p[key] = value } } - return v.call(ctx, "CVSync2AsyncSubmitTask", p) + return v.call(ctx, protocol.submitAction, protocol.version, p) } func (v *Volcengine) Query(ctx context.Context, id string) (Result, error) { return v.QueryModel(ctx, id, v.config.Model) } func (v *Volcengine) QueryModel(ctx context.Context, id, model string) (Result, error) { + if strings.TrimSpace(id) == "" { + return Result{}, errors.New("provider task id is required") + } + resolvedModel := requestModel(Request{Model: model}, v.config.Model) + protocol := volcengineProtocolForModel(resolvedModel) queryOptions, _ := json.Marshal(map[string]any{ "return_url": true, "logo_info": map[string]any{"add_logo": false, "position": 0, "language": 0, "opacity": 1}, }) - return v.call(ctx, "CVSync2AsyncGetResult", map[string]any{"req_key": requestModel(Request{Model: model}, v.config.Model), "task_id": id, "req_json": string(queryOptions)}) + result, err := v.call(ctx, protocol.queryAction, protocol.version, map[string]any{"req_key": resolvedModel, "task_id": id, "req_json": string(queryOptions)}) + if err != nil { + return Result{}, err + } + if result.TaskID == "" { + result.TaskID = id + } + return result, nil } -func (v *Volcengine) call(ctx context.Context, action string, payload any) (Result, error) { - body, _ := json.Marshal(payload) +func (v *Volcengine) call(ctx context.Context, action, version string, payload any) (Result, error) { + startedAt := time.Now() + operation := volcengineOperation(action) + body, err := json.Marshal(payload) + if err != nil { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "encode", ElapsedMS: elapsedMilliseconds(startedAt)}) + return Result{}, &ProviderError{Operation: "volcengine request"} + } endpoint, err := url.Parse(v.config.BaseURL) if err != nil { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)}) return Result{}, errors.New("invalid provider base URL") } q := endpoint.Query() q.Set("Action", action) - q.Set("Version", "2022-08-31") + q.Set("Version", version) endpoint.RawQuery = canonicalQuery(q) date := v.now().UTC() xdate := date.Format("20060102T150405Z") @@ -83,26 +124,251 @@ func (v *Volcengine) call(ctx context.Context, action string, payload any) (Resu key = hmacBytes(key, v.config.Service) key = hmacBytes(key, "request") signature := hex.EncodeToString(hmacBytes(key, stringToSign)) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body))) + if err != nil { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)}) + return Result{}, &ProviderError{Operation: "volcengine request"} + } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Content-Sha256", hash) req.Header.Set("X-Date", xdate) req.Header.Set("Authorization", "HMAC-SHA256 Credential="+v.config.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+signature) resp, err := v.client.Do(req) if err != nil { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: classifyVolcengineTransportError(err), ElapsedMS: elapsedMilliseconds(startedAt)}) return Result{}, &ProviderError{Operation: "volcengine request"} } defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 || !json.Valid(raw) { + limit := v.config.MaxResponseBytes + if limit <= 0 { + limit = 2 << 20 + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)}) return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode} } - r := record(raw) - d := object(r["data"]) + if int64(len(raw)) > limit { + logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)}) + return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode} + } + validJSON := json.Valid(raw) + r := map[string]any{} + if validJSON { + r = record(raw) + } + response := inspectVolcengineResponse(r, resp.Header) + diagnostic := volcengineDiagnostic{ + Operation: operation, + Status: resp.StatusCode, + Code: volcengineDiagnosticCode(response.code), + CodeN: volcengineDiagnosticNumericCode(response.codeN), + RequestID: safeVolcengineRequestID(response.requestID), + ElapsedMS: elapsedMilliseconds(startedAt), + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + diagnostic.ErrorClass = "service" + logVolcengineFailure(diagnostic) + return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode} + } + if !validJSON { + diagnostic.ErrorClass = "invalid_json" + logVolcengineFailure(diagnostic) + return Result{}, &ProviderError{Operation: "volcengine request"} + } + if response.code != nil && !volcengineRequestSucceeded(response.code) { + diagnostic.ErrorClass = "service" + logVolcengineFailure(diagnostic) + return Result{}, &ProviderError{Operation: "volcengine request"} + } + d := object(first(response.business["data"], response.business["Data"])) out := []string{} - collectURLs(d, &out) - return Result{TaskID: stringValue(r["task_id"], d["task_id"]), Status: status(first(r["status"], d["status"])), OutputURLs: out, Raw: raw}, nil + for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} { + collectURLs(value, &out) + } + return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil } + +type volcengineResponse struct { + business map[string]any + code any + codeN any + requestID any +} + +func inspectVolcengineResponse(root map[string]any, headers http.Header) volcengineResponse { + business := root + if wrapped := object(first(root["Result"], root["result"])); len(wrapped) > 0 { + business = wrapped + } + metadata := object(first(root["ResponseMetadata"], root["response_metadata"])) + gatewayError := object(first(metadata["Error"], metadata["error"])) + return volcengineResponse{ + business: business, + code: first( + business["code"], business["Code"], + root["code"], root["Code"], + gatewayError["Code"], gatewayError["code"], + ), + codeN: first( + gatewayError["CodeN"], gatewayError["codeN"], gatewayError["code_n"], + business["code_n"], business["codeN"], business["CodeN"], + root["code_n"], root["codeN"], root["CodeN"], + ), + requestID: first( + business["request_id"], business["requestId"], business["RequestId"], business["RequestID"], + root["request_id"], root["requestId"], root["RequestId"], root["RequestID"], + metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"], + headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"), + ), + } +} + +func volcengineProtocolForModel(model string) volcengineProtocol { + if strings.TrimSpace(model) == seedream46Model { + return volcengineProtocol{ + submitAction: seedream46SubmitAction, + queryAction: seedream46QueryAction, + version: seedream46Version, + } + } + return volcengineProtocol{ + submitAction: "CVSync2AsyncSubmitTask", + queryAction: "CVSync2AsyncGetResult", + version: visualLegacyVersion, + } +} + +func volcengineRequestSucceeded(value any) bool { + return volcengineDiagnosticCode(value) == "10000" +} + +type volcengineDiagnostic struct { + Operation string + Status int + Code string + CodeN string + RequestID string + ErrorClass string + ElapsedMS int64 +} + +func logVolcengineFailure(diagnostic volcengineDiagnostic) { + log.Printf( + "zhinian-api Volcengine operation failed operation=%s status=%d code=%q codeN=%q requestId=%q errorClass=%s elapsedMs=%d", + diagnostic.Operation, + diagnostic.Status, + diagnostic.Code, + diagnostic.CodeN, + diagnostic.RequestID, + diagnostic.ErrorClass, + diagnostic.ElapsedMS, + ) +} + +func volcengineOperation(action string) string { + switch action { + case "CVSync2AsyncSubmitTask", seedream46SubmitAction: + return "submit" + case "CVSync2AsyncGetResult", seedream46QueryAction: + return "query" + default: + return "unknown" + } +} + +func volcengineDiagnosticCode(value any) string { + code := volcengineDiagnosticValue(value) + if len(code) == 0 || len(code) > 64 { + return "" + } + for _, character := range code { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) { + continue + } + return "" + } + return code +} + +func volcengineDiagnosticNumericCode(value any) string { + code := volcengineDiagnosticValue(value) + if len(code) == 0 || len(code) > 32 { + return "" + } + for _, character := range code { + if character < '0' || character > '9' { + return "" + } + } + return code +} + +func volcengineDiagnosticValue(value any) string { + switch typed := value.(type) { + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 32) + case int: + return strconv.Itoa(typed) + case int32: + return strconv.FormatInt(int64(typed), 10) + case int64: + return strconv.FormatInt(typed, 10) + case json.Number: + return string(typed) + case string: + return strings.TrimSpace(typed) + default: + return "" + } +} + +func safeVolcengineRequestID(value any) string { + requestID := stringValue(value) + if len(requestID) == 0 || len(requestID) > 128 { + return "" + } + for _, character := range requestID { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) { + continue + } + return "" + } + return requestID +} + +func classifyVolcengineTransportError(err error) string { + if errors.Is(err, context.Canceled) { + return "canceled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return "timeout" + } + var dnsError *net.DNSError + if errors.As(err, &dnsError) { + return "dns" + } + var operationError *net.OpError + if errors.As(err, &operationError) && operationError.Op == "dial" { + return "connect" + } + return "transport" +} + +func elapsedMilliseconds(startedAt time.Time) int64 { + elapsed := time.Since(startedAt).Milliseconds() + if elapsed < 0 { + return 0 + } + return elapsed +} + func sha(b []byte) string { x := sha256.Sum256(b); return hex.EncodeToString(x[:]) } func hmacBytes(k []byte, s string) []byte { h := hmac.New(sha256.New, k) diff --git a/contracts/providers/http-v1.json b/contracts/providers/http-v1.json index 9b04392..0ff0663 100644 --- a/contracts/providers/http-v1.json +++ b/contracts/providers/http-v1.json @@ -1,10 +1,10 @@ { "version": 1, "providers": { - "volcengine-visual": {"submitAction": "CVSync2AsyncSubmitTask", "queryAction": "CVSync2AsyncGetResult", "auth": "HMAC-SHA256"}, + "volcengine-visual": {"model": "jimeng_seedream46_cvtob", "submitAction": "JimengSeedream46CVToBSubmitTask", "queryAction": "JimengSeedream46CVToBGetResult", "version": "2024-06-06", "auth": "HMAC-SHA256"}, "evolink": {"submit": "/v1/images/generations", "query": "/v1/tasks/{id}", "auth": "bearer"}, "bailian": {"imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"}, - "seedance": {"submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer"} + "seedance": {"model": "doubao-seedance-2-0-260128", "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": 4, "resolutions": ["480p", "720p", "1080p"]} }, "errors": {"generic": true, "secretSafe": true}, "liveCallsInTests": false diff --git a/lib/prompt/assembler.ts b/lib/prompt/assembler.ts index de06a5b..b1a925a 100644 --- a/lib/prompt/assembler.ts +++ b/lib/prompt/assembler.ts @@ -139,12 +139,12 @@ export function materialContentForProvider(materials: PromptMaterial[], origin: return normalizeMaterials(materials).map((material) => { const url = toAbsoluteUrl(material.url, origin); if (material.type === "video") { - return { type: "video_url", video_url: { url }, role: "reference_video", label: material.label }; + return { type: "video_url", video_url: { url }, role: "reference_video" }; } if (material.type === "audio") { - return { type: "audio_url", audio_url: { url }, role: "reference_audio", label: material.label }; + return { type: "audio_url", audio_url: { url }, role: "reference_audio" }; } - return { type: "image_url", image_url: { url }, role: "reference_image", label: material.label }; + return { type: "image_url", image_url: { url }, role: "reference_image" }; }); } diff --git a/lib/seedance/client.ts b/lib/seedance/client.ts index 247c2a0..0aa7d1c 100644 --- a/lib/seedance/client.ts +++ b/lib/seedance/client.ts @@ -23,7 +23,7 @@ export type SeedanceCreateInput = { }; export type SeedanceQueryResult = { - status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; + status: "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired"; resultUrl?: string; errorMessage?: string; usage?: SeedanceUsage; @@ -49,11 +49,15 @@ export function getSeedanceConfig() { export async function createSeedanceTask(input: SeedanceCreateInput) { const config = getSeedanceConfig(); if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY。请在 .env.local 配置火山方舟 API Key。"); + const materialContent = materialContentForProvider(input.materials, input.origin); + if (materialContent.length > 4) { + throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。"); + } const payload = { model: config.model, content: [ { type: "text", text: input.prompt }, - ...materialContentForProvider(input.materials, input.origin) + ...materialContent ], generate_audio: true, ratio: normalizeVideoRatio(input.settings.ratio, config.ratio), @@ -123,7 +127,8 @@ export function extractSeedanceUsage(value: unknown): SeedanceUsage | undefined function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] { const value = String(status || "").toLowerCase(); if (["succeeded", "success", "completed"].includes(value)) return "succeeded"; - if (["failed", "error", "expired", "timeout"].includes(value)) return "failed"; + if (["expired", "timeout"].includes(value)) return "expired"; + if (["failed", "error"].includes(value)) return "failed"; if (["cancelled", "canceled"].includes(value)) return "cancelled"; if (["running", "processing", "generating"].includes(value)) return "running"; return "queued"; diff --git a/lib/video-settings.ts b/lib/video-settings.ts index 48497c5..6cfd2da 100644 --- a/lib/video-settings.ts +++ b/lib/video-settings.ts @@ -11,7 +11,7 @@ export const VIDEO_RATIO_DEFAULT = "9:16"; export const VIDEO_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive"] as const; export const VIDEO_RESOLUTION_DEFAULT = "720p"; -export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p", "4k"] as const; +export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p"] as const; export const VIDEO_FAST_RESOLUTIONS = ["720p", "480p"] as const; type VideoDurationOptions = { diff --git a/lib/volcengine/visual-client.ts b/lib/volcengine/visual-client.ts index eb66439..d69a3cc 100644 --- a/lib/volcengine/visual-client.ts +++ b/lib/volcengine/visual-client.ts @@ -27,7 +27,8 @@ export async function submitVisualTask( config = getVisualClientConfig() ): Promise { if (!config) throw new Error("Volcengine Visual credentials are not configured."); - return callVisualApi("CVSync2AsyncSubmitTask", payload, config); + const protocol = visualProtocolForPayload(payload); + return callVisualApi(protocol.submitAction, protocol.version, payload, config); } export async function queryVisualTask( @@ -35,11 +36,38 @@ export async function queryVisualTask( config = getVisualClientConfig() ): Promise { if (!config) throw new Error("Volcengine Visual credentials are not configured."); - return callVisualApi("CVSync2AsyncGetResult", payload, config); + const protocol = visualProtocolForPayload(payload); + return callVisualApi(protocol.queryAction, protocol.version, payload, config); +} + +type VisualAction = + | "CVSync2AsyncSubmitTask" + | "CVSync2AsyncGetResult" + | "JimengSeedream46CVToBSubmitTask" + | "JimengSeedream46CVToBGetResult"; + +function visualProtocolForPayload(payload: Record): { + submitAction: VisualAction; + queryAction: VisualAction; + version: string; +} { + if (String(payload.req_key || "").trim() === "jimeng_seedream46_cvtob") { + return { + submitAction: "JimengSeedream46CVToBSubmitTask", + queryAction: "JimengSeedream46CVToBGetResult", + version: "2024-06-06" + }; + } + return { + submitAction: "CVSync2AsyncSubmitTask", + queryAction: "CVSync2AsyncGetResult", + version: "2022-08-31" + }; } async function callVisualApi( - action: "CVSync2AsyncSubmitTask" | "CVSync2AsyncGetResult", + action: VisualAction, + version: string, payload: Record, config: VisualClientConfig ): Promise { @@ -49,7 +77,7 @@ async function callVisualApi( endpoint: config.endpoint, query: { Action: action, - Version: "2022-08-31" + Version: version }, body, accessKeyId: config.accessKeyId, diff --git a/tests/create-studio-template-interaction.test.ts b/tests/create-studio-template-interaction.test.ts index d75b9e1..d542de5 100644 --- a/tests/create-studio-template-interaction.test.ts +++ b/tests/create-studio-template-interaction.test.ts @@ -13,6 +13,16 @@ describe("create studio template interaction", () => { expect(applyTemplate).not.toMatch(/\bsetMode\s*\(/); expect(applyTemplate).toContain("[generateMode]: template.prompt"); }); + + it("keeps the effect preview display-only instead of adding it to generation materials", async () => { + const source = await readFile(createStudioUrl, "utf8"); + const applyTemplate = functionSource(source, "applyTemplateToConsole", "resetTemplateForm"); + const buildBody = functionSource(source, "buildGenerationBody", "submit"); + + expect(applyTemplate).not.toMatch(/\bsetMaterials\s*\(/); + expect(applyTemplate).not.toContain("previewImageUrl"); + expect(buildBody).not.toContain("previewImageUrl"); + }); }); function functionSource(source: string, name: string, nextName: string) { diff --git a/tests/providers-contract.test.ts b/tests/providers-contract.test.ts index 60e255f..0741216 100644 --- a/tests/providers-contract.test.ts +++ b/tests/providers-contract.test.ts @@ -1,3 +1,24 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -describe("Go provider adapter contract",()=>{it("freezes bounded adapters and safe errors",async()=>{const c=JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json",import.meta.url),"utf8"));expect(Object.keys(c.providers)).toEqual(["volcengine-visual","evolink","bailian","seedance"]);expect(c.errors).toEqual({generic:true,secretSafe:true});expect(c.liveCallsInTests).toBe(false);});}); + +describe("Go provider adapter contract", () => { + it("freezes bounded adapters and safe errors", async () => { + const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8")); + expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance"]); + expect(contract.providers["volcengine-visual"]).toMatchObject({ + model: "jimeng_seedream46_cvtob", + submitAction: "JimengSeedream46CVToBSubmitTask", + queryAction: "JimengSeedream46CVToBGetResult", + version: "2024-06-06" + }); + expect(contract.providers.seedance).toMatchObject({ + model: "doubao-seedance-2-0-260128", + submit: "/contents/generations/tasks", + query: "/contents/generations/tasks/{id}", + maxMaterials: 4, + resolutions: ["480p", "720p", "1080p"] + }); + expect(contract.errors).toEqual({ generic: true, secretSafe: true }); + expect(contract.liveCallsInTests).toBe(false); + }); +}); diff --git a/tests/seedance-client.test.ts b/tests/seedance-client.test.ts index 892eb22..bd26aed 100644 --- a/tests/seedance-client.test.ts +++ b/tests/seedance-client.test.ts @@ -1,10 +1,80 @@ -import { describe, expect, it } from "vitest"; -import { extractSeedanceUsage } from "@/lib/seedance/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSeedanceTask, extractSeedanceUsage, querySeedanceTask } from "@/lib/seedance/client"; describe("Seedance usage extraction", () => { + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.SEEDANCE_API_KEY; + delete process.env.SEEDANCE_BASE_URL; + delete process.env.SEEDANCE_MODEL; + }); + it("reads completion_tokens from top-level and nested provider responses", () => { expect(extractSeedanceUsage({ usage: { completion_tokens: 12345 } })).toEqual({ completionTokens: 12345 }); expect(extractSeedanceUsage({ data: { usage: { completionTokens: 67890 } } })).toEqual({ completionTokens: 67890 }); expect(extractSeedanceUsage({ usage: { prompt_tokens: 100 } })).toBeUndefined(); }); + + it("sends the official Seedance 2.0 multimodal payload without UI labels", async () => { + process.env.SEEDANCE_API_KEY = "test-key"; + process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3"; + process.env.SEEDANCE_MODEL = "doubao-seedance-2-0-260128"; + let body: Record | undefined; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "task-1" }), { status: 200, headers: { "Content-Type": "application/json" } }); + })); + + await createSeedanceTask({ + prompt: "combine", + settings: { ratio: "16:9", duration: 8, resolution: "1080p" }, + materials: [ + { type: "image", url: "/image.png", label: "@图片1" }, + { type: "video", url: "/video.mp4", label: "@视频1" }, + { type: "audio", url: "/audio.mp3", label: "@音频1" } + ], + origin: "https://app.test" + }); + + expect(body).toMatchObject({ + model: "doubao-seedance-2-0-260128", + generate_audio: true, + ratio: "16:9", + duration: 8, + resolution: "1080p", + watermark: false + }); + const content = body?.content as Array>; + expect(content).toHaveLength(4); + expect(content.slice(1).every((item) => !("label" in item))).toBe(true); + }); + + it("keeps the provider expired status distinct from a failed task", async () => { + process.env.SEEDANCE_API_KEY = "test-key"; + process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3"; + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ id: "task-1", status: "expired" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }))); + + await expect(querySeedanceTask("task-1")).resolves.toMatchObject({ status: "expired" }); + }); + + it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => { + process.env.SEEDANCE_API_KEY = "test-key"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(createSeedanceTask({ + prompt: "combine", + settings: {}, + materials: Array.from({ length: 5 }, (_, index) => ({ + type: "image" as const, + url: `/image-${index + 1}.png`, + label: `@图片${index + 1}` + })), + origin: "https://app.test" + })).rejects.toThrow("最多支持 4 个素材"); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/task-management.test.ts b/tests/task-management.test.ts index 7ccf1dd..36bdedf 100644 --- a/tests/task-management.test.ts +++ b/tests/task-management.test.ts @@ -54,10 +54,10 @@ describe("task management and public API helpers", () => { delete process.env.ALI_OSS_PUBLIC_BASE_URL; vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const target = String(input); - if (target.includes("CVSync2AsyncSubmitTask")) { + if (target.includes("JimengSeedream46CVToBSubmitTask")) { return new Response(JSON.stringify({ code: 10000, data: { task_id: "visual-task-1" } }), { status: 200, headers: { "Content-Type": "application/json" } }); } - if (target.includes("CVSync2AsyncGetResult")) { + if (target.includes("JimengSeedream46CVToBGetResult")) { return new Response(JSON.stringify({ code: 10000, data: { status: "done", image_urls: ["https://cdn.test/result.png"] } }), { status: 200, headers: { "Content-Type": "application/json" } }); } if (target === "https://cdn.test/result.png") { diff --git a/tests/video-settings.test.ts b/tests/video-settings.test.ts index 6ec1019..07b7515 100644 --- a/tests/video-settings.test.ts +++ b/tests/video-settings.test.ts @@ -32,6 +32,7 @@ describe("video settings", () => { expect(normalizeVideoRatio("21:9")).toBe("21:9"); expect(normalizeVideoRatio("bad-ratio")).toBe("9:16"); expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-260128")).toBe("1080p"); + expect(normalizeVideoResolution("4k", "doubao-seedance-2-0-260128")).toBe("720p"); expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-fast-260128")).toBe("720p"); }); }); diff --git a/tests/volcengine-signature.test.ts b/tests/volcengine-signature.test.ts index c7de830..a082043 100644 --- a/tests/volcengine-signature.test.ts +++ b/tests/volcengine-signature.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { sha256Hex, signVolcengineRequest } from "@/lib/volcengine/signature"; +import { queryVisualTask, submitVisualTask, type VisualClientConfig } from "@/lib/volcengine/visual-client"; describe("Volcengine Visual signing", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("creates canonical request and signed headers", () => { const signed = signVolcengineRequest({ method: "POST", @@ -10,7 +15,7 @@ describe("Volcengine Visual signing", () => { Version: "2022-08-31", Action: "CVSync2AsyncSubmitTask" }, - body: JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" }), + body: JSON.stringify({ req_key: "legacy-model", prompt: "test" }), accessKeyId: "ak", secretAccessKey: "sk", region: "cn-north-1", @@ -19,7 +24,34 @@ describe("Volcengine Visual signing", () => { }); expect(signed.url).toBe("https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31"); expect(signed.headers.Authorization).toContain("HMAC-SHA256 Credential=ak/20260528/cn-north-1/cv/request"); - expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" }))); + expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "legacy-model", prompt: "test" }))); expect(signed.canonicalRequest).toContain("content-type;host;x-content-sha256;x-date"); }); + + it("uses the dedicated Seedream 4.6 submit and query protocol", async () => { + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + return new Response(JSON.stringify({ code: 10000, data: { task_id: "task-1", status: "queued" } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + })); + const config: VisualClientConfig = { + accessKeyId: "ak", + secretAccessKey: "sk", + region: "cn-north-1", + service: "cv", + endpoint: "https://visual.volcengineapi.com" + }; + + await submitVisualTask({ req_key: "jimeng_seedream46_cvtob", prompt: "draw", scale: 50 }, config); + await queryVisualTask({ req_key: "jimeng_seedream46_cvtob", task_id: "task-1" }, config); + + expect(urls).toEqual([ + "https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBSubmitTask&Version=2024-06-06", + "https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBGetResult&Version=2024-06-06" + ]); + }); });