From 617b70e189b9aeb6e7b6431f8cb17a8ff0743d6b Mon Sep 17 00:00:00 2001 From: zAhYAng Date: Sat, 5 Sep 2026 09:56:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(video):=20NewAPI=20Channel=202=20-=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BD=AE=E8=AF=A2=E4=B8=8E=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创建成功后的短暂 task_not_exist 按退避策略重试,并在短期窗口耗尽后保留 running 状态,继续回查原 Provider Task ID。 补齐嵌套任务 ID 与视频结果地址解析,更新生成插件、协议文档和专项回归测试。 --- .gitignore | 2 + .../protocol/official_catalog_test.go | 49 +++++++ backend/internal/service/analytics.go | 6 +- .../internal/service/finance_token_test.go | 19 +++ .../internal/service/protocol_runtime_test.go | 125 ++++++++++++++++++ backend/internal/service/provider.go | 77 ++++++++++- .../internal/service/runtime_policy_test.go | 10 ++ backend/internal/service/task_worker.go | 34 +++-- docs/content/docs/backend/meta.json | 1 + .../content/docs/backend/newapi-channel-2.mdx | 49 +++++++ docs/content/docs/progress/pending-test.mdx | 8 ++ plugin-packages/generate-catalog.mjs | 10 +- .../newapi-video-generations-v1.yingce-plugin | Bin 7907 -> 8346 bytes 13 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 docs/content/docs/backend/newapi-channel-2.mdx diff --git a/.gitignore b/.gitignore index b023579bb..1099fd6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ docs/content/docs/* !docs/content/docs/plugins/ docs/content/docs/plugins/* !docs/content/docs/plugins/ai-art-critique.mdx +!docs/content/docs/backend/ +!docs/content/docs/backend/newapi-channel-2.mdx .env* !.env.example data/ diff --git a/backend/internal/protocol/official_catalog_test.go b/backend/internal/protocol/official_catalog_test.go index 2927db4a9..2c7df04ee 100644 --- a/backend/internal/protocol/official_catalog_test.go +++ b/backend/internal/protocol/official_catalog_test.go @@ -377,6 +377,55 @@ func TestOfficialRollDekWanVideoUsesVideosLifecycle(t *testing.T) { } } +func TestNewAPIVideoGenerationsParsesNestedTaskIDs(t *testing.T) { + adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2") + tests := []struct { + name string + payload string + wantID string + }{ + {name: "snake case", payload: `{"data":{"task_id":"task-snake","status":"queued"}}`, wantID: "task-snake"}, + {name: "camel case", payload: `{"data":{"taskId":"task-camel","status":"queued"}}`, wantID: "task-camel"}, + {name: "nested upstream id wins over wrapper id", payload: `{"id":"49137","data":{"task_id":"task-upstream","status":"queued"}}`, wantID: "task-upstream"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + created, err := adapter.ParseCreate(context.Background(), []byte(test.payload)) + if err != nil { + t.Fatal(err) + } + if created.TaskID != test.wantID || created.Status != StatusPending { + t.Fatalf("created = %#v, want task ID %q and pending status", created, test.wantID) + } + }) + } +} + +func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) { + adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2") + tests := []struct { + name string + payload string + }{ + {name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`}, + {name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-upstream"}, []byte(test.payload)) + if err != nil { + t.Fatal(err) + } + if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 { + t.Fatalf("state = %#v, want one completed video", state) + } + if state.Result.Videos[0].URL == "" { + t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0]) + } + }) + } +} + func TestOfficialOpenAIVideosDeclaresAuthenticatedResultDownload(t *testing.T) { adapter := officialPackageAdapter(t, "openai-videos.yingce-plugin", "newapi") capability, ok := adapter.(ResultCapability) diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 6fff6f9a8..6f685997d 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -1000,7 +1000,11 @@ func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, response } func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { + nestedTaskID := "" if data, ok := payload["data"].(map[string]any); ok { + if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") { + nestedTaskID = firstNonEmpty(stringField(data, "task_id"), stringField(data, "taskId")) + } for key, value := range data { if _, exists := payload[key]; !exists { payload[key] = value @@ -1064,7 +1068,7 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str } log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") } - log.ProviderRequestID = firstNonEmpty(stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) + log.ProviderRequestID = firstNonEmpty(nestedTaskID, stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus)) if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" { log.Status = model.ApiCallStatusFailed diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go index 02b18a070..638646c41 100644 --- a/backend/internal/service/finance_token_test.go +++ b/backend/internal/service/finance_token_test.go @@ -86,6 +86,25 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { } } +func TestEnrichAPICallLogPrefersNewAPIChannel2NestedTaskID(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {name: "snake case", payload: `{"id":"wrapper-id","data":{"task_id":"task-snake"}}`}, + {name: "camel case", payload: `{"id":"wrapper-id","data":{"taskId":"task-camel"}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: "/v1/video/generations"} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if !strings.HasPrefix(log.ProviderRequestID, "task-") { + t.Fatalf("ProviderRequestID = %q, want nested upstream task ID", log.ProviderRequestID) + } + }) + } +} + func TestEnrichAPICallLogKeepsUserFacingAndUpstreamFailureDetails(t *testing.T) { log := &model.ApiCallLog{ Status: model.ApiCallStatusFailed, diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index 195d87a89..4eb960dcf 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -5,12 +5,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" "infinite-canvas/backend/internal/protocol" ) @@ -342,6 +344,129 @@ func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) { } } +func TestDeclarativeNewAPIChannel2TaskNotExistRetry(t *testing.T) { + adapter := newDeclarativeNewAPIChannel2TestAdapter(t) + createCalls := 0 + pollCalls := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + createCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"pending"}`)) + case "/v1/video/generations/provider-task-1": + pollCalls++ + w.Header().Set("Content-Type", "application/json") + if pollCalls <= 2 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"task_not_exist","data":null,"message":"task_not_exist"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"succeeded","video_url":"` + server.URL + `/media.mp4"}`)) + case "/media.mp4": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: "newapi-channel-2", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + result, err := runProtocolAdapterTaskWithTiming(ctx, canvasGenerationInput{Mode: "video", Prompt: "a clip", Config: config}, adapter, protocolPollTiming{ + InitialDelay: time.Millisecond, + PollInterval: time.Millisecond, + TaskNotExistWindow: time.Second, + TaskNotExistRetryDelays: []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond, time.Millisecond}, + }) + if err != nil { + t.Fatal(err) + } + if result["mode"] != "video" || createCalls != 1 || pollCalls != 3 { + t.Fatalf("result = %#v, create calls = %d, poll calls = %d", result, createCalls, pollCalls) + } +} + +func TestDeclarativeNewAPIChannel2TaskNotExistExhaustion(t *testing.T) { + adapter := newDeclarativeNewAPIChannel2TestAdapter(t) + createCalls := 0 + pollCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + createCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"pending"}`)) + case "/v1/video/generations/provider-task-1": + pollCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"task_not_exist","message":"task_not_exist"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: "newapi-channel-2", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + _, err := runProtocolAdapterTaskWithTiming(ctx, canvasGenerationInput{Mode: "video", Prompt: "a clip", Config: config}, adapter, protocolPollTiming{ + InitialDelay: time.Millisecond, + PollInterval: time.Millisecond, + TaskNotExistWindow: time.Second, + TaskNotExistRetryDelays: []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond, time.Millisecond}, + }) + var pendingErr providerStatePendingError + var httpErr providerHTTPError + if !errors.As(err, &pendingErr) || pendingErr.TaskID != "provider-task-1" || !errors.As(err, &httpErr) { + t.Fatalf("error = %#v, want typed provider pending error wrapping HTTP error", err) + } + if createCalls != 1 || pollCalls != 5 { + t.Fatalf("create calls = %d, poll calls = %d, want 1 and 5", createCalls, pollCalls) + } +} + +func TestDeclarativeNewAPIChannel2TaskNotExistStrictClassification(t *testing.T) { + tests := []struct { + name string + interfaceType string + taskID string + err error + want bool + }{ + {name: "error code", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}, want: true}, + {name: "error message", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"message":"task_not_exist"}`}, want: true}, + {name: "other interface", interfaceType: "newapi-channel-1", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}}, + {name: "missing task id", interfaceType: "newapi-channel-2", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}}, + {name: "other status", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 404, Body: `{"code":"task_not_exist"}`}}, + {name: "unstructured body", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: "task_not_exist"}}, + {name: "partial match", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist_later"}`}}, + {name: "other provider error", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"invalid_parameter"}`}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isNewAPIChannel2TaskNotReady(test.interfaceType, test.taskID, test.err); got != test.want { + t.Fatalf("isNewAPIChannel2TaskNotReady() = %v, want %v", got, test.want) + } + }) + } +} + +func newDeclarativeNewAPIChannel2TestAdapter(t *testing.T) protocol.Adapter { + t.Helper() + adapter, err := protocol.LoadManifest([]byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"newapi-channel-2","version":"1.0.0","name":"NewAPI Channel 2","author":"Test","documentation":"# Test", + "contributes":{"providers":[{"id":"newapi-channel-2","label":"NewAPI Channel 2","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/video/generations","fields":{"model":"request.model"}},"poll":{"method":"GET","path":"/video/generations/{{taskId}}"},"response":{"taskIdPaths":["id"],"statusPaths":["status"],"resultPaths":["video_url"],"resultKind":"video"}}]} + }`)) + if err != nil { + t.Fatal(err) + } + return adapter +} + func TestDeclarativeProtocolRecoveryQueriesExistingTaskWithoutCreating(t *testing.T) { manifest := []byte(`{ "apiVersion":"yingce.plugin/v2", diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 8491e4589..27fc1ff5d 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -150,6 +150,17 @@ type providerHTTPError struct { RetryAfter time.Duration } +type providerStatePendingError struct { + TaskID string + Cause error +} + +func (e providerStatePendingError) Error() string { + return fmt.Sprintf("上游任务状态尚未同步,将继续查询原任务(任务 %s)", e.TaskID) +} + +func (e providerStatePendingError) Unwrap() error { return e.Cause } + type providerAnalyticsKey struct{} type providerOutboundPolicyKey struct{} @@ -2297,10 +2308,32 @@ func runDeclarativeProtocolTask(ctx context.Context, input canvasGenerationInput return runProtocolAdapterTask(ctx, input, adapter) } +type protocolPollTiming struct { + InitialDelay time.Duration + PollInterval time.Duration + TaskNotExistWindow time.Duration + TaskNotExistMaxMisses int + TaskNotExistRetryDelays []time.Duration +} + +var defaultProtocolPollTiming = protocolPollTiming{ + InitialDelay: 2 * time.Second, + PollInterval: 2500 * time.Millisecond, + TaskNotExistWindow: 15 * time.Second, + TaskNotExistMaxMisses: 5, + TaskNotExistRetryDelays: []time.Duration{2 * time.Second, 3 * time.Second, 5 * time.Second, 5 * time.Second}, +} + func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter) (map[string]interface{}, error) { + return runProtocolAdapterTaskWithTiming(ctx, input, adapter, defaultProtocolPollTiming) +} + +func runProtocolAdapterTaskWithTiming(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, timing protocolPollTiming) (map[string]interface{}, error) { request := protocolRequestFromInput(input) taskID := resumedProviderRequestID(ctx) var created protocol.CreateResult + createdProviderTask := false + syncWindowStartedAt := time.Now() if taskID == "" { spec, err := adapter.BuildCreate(ctx, protocol.RequestContext{BaseURL: input.Config.BaseURL, Request: request}) if err != nil { @@ -2324,8 +2357,16 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad if taskID == "" { return nil, errors.New("声明式协议创建请求没有返回任务 ID") } + createdProviderTask = true + syncWindowStartedAt = time.Now() + } + if createdProviderTask && input.Config.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) { + if err := sleepContext(ctx, timing.InitialDelay); err != nil { + return nil, err + } } + taskNotExistMisses := 0 for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { spec, err := adapter.BuildPoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) if err != nil { @@ -2333,8 +2374,26 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad } body, err := executeProtocolRequest(withProviderRequestKind(ctx, "poll"), input.Config, spec) if err != nil { + if isNewAPIChannel2TaskNotReady(input.Config.InterfaceType, taskID, err) { + taskNotExistMisses++ + maxMisses := timing.TaskNotExistMaxMisses + if maxMisses <= 0 { + maxMisses = len(timing.TaskNotExistRetryDelays) + 1 + } + if time.Since(syncWindowStartedAt) >= timing.TaskNotExistWindow || taskNotExistMisses >= maxMisses { + return nil, providerStatePendingError{TaskID: taskID, Cause: err} + } + delayIndex := min(taskNotExistMisses-1, len(timing.TaskNotExistRetryDelays)-1) + if delayIndex >= 0 { + if sleepErr := sleepContext(ctx, timing.TaskNotExistRetryDelays[delayIndex]); sleepErr != nil { + return nil, sleepErr + } + } + continue + } return nil, err } + taskNotExistMisses = 0 state, err := adapter.ParsePoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}, body) if err != nil { return nil, err @@ -2348,13 +2407,29 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad case protocol.StatusFailed, protocol.StatusCancelled: return nil, protocolResultError(state.Message, taskID) } - if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { + if err := sleepContext(ctx, timing.PollInterval); err != nil { return nil, err } } return nil, fmt.Errorf("声明式协议任务超时(任务 %s)", taskID) } +func isNewAPIChannel2TaskNotReady(interfaceType string, taskID string, err error) bool { + if strings.TrimSpace(interfaceType) != string(model.ChannelInterfaceNewAPIChannel2) || strings.TrimSpace(taskID) == "" { + return false + } + var httpErr providerHTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest { + return false + } + var payload map[string]any + if json.Unmarshal([]byte(httpErr.Body), &payload) != nil { + return false + } + code, message := providerFailureDetails(payload) + return strings.EqualFold(strings.TrimSpace(code), "task_not_exist") || strings.EqualFold(strings.TrimSpace(message), "task_not_exist") +} + // queryProtocolAdapterVideoTask performs exactly one read of an existing // declarative provider task. Manual recovery uses this path so it can never // create a second billable generation while checking a failed local task. diff --git a/backend/internal/service/runtime_policy_test.go b/backend/internal/service/runtime_policy_test.go index d0c8141a1..96bdab14c 100644 --- a/backend/internal/service/runtime_policy_test.go +++ b/backend/internal/service/runtime_policy_test.go @@ -35,12 +35,22 @@ func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) { if !svc.shouldDeferVideoProviderTask(base, string(input), context.DeadlineExceeded) { t.Fatal("resumable NewAPI Channel 2 deadline should remain running") } + pendingErr := providerStatePendingError{TaskID: base.ProviderRequestID, Cause: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}} + if !svc.shouldDeferVideoProviderTask(base, string(input), pendingErr) { + t.Fatal("resumable NewAPI Channel 2 provider sync delay should remain running") + } if svc.shouldDeferVideoProviderTask(model.Task{ID: "task-2", Type: "canvas_video"}, string(input), context.DeadlineExceeded) { t.Fatal("missing provider task id must not be deferred") } + if svc.shouldDeferVideoProviderTask(model.Task{ID: "task-2", Type: "canvas_video"}, string(input), pendingErr) { + t.Fatal("provider sync delay without persisted provider task id must not be deferred") + } if svc.shouldDeferVideoProviderTask(base, string(input), context.Canceled) { t.Fatal("explicit cancellation must not be deferred") } + if svc.shouldDeferVideoProviderTask(base, string(input), providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}) { + t.Fatal("untyped provider error must not be deferred") + } other, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIVideo)}}) if err != nil { t.Fatal(err) diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go index 6613f3626..c17dff74d 100644 --- a/backend/internal/service/task_worker.go +++ b/backend/internal/service/task_worker.go @@ -180,15 +180,22 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error { return leaseErr default: } - if errors.Is(err, context.DeadlineExceeded) { - decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON) - if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) { - if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "后台仍在生成", 15*time.Second); deferErr != nil { - return deferErr - } - _ = s.log(task.UserID, task.ID, "info", "前台等待结束,上游视频仍在生成,将继续回查原任务", task.PollStage) - return nil + decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON) + if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) { + stage := "后台仍在生成" + message := "前台等待结束,上游视频仍在生成,将继续回查原任务" + var pendingErr providerStatePendingError + if errors.As(err, &pendingErr) { + stage = "等待上游任务同步" + message = "上游任务状态暂未同步,将继续回查原任务" } + if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, stage, 15*time.Second); deferErr != nil { + return deferErr + } + _ = s.log(task.UserID, task.ID, "info", message, task.PollStage) + return nil + } + if errors.Is(err, context.DeadlineExceeded) { err = errors.New(taskTimeoutMessage(task.Type)) } return terminal.handleExecutionFailure(task, err, providerSucceeded, channelSlotFailedBeforeRequest) @@ -252,7 +259,16 @@ func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) t } func (s *Service) shouldDeferVideoProviderTask(task model.Task, decryptedInput string, err error) bool { - if !errors.Is(err, context.DeadlineExceeded) || strings.TrimSpace(task.ProviderRequestID) == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) { + providerRequestID := strings.TrimSpace(task.ProviderRequestID) + if providerRequestID == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) { + return false + } + deferSignal := errors.Is(err, context.DeadlineExceeded) + var pendingErr providerStatePendingError + if errors.As(err, &pendingErr) { + deferSignal = strings.TrimSpace(pendingErr.TaskID) == providerRequestID + } + if !deferSignal { return false } var input canvasGenerationInput diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json index ad9affd2d..009735dad 100644 --- a/docs/content/docs/backend/meta.json +++ b/docs/content/docs/backend/meta.json @@ -6,6 +6,7 @@ "local-development", "code-map", "backend-database", + "newapi-channel-2", "system-update", "protocol-plugins", "canvas-data-structure" diff --git a/docs/content/docs/backend/newapi-channel-2.mdx b/docs/content/docs/backend/newapi-channel-2.mdx new file mode 100644 index 000000000..9b32b4d6b --- /dev/null +++ b/docs/content/docs/backend/newapi-channel-2.mdx @@ -0,0 +1,49 @@ +--- +title: NewAPI Video Generations Channel 2 +description: Channel 2 视频任务创建、轮询与上游任务同步延迟处理 +--- + +# NewAPI Video Generations Channel 2 + +## 请求流程 + +声明式插件使用以下接口: + +- 创建:`POST /v1/video/generations` +- 查询:`GET /v1/video/generations/{taskId}` + +创建响应支持顶层 `id`、`task_id`、`taskId`,以及 `data.id`、`data.task_id`、`data.taskId`。当中转包装 ID 与真实上游任务 ID 同时存在时,按 `data.task_id`、`data.taskId`、顶层 `task_id`、顶层 `taskId`、`data.id`、顶层 `id` 的顺序解析,避免把中转内部记录 ID 用作上游查询 ID。宿主把解析出的值保存为 `provider_request_id`,后续查询始终使用该值,并对路径参数进行 URL 编码。 + +查询响应进入成功状态后,插件会从 `data.result_url`、`data.video_url`、`data.output_url`、`data.url`、`data.metadata.url` 以及对应的 `data.data.*` 嵌套字段读取视频地址,再回退到兼容的顶层结果字段。只返回成功状态而没有任何结果地址时,宿主仍按协议异常处理,不会创建空资源。 + +## 上游同步延迟 + +创建接口返回任务 ID 后,Channel 2 可能在数百毫秒到数秒内仍对查询接口返回结构化的 `400`: + +```json +{"code":"task_not_exist","message":"task_not_exist"} +``` + +宿主只在同时满足以下条件时把该响应视为“任务状态尚未同步”: + +1. 渠道接口类型精确为 `newapi-channel-2`; +2. 已经保存非空 Provider Task ID; +3. HTTP 状态为 `400`; +4. JSON 的 `code` 或 `message` 精确等于 `task_not_exist`。 + +不符合条件的 HTTP 错误、非 JSON 正文或其他错误码仍立即按失败语义处理。 + +## 轮询与延期 + +新任务首次查询前等待 `2` 秒;同步错误在同一次执行中按 `2` 秒、`3` 秒、`5` 秒、`5` 秒退避,最多短期查询 5 次。短期窗口耗尽后,Worker 不发送新的创建请求,也不切换渠道或退款,而是: + +- 保持本地任务状态为 `running`; +- 保留原 `provider_request_id` 和计费状态; +- 释放当前租约并把 `next_poll_at` 延后 15 秒; +- 下一轮从已保存的 Provider Task ID 恢复,只发送查询请求。 + +因此,上游生成耗时 500 至 600 秒时,本地会持续以低频查询等待完成,不会把正常排队误报为“模型服务拒绝了请求”。视频真正返回失败状态时,仍按供应商失败处理。 + +## 兼容边界 + +该容错只适用于 `newapi-channel-2` 视频任务。其他 NewAPI 接口、其他 Provider、缺少 Provider Task ID 的任务,以及真实参数错误不进入延期路径。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8dd029461..0b10399c9 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## NewAPI Video Generations Channel 2 轮询恢复 + +- [x] 官方插件兼容从 `data.task_id` 和 `data.taskId` 读取上游任务 ID;Channel 2 创建成功后首次查询等待 2 秒,仅将结构化 `400 task_not_exist` 识别为上游同步延迟,并按 2 秒、3 秒、5 秒、5 秒退避复查同一任务。 +- [x] 短期重试耗尽后,Worker 保留本地任务 `running`、原 `provider_request_id` 和计费状态,延后 15 秒继续查询;恢复过程不重复发送创建请求、不切换渠道,也不立即失败或退款。其他接口类型、HTTP 状态和错误码仍按原失败语义处理。 +- [x] Channel 2 终态响应兼容 `data.result_url`、`data.video_url`、`data.output_url`、`data.url`、`data.metadata.url` 及对应的 `data.data.*` 嵌套路径;API 调用日志在该接口下优先记录嵌套真实任务 ID。协议与日志富化专项回归测试通过。 +- [ ] 使用真实 Channel 2 分别验证上游任务 ID 短暂不可见和生成耗时 500 至 600 秒的任务:本地不再提示“模型服务拒绝了请求”,创建请求只出现一次,后续始终查询同一 Provider Task ID,最终视频正常回写;真实参数错误仍应及时失败。 +- [ ] 使用返回 `SUCCESS` 且视频地址位于 `data.result_url` 或 `data.data.video_url` 的真实任务,确认不再提示“声明式协议已完成但没有返回结果”,并在当前 OSS 配置下成功下载并持久化为本地资源。 + ## 编辑器(M2 命令状态机) - [x] 命令协议黄金文件:`editor-commands` 8 个黄金 op(addClip/moveClip/trimClip/splitClip/removeClip/setClipProperty/addSubtitle/removeSubtitle)全量快照比对通过;未知 op、非法 payload 一律抛错(fail-closed)且输入不可变。 diff --git a/plugin-packages/generate-catalog.mjs b/plugin-packages/generate-catalog.mjs index 896f12aee..5383f2513 100644 --- a/plugin-packages/generate-catalog.mjs +++ b/plugin-packages/generate-catalog.mjs @@ -357,7 +357,15 @@ add({ video_urls: omit(map({ $sortByOrder: ref("request.videos") }, "media", ref("media.value"))), audio_urls: omit(map({ $sortByOrder: ref("request.audios") }, "media", ref("media.value"))) }), - poll: { method: "GET", path: "/v1/video/generations/{{taskId}}" }, response: asyncResponse("video") + poll: { method: "GET", path: "/v1/video/generations/{{taskId}}" }, + response: asyncResponse("video", { + taskId: coalesce(ref("response.data.task_id"), ref("response.data.taskId"), ref("response.task_id"), ref("response.taskId"), ref("response.data.id"), ref("response.id"), ref("taskId")), + videos: coalesce( + ref("response.data.result_url"), ref("response.data.video_url"), ref("response.data.output_url"), ref("response.data.url"), ref("response.data.metadata.url"), + ref("response.data.data.video_url"), ref("response.data.data.output_url"), ref("response.data.data.result_url"), ref("response.data.data.url"), ref("response.data.data.metadata.url"), + ref("response.video_url"), ref("response.videoUrl"), ref("response.result_url"), ref("response.output_url"), ref("response.url"), ref("response.metadata.url"), ref("response.output.url") + ) + }) }); add({ diff --git a/plugin-packages/newapi-video-generations-v1.yingce-plugin b/plugin-packages/newapi-video-generations-v1.yingce-plugin index 9398b99ca87e3400503eaa9bf6e6843efcc9d4b2..42f634d4a5eb1b9bfe99f7f81199877cdcefce6f 100644 GIT binary patch literal 8346 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 delta 7706 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