fix: 修复请求日志丢失、轮询日志显示和开关动画问题 (#450)
* fix(logs): 请求明细 - 隐藏轮询状态查询 * fix(ui): 开关滑块 - 恢复状态位移与过渡动画 * fix(protocol): 声明式任务 - 创建请求生成幂等 UUID * fix(logs): 请求日志 - 辅助状态更新失败时仍保留记录
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,7 +115,8 @@ export function Switch({ size = "md", checked: checkedProp, defaultChecked = fal
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("sc-switch-thumb absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full bg-surface-strong border border-border/80 transition-transform duration-200 ease-out motion-reduce:transition-none", thumbClass)}
|
||||
data-state={checked ? "on" : "off"}
|
||||
className={cn("sc-switch-thumb absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full border border-border/80 bg-surface-strong transition-transform duration-200 ease-out motion-reduce:transition-none", thumbClass)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function LogsPage() {
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="请求明细"
|
||||
description="模型生成、状态查询与结果下载;仅计费调用扣除积分"
|
||||
description="模型生成与结果下载记录;仅计费调用扣除积分"
|
||||
actions={
|
||||
<AdminExportButton
|
||||
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
|
||||
|
||||
Reference in New Issue
Block a user