fix: 修复请求日志丢失、轮询日志显示和开关动画问题 (#450)

* fix(logs): 请求明细 - 隐藏轮询状态查询

* fix(ui): 开关滑块 - 恢复状态位移与过渡动画

* fix(protocol): 声明式任务 - 创建请求生成幂等 UUID

* fix(logs): 请求日志 - 辅助状态更新失败时仍保留记录
This commit is contained in:
Rou
2026-09-09 06:48:26 +08:00
committed by GitHub
parent 59ecfec3e0
commit 6f7a0674ce
7 changed files with 116 additions and 6 deletions

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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