From 6f7a0674ce3e220fa99d822920b6023c5348d8ef Mon Sep 17 00:00:00 2001
From: Rou <76741805+Rou325@users.noreply.github.com>
Date: Wed, 9 Sep 2026 06:48:26 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AF=B7=E6=B1=82?=
=?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=A2=E5=A4=B1=E3=80=81=E8=BD=AE=E8=AF=A2?=
=?UTF-8?q?=E6=97=A5=E5=BF=97=E6=98=BE=E7=A4=BA=E5=92=8C=E5=BC=80=E5=85=B3?=
=?UTF-8?q?=E5=8A=A8=E7=94=BB=E9=97=AE=E9=A2=98=20(#450)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(logs): 请求明细 - 隐藏轮询状态查询
* fix(ui): 开关滑块 - 恢复状态位移与过渡动画
* fix(protocol): 声明式任务 - 创建请求生成幂等 UUID
* fix(logs): 请求日志 - 辅助状态更新失败时仍保留记录
---
backend/internal/repository/analytics.go | 4 +-
backend/internal/repository/analytics_test.go | 36 +++++++++++
backend/internal/service/admin.go | 9 ++-
.../internal/service/protocol_runtime_test.go | 63 +++++++++++++++++++
backend/internal/service/provider.go | 5 ++
web/src/components/ui/base/switch/switch.tsx | 3 +-
web/src/pages/admin/logs/logs-page.tsx | 2 +-
7 files changed, 116 insertions(+), 6 deletions(-)
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
) : (
)}
diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx
index ce32906e9..3d6e3cc94 100644
--- a/web/src/pages/admin/logs/logs-page.tsx
+++ b/web/src/pages/admin/logs/logs-page.tsx
@@ -127,7 +127,7 @@ export default function LogsPage() {
return (
exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}