diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index f4e261c34..89ebc5db6 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -190,8 +190,8 @@ func (r *Repository) HasAPICallLogForTask(taskID string) (bool, error) { } func visibleAPICallLogQuery(query *gorm.DB) *gorm.DB { - // 视频轮询属于一次生成调用的内部阶段,管理端只展示聚合后的创建主记录。 - return query.Where("NOT (api_call_logs.capability = ? AND api_call_logs.request_kind IN ?)", "video", []string{"poll", "download"}) + // 轮询属于一次生成调用的内部状态查询,管理端不单独展示。 + return query.Where("api_call_logs.request_kind <> ?", "poll") } func (r *Repository) VideoAPICallRoot(log model.ApiCallLog) (*model.ApiCallLog, error) { diff --git a/backend/internal/repository/analytics_test.go b/backend/internal/repository/analytics_test.go index bf8ac0c9b..62f45636b 100644 --- a/backend/internal/repository/analytics_test.go +++ b/backend/internal/repository/analytics_test.go @@ -151,3 +151,39 @@ func TestQueryAPICallLogsSearchesFailureFields(t *testing.T) { } } } + +func TestQueryAPICallLogsHidesInternalPollStages(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:api-log-visible-stages?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + now := time.Now() + logs := []model.ApiCallLog{ + {ID: "image-create", UserID: "user-1", Capability: "image", RequestKind: "create", CreatedAt: now}, + {ID: "image-poll", UserID: "user-1", Capability: "image", RequestKind: "poll", CreatedAt: now.Add(time.Second)}, + {ID: "image-download", UserID: "user-1", Capability: "image", RequestKind: "download", CreatedAt: now.Add(2 * time.Second)}, + {ID: "video-create", UserID: "user-1", Capability: "video", RequestKind: "create", CreatedAt: now.Add(3 * time.Second)}, + {ID: "video-poll", UserID: "user-1", Capability: "video", RequestKind: "poll", CreatedAt: now.Add(4 * time.Second)}, + } + if err := db.Create(&logs).Error; err != nil { + t.Fatal(err) + } + + items, total, err := New(db).QueryAPICallLogs(APICallLogFilter{ + AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, + Page: 1, + Limit: 20, + }) + if err != nil { + t.Fatal(err) + } + if total != 3 || len(items) != 3 { + t.Fatalf("visible logs = total:%d items:%#v, want create and download logs without polls", total, items) + } + if items[0].ID != "video-create" || items[1].ID != "image-download" || items[2].ID != "image-create" { + t.Fatalf("visible logs = %#v, want video-create, image-download, image-create", items) + } +} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index df6838a23..ee00f74f8 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + stdlog "log" "strings" "time" @@ -652,7 +653,9 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error { s.estimateCallCost(&log) if log.BillingOrderID != "" && log.ProviderRequestID != "" { if err := s.repo.UpdateBillingProviderRequestID(log.BillingOrderID, log.ProviderRequestID); err != nil { - return err + // 账单关联是请求日志的辅助状态,不能因为关联更新失败而丢失 + // 已经发生的上游调用记录。后续由任务/账单对账流程补偿关联。 + stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err) } } if log.TaskID != "" { @@ -667,7 +670,9 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error { nextPollAt = &next } if err := s.repo.UpdateTaskProviderState(log.TaskID, log.ProviderRequestID, stage, nextPollAt); err != nil { - return err + // 请求日志本身仍需保留;任务状态可由后续任务收尾或恢复流程 + // 重建,不能让一次状态写失败掩盖真实的上游调用。 + stdlog.Printf("provider task state update failed: task_id=%s provider_request_id=%s error=%v", log.TaskID, log.ProviderRequestID, err) } } if merged, err := s.mergeVideoAPICallLog(log); err != nil { diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index 4eb960dcf..423f4af63 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "strings" "testing" "time" @@ -344,6 +345,68 @@ func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) { } } +func TestDeclarativeProtocolRuntimeGeneratesPerCreateIdempotencyKey(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"test-idempotency-key-runtime","version":"1.0.0","name":"Test Idempotency Key","author":"Test","documentation":"# Test Idempotency Key", + "contributes":{"providers":[{"id":"test-idempotency-key-runtime","label":"Test Idempotency Key","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","headers":{"Idempotency-Key":{"$ref":"request.extra.idempotencyKey"}},"body":{"model":{"$ref":"request.model"},"prompt":{"$ref":"request.prompt"}}},"poll":{"method":"GET","path":"/tasks/{{taskId}}"},"response":{"taskIdPaths":["id"],"statusPaths":["status"],"resultPaths":["video_url"],"resultKind":"video"}}]} + }`) + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := center.install(testPluginPackage(t, manifest), "test-idempotency-key-runtime.yingce-plugin"); err != nil { + t.Fatal(err) + } + + var keys []string + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/tasks": + key := r.Header.Get("Idempotency-Key") + if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) { + t.Errorf("Idempotency-Key = %q, want UUID", key) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode create body: %v", err) + } + if _, ok := body["idempotencyKey"]; ok { + t.Error("host idempotency key leaked into provider JSON body") + } + keys = append(keys, key) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task-1","status":"pending"}`)) + case "/v1/tasks/task-1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"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: "test-model", APIFormat: "openai", InterfaceType: "test-idempotency-key-runtime", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + ctx = withProtocolRegistry(ctx, center.registrySnapshot()) + for i := 0; i < 2; i++ { + result, err := runDeclarativeProtocolTask(ctx, canvasGenerationInput{Mode: "video", Prompt: "a clip", Config: config}) + if err != nil { + t.Fatal(err) + } + if result["mode"] != "video" { + t.Fatalf("result = %#v", result) + } + } + if len(keys) != 2 || keys[0] == keys[1] { + t.Fatalf("create idempotency keys = %#v, want two different UUIDs", keys) + } +} + func TestDeclarativeNewAPIChannel2TaskNotExistRetry(t *testing.T) { adapter := newDeclarativeNewAPIChannel2TestAdapter(t) createCalls := 0 diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 33e5ad633..0ded6377c 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -29,6 +29,7 @@ import ( "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/protocol" + "github.com/google/uuid" "github.com/volcengine/volc-sdk-golang/base" "gorm.io/gorm" ) @@ -2445,6 +2446,10 @@ func runProtocolAdapterTaskWithTiming(ctx context.Context, input canvasGeneratio createdProviderTask := false syncWindowStartedAt := time.Now() if taskID == "" { + // The upstream create request may require an idempotency key. Keep it in + // the host-only request metadata so declarative plugins can map it to a + // header without exposing it in the provider JSON body. + request.Extra["idempotencyKey"] = uuid.NewString() spec, err := adapter.BuildCreate(ctx, protocol.RequestContext{BaseURL: input.Config.BaseURL, Request: request}) if err != nil { return nil, err diff --git a/web/src/components/ui/base/switch/switch.tsx b/web/src/components/ui/base/switch/switch.tsx index 5da612cc1..5a37275ac 100644 --- a/web/src/components/ui/base/switch/switch.tsx +++ b/web/src/components/ui/base/switch/switch.tsx @@ -115,7 +115,8 @@ export function Switch({ size = "md", checked: checkedProp, defaultChecked = fal ) : (