From 8be75c1fbd90317aff162a450b70c228ee71c69b Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 22 Aug 2026 09:52:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E4=BB=BB=E5=8A=A1=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8D=8F=E8=AE=AE=E6=B2=BB=E7=90=86=20-=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=9C=8D=E5=8A=A1=E8=81=8C=E8=B4=A3=E5=B9=B6?= =?UTF-8?q?=E7=A6=81=E6=AD=A2=E5=B7=B2=E5=8F=91=E8=B5=B7=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=8F=96=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 95 +- backend/cmd/server/main_test.go | 31 + backend/internal/handler/auth.go | 11 +- backend/internal/handler/project.go | 4 +- backend/internal/handler/response.go | 98 + backend/internal/handler/response_test.go | 74 + backend/internal/handler/routes.go | 19 +- backend/internal/handler/security.go | 2 +- backend/internal/repository/repository.go | 61 +- backend/internal/service/admin.go | 5 +- backend/internal/service/analytics.go | 5 +- backend/internal/service/auth.go | 23 +- .../internal/service/channel_model_catalog.go | 17 +- backend/internal/service/channel_models.go | 4 +- backend/internal/service/email.go | 13 +- backend/internal/service/errors.go | 33 + backend/internal/service/errors_test.go | 31 + backend/internal/service/finance.go | 46 +- backend/internal/service/project.go | 39 +- .../internal/service/project_delete_test.go | 225 ++ backend/internal/service/provider.go | 110 +- .../service/provider_billing_boundary_test.go | 23 + .../service/provider_task_cancellation.go | 22 +- .../service/provider_task_recovery.go | 19 +- backend/internal/service/provider_test.go | 38 + backend/internal/service/resource.go | 20 +- backend/internal/service/service.go | 2001 +---------------- .../service/service_workflow_results.go | 65 - backend/internal/service/session_creation.go | 92 + backend/internal/service/session_upload.go | 129 ++ backend/internal/service/storyboard.go | 675 ++++++ .../internal/service/storyboard_workflow.go | 231 ++ backend/internal/service/task_billing.go | 113 + backend/internal/service/task_billing_test.go | 148 ++ .../service/task_cancellation_test.go | 61 +- backend/internal/service/task_creation.go | 269 +++ backend/internal/service/task_execution.go | 63 + .../internal/service/task_execution_test.go | 100 + backend/internal/service/task_lifecycle.go | 117 + backend/internal/service/task_output.go | 242 ++ backend/internal/service/task_output_test.go | 52 + .../internal/service/task_route_executor.go | 75 + .../service/task_route_executor_test.go | 116 + backend/internal/service/task_terminal.go | 235 ++ .../internal/service/task_terminal_test.go | 270 +++ backend/internal/service/task_worker.go | 230 ++ web/package.json | 2 +- .../canvas/canvas-config-node-panel.tsx | 13 +- .../components/canvas/canvas-node-content.tsx | 10 +- .../canvas/canvas-node-prompt-panel.tsx | 14 +- web/src/components/canvas/canvas-node.tsx | 4 - .../components/canvas/canvas-script-node.tsx | 21 +- web/src/lib/model-selection.ts | 5 +- .../canvas/canvas-project-world-layers.tsx | 2 - web/src/pages/canvas/project.tsx | 12 +- web/src/pages/canvas/shared.tsx | 2 +- .../canvas/use-canvas-generation-batches.ts | 73 +- web/src/pages/canvas/use-canvas-generation.ts | 86 +- web/src/pages/create/index.tsx | 60 +- web/src/pages/projects/index.tsx | 16 +- web/src/pages/tasks/index.tsx | 53 +- web/src/pages/tasks/task-grid-card.tsx | 12 +- web/src/pages/tasks/task-list-row.tsx | 12 +- web/src/pages/tasks/task-shared.tsx | 4 - web/src/services/api/image-contracts.ts | 98 + web/src/services/api/image-models.ts | 81 + web/src/services/api/image-protocols.ts | 161 ++ web/src/services/api/image-response.ts | 301 +++ web/src/services/api/image-streaming.ts | 114 + web/src/services/api/image-transport.ts | 75 + web/src/services/api/image-validation.ts | 140 ++ web/src/services/api/image.ts | 945 +------- web/src/services/api/request.ts | 42 +- web/src/services/api/task-center.ts | 11 +- web/src/services/api/video-contracts.ts | 45 + web/src/services/api/video-provider-deps.ts | 8 + web/src/services/api/video-provider-gemini.ts | 94 + .../services/api/video-provider-minimax.ts | 99 + web/src/services/api/video-provider-newapi.ts | 73 + web/src/services/api/video-provider-novita.ts | 60 + web/src/services/api/video-provider-openai.ts | 68 + .../services/api/video-provider-seedance.ts | 200 ++ web/src/services/api/video-response.ts | 122 + web/src/services/api/video-transport.ts | 54 + web/src/services/api/video-validation.ts | 67 + web/src/services/api/video.ts | 813 +------ web/test/api-request.test.ts | 38 + web/test/generation-task-local.test.ts | 32 +- web/test/image-api-contracts.test.ts | 69 + web/test/task-media-fallback.test.ts | 28 +- web/test/video-api-contracts.test.ts | 36 + 91 files changed, 6380 insertions(+), 4247 deletions(-) create mode 100644 backend/internal/handler/response.go create mode 100644 backend/internal/handler/response_test.go create mode 100644 backend/internal/service/errors.go create mode 100644 backend/internal/service/errors_test.go create mode 100644 backend/internal/service/project_delete_test.go create mode 100644 backend/internal/service/provider_billing_boundary_test.go delete mode 100644 backend/internal/service/service_workflow_results.go create mode 100644 backend/internal/service/session_creation.go create mode 100644 backend/internal/service/session_upload.go create mode 100644 backend/internal/service/storyboard.go create mode 100644 backend/internal/service/storyboard_workflow.go create mode 100644 backend/internal/service/task_billing.go create mode 100644 backend/internal/service/task_billing_test.go create mode 100644 backend/internal/service/task_creation.go create mode 100644 backend/internal/service/task_execution.go create mode 100644 backend/internal/service/task_execution_test.go create mode 100644 backend/internal/service/task_lifecycle.go create mode 100644 backend/internal/service/task_output.go create mode 100644 backend/internal/service/task_output_test.go create mode 100644 backend/internal/service/task_route_executor.go create mode 100644 backend/internal/service/task_route_executor_test.go create mode 100644 backend/internal/service/task_terminal.go create mode 100644 backend/internal/service/task_terminal_test.go create mode 100644 backend/internal/service/task_worker.go create mode 100644 web/src/services/api/image-contracts.ts create mode 100644 web/src/services/api/image-models.ts create mode 100644 web/src/services/api/image-protocols.ts create mode 100644 web/src/services/api/image-response.ts create mode 100644 web/src/services/api/image-streaming.ts create mode 100644 web/src/services/api/image-transport.ts create mode 100644 web/src/services/api/image-validation.ts create mode 100644 web/src/services/api/video-contracts.ts create mode 100644 web/src/services/api/video-provider-deps.ts create mode 100644 web/src/services/api/video-provider-gemini.ts create mode 100644 web/src/services/api/video-provider-minimax.ts create mode 100644 web/src/services/api/video-provider-newapi.ts create mode 100644 web/src/services/api/video-provider-novita.ts create mode 100644 web/src/services/api/video-provider-openai.ts create mode 100644 web/src/services/api/video-provider-seedance.ts create mode 100644 web/src/services/api/video-response.ts create mode 100644 web/src/services/api/video-transport.ts create mode 100644 web/src/services/api/video-validation.ts create mode 100644 web/test/api-request.test.ts create mode 100644 web/test/image-api-contracts.test.ts create mode 100644 web/test/video-api-contracts.test.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 42239010f..a6b1a0b68 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -67,7 +67,11 @@ func main() { r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { return fmt.Sprintf("%s - [%s] \"%s %s\" %d %s %s\n", param.ClientIP, param.TimeStamp.Format(time.RFC3339), param.Method, redactCanvasSharePath(param.Path), param.StatusCode, param.Latency, param.ErrorMessage) }), gin.Recovery()) - r.Use(cors()) + corsMiddleware, err := cors() + if err != nil { + log.Fatal(err) + } + r.Use(corsMiddleware) handler.ConfigureRuntime(svc) api := r.Group("/api") api.GET("/health", func(c *gin.Context) { @@ -123,10 +127,23 @@ func env(key string, fallback string) string { return value } -func cors() gin.HandlerFunc { +const corsAllowedHeaders = "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format, X-Canvas-Allow-Local-Channel, X-Canvas-Upstream-Base-URL" + +const corsAllowedMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + +type corsPolicy struct { + origins map[string]struct{} + allowAny bool +} + +func cors() (gin.HandlerFunc, error) { + policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS")) + if err != nil { + return nil, err + } return func(c *gin.Context) { origin := strings.TrimSpace(c.GetHeader("Origin")) - if origin != "" && !allowedOrigin(c, origin) { + if origin != "" && !allowedOriginWithPolicy(c, origin, policy) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "data": nil, "msg": "不允许的跨域来源"}) return } @@ -135,20 +152,67 @@ func cors() gin.HandlerFunc { c.Header("Access-Control-Allow-Credentials", "true") c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers") } - c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format, X-Canvas-Allow-Local-Channel, X-Canvas-Upstream-Base-URL") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", corsAllowedHeaders) + c.Header("Access-Control-Allow-Methods", corsAllowedMethods) c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() - } + }, nil } func allowedOrigin(c *gin.Context, origin string) bool { - parsed, err := url.Parse(origin) - if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS")) + if err != nil { + return false + } + return allowedOriginWithPolicy(c, origin, policy) +} + +func parseCORSPolicy(raw string) (corsPolicy, error) { + policy := corsPolicy{origins: make(map[string]struct{})} + for _, value := range strings.Split(raw, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if value == "*" { + policy.allowAny = true + continue + } + normalized, err := normalizeCORSOrigin(value) + if err != nil { + return corsPolicy{}, fmt.Errorf("CANVAS_CORS_ORIGINS contains invalid origin %q: %w", value, err) + } + policy.origins[normalized] = struct{}{} + } + return policy, nil +} + +func normalizeCORSOrigin(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("origin is empty") + } + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("origin must be an http or https origin") + } + if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("origin must not contain a path, query, or fragment") + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil +} + +func allowedOriginWithPolicy(c *gin.Context, origin string, policy corsPolicy) bool { + normalizedOrigin, err := normalizeCORSOrigin(origin) + if err != nil { + return false + } + parsed, err := url.Parse(normalizedOrigin) + if err != nil { return false } requestHost := c.Request.Host @@ -158,13 +222,14 @@ func allowedOrigin(c *gin.Context, origin string) bool { if strings.EqualFold(parsed.Host, strings.TrimSpace(requestHost)) { return true } - for _, allowed := range strings.Split(os.Getenv("CANVAS_CORS_ORIGINS"), ",") { - if strings.TrimSpace(allowed) == "*" { - return true - } - if strings.EqualFold(strings.TrimRight(strings.TrimSpace(allowed), "/"), strings.TrimRight(origin, "/")) { - return true - } + if policy.allowAny { + return true + } + if _, ok := policy.origins[normalizedOrigin]; ok { + return true + } + if len(policy.origins) > 0 { + return false } host := strings.ToLower(parsed.Hostname()) return (host == "localhost" || host == "127.0.0.1" || host == "::1") && (parsed.Scheme == "http" || parsed.Scheme == "https") diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index da8476ee3..cf8b30a04 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -29,6 +29,37 @@ func TestAllowedOriginUsesForwardedHost(t *testing.T) { } } +func TestParseCORSPolicyNormalizesConfiguredOrigins(t *testing.T) { + policy, err := parseCORSPolicy(" https://Canvas.example.com/ , http://localhost:3000 ") + if err != nil { + t.Fatal(err) + } + if _, ok := policy.origins["https://canvas.example.com"]; !ok { + t.Fatal("configured origin was not normalized") + } + if _, ok := policy.origins["http://localhost:3000"]; !ok { + t.Fatal("configured port was not preserved") + } +} + +func TestParseCORSPolicyRejectsNonOriginValue(t *testing.T) { + if _, err := parseCORSPolicy("https://example.com/app"); err == nil { + t.Fatal("path-bearing CORS value should be rejected") + } + if _, err := parseCORSPolicy("ftp://example.com"); err == nil { + t.Fatal("non-HTTP CORS value should be rejected") + } +} + +func TestAllowedOriginConfiguredListDoesNotFallbackToArbitraryLocalhost(t *testing.T) { + t.Setenv("CANVAS_CORS_ORIGINS", "https://app.example.com") + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest("GET", "http://backend/api/health", nil) + if allowedOrigin(context, "http://localhost:3000") { + t.Fatal("configured CORS list should disable the implicit localhost fallback") + } +} + func TestRedactCanvasSharePath(t *testing.T) { got := redactCanvasSharePath("/api/public/canvas-shares/private-token/resources/resource-1/file") if got != "/api/public/canvas-shares/:token/resources/resource-1/file" { diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 82583cff9..515ccab18 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -786,7 +786,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, log := apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), model.ApiCallStatusFailed, 0, time.Since(startedAt), err.Error(), concurrencyLimit) log.ErrorCode, log.Error = service.ChannelSlotFailureDetails(err) logSystemProxyCall(svc, log, nil) - fail(c, http.StatusServiceUnavailable, err) + failInternal(c, http.StatusServiceUnavailable, err) return } defer releaseChannel() @@ -976,12 +976,3 @@ func clearSessionCookie(c *gin.Context) { Secure: secure, }) } - -func failService(c *gin.Context, err error) { - var authErr *service.AuthError - if errors.As(err, &authErr) { - fail(c, authErr.Status, errors.New(authErr.Message)) - return - } - fail(c, http.StatusInternalServerError, err) -} diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 8e42eb4f4..5eaafa9db 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -31,7 +31,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } projects, err := svc.ListProjects(user.ID) if err != nil { - fail(c, http.StatusInternalServerError, err) + failService(c, err) return } ok(c, gin.H{"projects": projects}) @@ -67,7 +67,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { fail(c, http.StatusNotFound, err) return } - fail(c, http.StatusInternalServerError, err) + failService(c, err) return } ok(c, detail) diff --git a/backend/internal/handler/response.go b/backend/internal/handler/response.go new file mode 100644 index 000000000..095b54b13 --- /dev/null +++ b/backend/internal/handler/response.go @@ -0,0 +1,98 @@ +package handler + +import ( + "errors" + "log" + "net/http" + "strings" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +const internalErrorMessage = "系统处理失败,请稍后重试" + +func ok(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": "ok"}) +} + +// fail 只接受调用方已经确认可公开的错误;service 返回值统一交给 failService 投影。 +func fail(c *gin.Context, status int, err error) { + message := http.StatusText(status) + if err != nil && strings.TrimSpace(err.Error()) != "" { + message = err.Error() + } + writeFailure(c, status, status, message) +} + +func failService(c *gin.Context, err error) { + var appErr *service.AppError + if errors.As(err, &appErr) && validErrorStatus(appErr.Status) { + code := appErr.Code + if code == 0 { + code = appErr.Status + } + message := strings.TrimSpace(appErr.Message) + if message == "" { + message = safeInternalErrorMessage(appErr.Status) + } + if appErr.Status >= http.StatusInternalServerError { + diagnosticErr := appErr.Cause + if diagnosticErr == nil { + diagnosticErr = appErr + } + logHandlerError(c, appErr.Status, diagnosticErr) + } + writeFailure(c, appErr.Status, code, message) + return + } + failInternal(c, http.StatusInternalServerError, err) +} + +// failInternal 保留真实 HTTP 状态,但绝不把未分类错误原文写入响应。 +func failInternal(c *gin.Context, status int, err error) { + if !validErrorStatus(status) { + status = http.StatusInternalServerError + } + logHandlerError(c, status, err) + writeFailure(c, status, status, safeInternalErrorMessage(status)) +} + +func writeFailure(c *gin.Context, status int, code int, message string) { + c.JSON(status, gin.H{"code": code, "data": nil, "msg": message}) +} + +func validErrorStatus(status int) bool { + return status >= http.StatusBadRequest && status <= 599 +} + +func safeInternalErrorMessage(status int) string { + switch status { + case http.StatusBadGateway: + return "上游服务暂时不可用,请稍后重试" + case http.StatusServiceUnavailable: + return "服务暂时不可用,请稍后重试" + case http.StatusGatewayTimeout: + return "上游服务响应超时,请稍后重试" + default: + return internalErrorMessage + } +} + +func logHandlerError(c *gin.Context, status int, err error) { + if err == nil { + return + } + method := "" + route := "" + if c != nil && c.Request != nil { + method = c.Request.Method + route = c.FullPath() + if route == "" { + route = "" + } + } + // 普通访问日志会输出 Gin error;这里只记录错误类型,避免密钥或上游响应体进入日志。 + log.Printf("handler request failed: method=%s route=%s status=%d error_type=%T", method, route, status, err) +} diff --git a/backend/internal/handler/response_test.go b/backend/internal/handler/response_test.go new file mode 100644 index 000000000..f81a302af --- /dev/null +++ b/backend/internal/handler/response_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +type failureEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +func TestFailServiceProjectsAppError(t *testing.T) { + recorder, context := responseTestContext() + err := service.NewAppError(http.StatusTooManyRequests, "请求过于频繁,请稍后重试") + err.Code = 42901 + + failService(context, err) + + response := decodeFailureEnvelope(t, recorder) + if recorder.Code != http.StatusTooManyRequests || response.Code != 42901 || response.Msg != err.Message { + t.Fatalf("response = status %d, body %#v", recorder.Code, response) + } +} + +func TestFailServiceHidesUnclassifiedInternalError(t *testing.T) { + recorder, context := responseTestContext() + failService(context, errors.New("database password=secret")) + + response := decodeFailureEnvelope(t, recorder) + if recorder.Code != http.StatusInternalServerError || response.Code != http.StatusInternalServerError { + t.Fatalf("response = status %d, body %#v", recorder.Code, response) + } + if response.Msg != internalErrorMessage || strings.Contains(recorder.Body.String(), "password=secret") { + t.Fatalf("internal error leaked in response: %s", recorder.Body.String()) + } +} + +func TestFailInternalKeepsStatusWithoutLeakingCause(t *testing.T) { + recorder, context := responseTestContext() + failInternal(context, http.StatusServiceUnavailable, errors.New("redis://user:password@private-host")) + + response := decodeFailureEnvelope(t, recorder) + if recorder.Code != http.StatusServiceUnavailable || response.Msg != "服务暂时不可用,请稍后重试" { + t.Fatalf("response = status %d, body %#v", recorder.Code, response) + } + if strings.Contains(recorder.Body.String(), "private-host") { + t.Fatalf("internal cause leaked in response: %s", recorder.Body.String()) + } +} + +func responseTestContext() (*httptest.ResponseRecorder, *gin.Context) { + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/api/test", nil) + return recorder, context +} + +func decodeFailureEnvelope(t *testing.T, recorder *httptest.ResponseRecorder) failureEnvelope { + t.Helper() + var response failureEnvelope + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + return response +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 364977f72..b94a0281d 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -63,7 +63,7 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) { ActiveOnly: c.Query("activeOnly") == "true", }) if err != nil { - fail(c, http.StatusInternalServerError, err) + failService(c, err) return } ok(c, tasks) @@ -200,7 +200,7 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) { } logs, err := svc.TaskLogs(user.ID, c.Param("id")) if err != nil { - fail(c, http.StatusInternalServerError, err) + failService(c, err) return } ok(c, logs) @@ -332,7 +332,7 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) { } item, err := svc.StoreUpload(user.ID, c.PostForm("sessionId"), file) if err != nil { - fail(c, http.StatusInternalServerError, err) + failService(c, err) return } ok(c, item) @@ -354,17 +354,4 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) { r.GET("/sessions/:id", querySession) r.POST("/files", uploadFile) r.GET("/sessions/:id/results", downloadResults) - // 兼容旧客户端的废弃路由;新调用统一使用上方 REST 风格路径。 - r.POST("/create_session", createSession) - r.GET("/query_session/:id", querySession) - r.POST("/upload_file", uploadFile) - r.GET("/download_results/:id", downloadResults) -} - -func ok(c *gin.Context, data any) { - c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": "ok"}) -} - -func fail(c *gin.Context, status int, err error) { - c.JSON(status, gin.H{"code": status, "data": nil, "msg": err.Error()}) } diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index b10b9167b..65a8b62f2 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -148,7 +148,7 @@ func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duratio func loadRuntimePolicy(c *gin.Context, svc *service.Service) (service.RuntimePolicySetting, bool) { policy, err := svc.RuntimePolicy() if err != nil { - fail(c, http.StatusServiceUnavailable, errors.New("读取运行时策略失败:"+err.Error())) + failInternal(c, http.StatusServiceUnavailable, err) return service.RuntimePolicySetting{}, false } return policy, true diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 8a814acdb..7a94ab450 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -27,6 +27,8 @@ var ErrTextReplayClosed = errors.New("text replay task is closed") var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empty") +var ErrProjectHasActiveTasks = errors.New("project has active tasks") + type Repository struct { db *gorm.DB } @@ -322,6 +324,17 @@ func (r *Repository) ActiveTaskCountForUser(userID string) (int64, error) { return count, err } +func (r *Repository) ActiveTaskCountForProjectIDs(userID string, projectIDs []string) (int64, error) { + if len(projectIDs) == 0 { + return 0, nil + } + var count int64 + err := r.db.Model(&model.Task{}). + Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&count).Error + return count, err +} + // 任务领取以数据库租约为真相;PostgreSQL 锁行跳过竞争任务,SQLite 继续依赖条件更新保证单实例原子性。 func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*model.Task, error) { var task model.Task @@ -854,6 +867,10 @@ func (r *Repository) SaveResource(resource *model.Resource) error { return r.db.Save(resource).Error } +func (r *Repository) DeleteResource(userID string, id string) error { + return r.db.Delete(&model.Resource{}, "id = ? AND user_id = ?", id, userID).Error +} + func (r *Repository) Resource(id string) (*model.Resource, error) { var resource model.Resource if err := r.db.First(&resource, "id = ?", id).Error; err != nil { @@ -985,14 +1002,48 @@ func (r *Repository) UpdateProject(project *model.Project) error { }).Error } -func (r *Repository) DeleteProject(userID string, id string) error { +func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []model.CanvasProject) error { return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { + var canvasIDs []string + if err := tx.Model(&model.CanvasProject{}). + Where("user_id = ? AND project_id = ?", userID, id). + Pluck("id", &canvasIDs).Error; err != nil { + return err + } + projectScopeIDs := append([]string{id}, canvasIDs...) + var activeTaskCount int64 + if err := tx.Model(&model.Task{}). + Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectScopeIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeTaskCount).Error; err != nil { + return err + } + if activeTaskCount > 0 { + return ErrProjectHasActiveTasks + } + if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { return err } if err := tx.Where("project_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil { return err } + for _, canvas := range canvasUpdates { + result := tx.Model(&model.CanvasProject{}). + Where("id = ? AND user_id = ? AND project_id = ?", canvas.ID, userID, id). + Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + } + var remainingCanvasCount int64 + if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Count(&remainingCanvasCount).Error; err != nil { + return err + } + if remainingCanvasCount != 0 { + return fmt.Errorf("项目删除时仍有 %d 个画布关联未处理", remainingCanvasCount) + } shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id) if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err @@ -1023,6 +1074,12 @@ func (r *Repository) DeleteProject(userID string, id string) error { if err := tx.Where("project_id = ?", id).Delete(&model.ProjectUnit{}).Error; err != nil { return err } + if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { + return err + } + if err := tx.Model(&model.Session{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { + return err + } return tx.Delete(&model.Project{}, "id = ? AND user_id = ?", id, userID).Error }) } diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 987a9b326..0aa86f86c 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "errors" + "fmt" "strings" "time" @@ -335,7 +336,9 @@ func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserReq return nil, err } user.PasswordHash = hash - _ = s.repo.DeleteUserAuthSessions(user.ID) + if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil { + return nil, fmt.Errorf("清理旧登录会话失败,密码未更新:%w", err) + } } user.Role = nextRole user.Status = nextStatus diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index dcda1bf62..352d6b817 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -6,6 +6,7 @@ import ( "encoding/csv" "encoding/json" "errors" + "log" "sort" "strconv" "strings" @@ -1106,5 +1107,7 @@ func firstInt64Value(values map[string]any, keys ...string) (int64, bool) { } func (s *Service) recordActivity(userID string, event string, count int) { - _ = s.repo.RecordUserActivity(userID, event, count, time.Now()) + if err := s.repo.RecordUserActivity(userID, event, count, time.Now()); err != nil { + log.Printf("record user activity failed: event=%s count=%d error=%v", event, count, err) + } } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 310f6779c..f37b09bc3 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "log" "net/mail" "regexp" "strings" @@ -22,14 +23,8 @@ const sessionMaxAge = 30 * 24 * time.Hour var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`) -type AuthError struct { - Status int - Message string -} - -func (e *AuthError) Error() string { - return e.Message -} +// AuthError 保留为兼容别名;跨认证域的新代码应直接使用 AppError。 +type AuthError = AppError type RegisterRequest struct { Username string `json:"username"` @@ -67,19 +62,19 @@ type AuthUser struct { } func BadAuthRequest(message string) *AuthError { - return &AuthError{Status: 400, Message: message} + return NewAppError(400, message) } func NotFound(message string) *AuthError { - return &AuthError{Status: 404, Message: message} + return NewAppError(404, message) } func Unauthorized(message string) *AuthError { - return &AuthError{Status: 401, Message: message} + return NewAppError(401, message) } func Forbidden(message string) *AuthError { - return &AuthError{Status: 403, Message: message} + return NewAppError(403, message) } func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) { @@ -232,7 +227,9 @@ func (s *Service) CurrentUser(cookieValue string) (*model.User, error) { return nil, err } if time.Now().After(session.ExpiresAt) || session.TokenHash != hashToken(token) { - _ = s.repo.DeleteAuthSession(sessionID) + if cleanupErr := s.repo.DeleteAuthSession(sessionID); cleanupErr != nil { + log.Printf("expired auth session cleanup failed: session_id=%s error=%v", sessionID, cleanupErr) + } return nil, Unauthorized("登录状态已失效") } user, err := s.repo.User(session.UserID) diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index 0a030bc2e..5ea1f9d4e 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "sort" "strings" @@ -156,13 +155,13 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use } var payload channelModelsPayload if err := json.Unmarshal(data, &payload); err != nil { - return nil, &AuthError{Status: http.StatusBadGateway, Message: "模型服务返回的不是有效 JSON"} + return nil, WrapAppError(http.StatusBadGateway, "模型服务返回的不是有效 JSON", err) } if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" { - return nil, &AuthError{Status: http.StatusBadGateway, Message: payload.Error.Message} + return nil, NewAppError(http.StatusBadGateway, "模型服务返回失败,请检查渠道配置") } if payload.Code != nil && *payload.Code != 0 { - return nil, &AuthError{Status: http.StatusBadGateway, Message: firstNonEmpty(strings.TrimSpace(payload.Msg), "模型服务返回失败")} + return nil, NewAppError(http.StatusBadGateway, "模型服务返回失败,请检查渠道配置") } items := payload.Data @@ -251,16 +250,16 @@ func channelModelsUpstreamError(err error) error { } var httpErr providerHTTPError if !errors.As(err, &httpErr) { - return &AuthError{Status: http.StatusBadGateway, Message: "连接模型服务失败:" + err.Error()} + return WrapAppError(http.StatusBadGateway, "连接模型服务失败,请检查渠道地址和网络", err) } switch httpErr.StatusCode { case http.StatusUnauthorized, http.StatusForbidden: - return &AuthError{Status: http.StatusBadGateway, Message: "模型服务鉴权失败,请检查 API Key"} + return NewAppError(http.StatusBadGateway, "模型服务鉴权失败,请检查 API Key") case http.StatusNotFound: - return &AuthError{Status: http.StatusBadGateway, Message: "模型服务未提供 /models 接口"} + return NewAppError(http.StatusBadGateway, "模型服务未提供 /models 接口") case http.StatusTooManyRequests: - return &AuthError{Status: http.StatusBadGateway, Message: "模型服务请求过于频繁或额度不足"} + return NewAppError(http.StatusBadGateway, "模型服务请求过于频繁或额度不足") default: - return &AuthError{Status: http.StatusBadGateway, Message: fmt.Sprintf("模型服务请求失败:HTTP %d", httpErr.StatusCode)} + return WrapAppError(http.StatusBadGateway, httpErr.Error(), err) } } diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 487cce864..13e462c95 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -343,7 +343,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{ - Service: s, UserID: actor.ID, ChannelID: channel.ID, Capability: capability, + Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability, Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, }) testCtx = withProviderOutboundPolicy(testCtx, input.Config) @@ -363,7 +363,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, if errors.Is(err, context.DeadlineExceeded) { status = http.StatusGatewayTimeout } - return nil, &AuthError{Status: status, Message: "模型测试失败:" + truncateRunes(err.Error(), 1000)} + return nil, WrapAppError(status, "模型测试失败:"+providerUserFacingErrorMessage(err), err) } return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil } diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index 9041d9e47..ba33637c6 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "math/big" "mime" "net" @@ -168,10 +169,18 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { return err } if err := sendSMTPMail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil { - _ = s.repo.DeleteEmailVerificationCode(record.ID) + cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID) + if cleanupErr != nil { + return errors.Join( + fmt.Errorf("发送注册邮件失败:%w", err), + fmt.Errorf("清理失效验证码失败:%w", cleanupErr), + ) + } return fmt.Errorf("发送注册邮件失败:%w", err) } - _ = s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)) + if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil { + log.Printf("expired registration code cleanup failed: error=%v", cleanupErr) + } return nil } diff --git a/backend/internal/service/errors.go b/backend/internal/service/errors.go new file mode 100644 index 000000000..da15f3fee --- /dev/null +++ b/backend/internal/service/errors.go @@ -0,0 +1,33 @@ +package service + +// AppError 是 service 层对外公开的结构化错误。 +// Message 必须可安全展示给用户,Cause 仅用于保留内部诊断链路,不得直接写入 HTTP 响应。 +type AppError struct { + Status int + Code int + Message string + Retryable bool + Cause error +} + +func (e *AppError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *AppError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func NewAppError(status int, message string) *AppError { + return &AppError{Status: status, Message: message} +} + +func WrapAppError(status int, message string, cause error) *AppError { + return &AppError{Status: status, Message: message, Cause: cause} +} diff --git a/backend/internal/service/errors_test.go b/backend/internal/service/errors_test.go new file mode 100644 index 000000000..2ccc70acc --- /dev/null +++ b/backend/internal/service/errors_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "errors" + "testing" +) + +func TestAppErrorPreservesSafeProjectionAndCause(t *testing.T) { + cause := errors.New("database password=secret") + err := WrapAppError(503, "服务暂时不可用", cause) + err.Code = 10001 + err.Retryable = true + + if err.Error() != "服务暂时不可用" { + t.Fatalf("AppError.Error() = %q", err.Error()) + } + if err.Status != 503 || err.Code != 10001 || !err.Retryable { + t.Fatalf("AppError fields = %#v", err) + } + if !errors.Is(err, cause) { + t.Fatal("AppError should unwrap its internal cause") + } +} + +func TestAuthErrorAliasRemainsCompatible(t *testing.T) { + var err *AuthError = BadAuthRequest("请求参数错误") + var appErr *AppError + if !errors.As(err, &appErr) || appErr.Status != 400 || appErr.Message != "请求参数错误" { + t.Fatalf("AuthError compatibility = %#v", err) + } +} diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 30e83c6fc..6607f8a9d 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -793,59 +793,23 @@ func billingQuantity(capability string, value any) int64 { } func (s *Service) MarkBillingRunning(orderID string) error { - if orderID == "" { - return nil - } - return s.repo.MarkBillingRunning(orderID) + return s.taskBilling().MarkBillingRunning(orderID) } func (s *Service) SettleBilling(orderID string, providerRequestID string) error { - if orderID == "" { - return nil - } - return s.repo.SettleBillingOrder(orderID, providerRequestID) + return s.taskBilling().SettleBilling(orderID, providerRequestID) } func (s *Service) RefundBilling(orderID string, errorText string) error { - if orderID == "" { - return nil - } - return s.repo.RefundBillingOrder(orderID, truncateRunes(errorText, 1000)) + return s.taskBilling().RefundBilling(orderID, errorText) } func (s *Service) MarkBillingUncertain(orderID string, errorText string) error { - if orderID == "" { - return nil - } - return s.repo.MarkBillingUncertain(orderID, truncateRunes(errorText, 1000)) + return s.taskBilling().MarkBillingUncertain(orderID, errorText) } func (s *Service) BillingFailureRequiresReview(orderID string, taskID string, err error) bool { - if orderID == "" { - return false - } - if billingFailureUncertain(err) { - return true - } - order, orderErr := s.repo.BillingOrder(orderID) - if orderErr != nil || order.Status == model.BillingStatusUncertain { - return true - } - hasSuccessfulCall, logErr := s.repo.TaskHasSuccessfulBillableCall(taskID) - return logErr != nil || hasSuccessfulCall -} - -func billingFailureUncertain(err error) bool { - if err == nil { - return false - } - message := strings.ToLower(err.Error()) - for _, marker := range []string{"524", "timeout", "超时", "deadline exceeded", "context canceled", "connection reset", "unexpected eof", "broken pipe"} { - if strings.Contains(message, marker) { - return true - } - } - return false + return s.taskBilling().BillingFailureRequiresReview(orderID, taskID, err) } func newRedeemCode() (string, error) { diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index d969ead0c..81f307c14 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -3,10 +3,12 @@ package service import ( "encoding/json" "errors" + "fmt" "strings" "time" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) @@ -196,7 +198,9 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model. return model.Project{}, err } if _, err := s.createProjectWorkflow(project.ID, "", "project"); err != nil { - _ = s.repo.DeleteProject(userID, project.ID) + if deleteErr := s.repo.DeleteProject(userID, project.ID, nil); deleteErr != nil { + return model.Project{}, errors.Join(err, fmt.Errorf("项目初始化失败,回滚项目记录失败:%w", deleteErr)) + } return model.Project{}, err } project.Revision++ @@ -255,7 +259,38 @@ func (s *Service) DeleteProject(userID string, id string) error { if _, err := s.repo.ProjectForUser(userID, id); err != nil { return err } - return s.repo.DeleteProject(userID, id) + canvases, err := s.repo.ProjectCanvasDocuments(userID, id) + if err != nil { + return err + } + projectScopeIDs := make([]string, 0, len(canvases)+1) + projectScopeIDs = append(projectScopeIDs, id) + canvasUpdates := make([]model.CanvasProject, 0, len(canvases)) + deleteTime := time.Now() + for _, canvas := range canvases { + payloadJSON, payloadErr := canvasPayloadWithoutProject(canvas.PayloadJSON, deleteTime) + if payloadErr != nil { + return payloadErr + } + canvas.PayloadJSON = payloadJSON + canvas.UpdatedAt = deleteTime + canvasUpdates = append(canvasUpdates, canvas) + projectScopeIDs = append(projectScopeIDs, canvas.ID) + } + activeTaskCount, err := s.repo.ActiveTaskCountForProjectIDs(userID, projectScopeIDs) + if err != nil { + return err + } + if activeTaskCount > 0 { + return BadAuthRequest("项目仍有进行中的生成任务,请等待任务完成或取消后再删除") + } + if err := s.repo.DeleteProject(userID, id, canvasUpdates); err != nil { + if errors.Is(err, repository.ErrProjectHasActiveTasks) { + return BadAuthRequest("项目仍有进行中的生成任务,请等待任务完成或取消后再删除") + } + return err + } + return nil } func (s *Service) CreateProjectUnit(userID string, projectID string, req CreateProjectUnitRequest) (model.ProjectUnit, error) { diff --git a/backend/internal/service/project_delete_test.go b/backend/internal/service/project_delete_test.go new file mode 100644 index 000000000..ae00aa753 --- /dev/null +++ b/backend/internal/service/project_delete_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "encoding/json" + "errors" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newProjectDeleteTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.Project{}, + &model.ProjectUnit{}, + &model.CanvasProject{}, + &model.CanvasShare{}, + &model.CanvasUnitLink{}, + &model.ProjectAssetLink{}, + &model.ProjectAssetFolder{}, + &model.ProjectAssetCandidate{}, + &model.Asset{}, + &model.Task{}, + &model.Session{}, + &model.Shot{}, + &model.ShotAssetReference{}, + &model.WorkflowInstance{}, + &model.WorkflowStepInstance{}, + &model.WorkflowStepTask{}, + ); err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} + +func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) { + service, db := newProjectDeleteTestService(t) + project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive} + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "分镜", PayloadJSON: `{"projectId":"project-1","nodes":[]}`} + asset := model.Asset{ID: "asset-1", UserID: "user-1", Title: "角色", Status: model.AssetVersionStatusConfirmed} + task := model.Task{ID: "task-1", UserID: "user-1", ProjectID: project.ID, Status: model.TaskStatusSucceeded, Prompt: "已完成"} + canvasTask := model.Task{ID: "task-2", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusSucceeded, Prompt: "画布任务"} + session := model.Session{ID: "session-1", UserID: "user-1", ProjectID: project.ID, Status: model.SessionStatusCompleted} + canvasSession := model.Session{ID: "session-2", UserID: "user-1", ProjectID: canvas.ID, Status: model.SessionStatusCompleted} + seed := []any{ + &project, + &canvas, + &model.CanvasShare{ID: "share-1", UserID: "user-1", ProjectID: project.ID}, + &model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一集"}, + &model.CanvasUnitLink{ID: "canvas-link-1", ProjectID: project.ID, CanvasID: canvas.ID, UnitID: "unit-1"}, + &asset, + &model.ProjectAssetLink{ID: "asset-link-1", ProjectID: project.ID, AssetID: asset.ID}, + &model.ProjectAssetFolder{ID: "folder-1", ProjectID: project.ID, Name: "角色", NameKey: "角色"}, + &model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: project.ID, UnitID: "unit-1", Name: "角色", Status: "pending_confirmation"}, + &task, + &canvasTask, + &session, + &canvasSession, + } + for _, item := range seed { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := service.DeleteProject("user-1", project.ID); err != nil { + t.Fatal(err) + } + + var storedProject model.Project + if err := db.First(&storedProject, "id = ?", project.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("project error = %v, want record not found", err) + } + var storedCanvas model.CanvasProject + if err := db.First(&storedCanvas, "id = ?", canvas.ID).Error; err != nil { + t.Fatal(err) + } + if storedCanvas.ProjectID != "" { + t.Fatalf("canvas project id = %q, want empty", storedCanvas.ProjectID) + } + var payload map[string]any + if err := json.Unmarshal([]byte(storedCanvas.PayloadJSON), &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["projectId"]; exists { + t.Fatalf("canvas payload still contains deleted project id: %s", storedCanvas.PayloadJSON) + } + + for _, check := range []struct { + name string + model any + where string + }{ + {name: "canvas share", model: &model.CanvasShare{}, where: "project_id = ?"}, + {name: "canvas unit link", model: &model.CanvasUnitLink{}, where: "project_id = ?"}, + {name: "project unit", model: &model.ProjectUnit{}, where: "project_id = ?"}, + {name: "asset link", model: &model.ProjectAssetLink{}, where: "project_id = ?"}, + {name: "asset folder", model: &model.ProjectAssetFolder{}, where: "project_id = ?"}, + {name: "asset candidate", model: &model.ProjectAssetCandidate{}, where: "project_id = ?"}, + } { + var count int64 + if err := db.Model(check.model).Where(check.where, project.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s count = %d, want 0", check.name, count) + } + } + var storedAsset model.Asset + if err := db.First(&storedAsset, "id = ?", asset.ID).Error; err != nil { + t.Fatalf("asset should remain in the account library: %v", err) + } + + var storedTask model.Task + if err := db.First(&storedTask, "id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if storedTask.ProjectID != "" { + t.Fatalf("direct project task id = %q, want empty", storedTask.ProjectID) + } + var storedCanvasTask model.Task + if err := db.First(&storedCanvasTask, "id = ?", canvasTask.ID).Error; err != nil { + t.Fatal(err) + } + if storedCanvasTask.ProjectID != canvas.ID { + t.Fatalf("independent canvas task project id = %q, want %q", storedCanvasTask.ProjectID, canvas.ID) + } + var storedSession model.Session + if err := db.First(&storedSession, "id = ?", session.ID).Error; err != nil { + t.Fatal(err) + } + if storedSession.ProjectID != "" { + t.Fatalf("direct project session id = %q, want empty", storedSession.ProjectID) + } + var storedCanvasSession model.Session + if err := db.First(&storedCanvasSession, "id = ?", canvasSession.ID).Error; err != nil { + t.Fatal(err) + } + if storedCanvasSession.ProjectID != canvas.ID { + t.Fatalf("independent canvas session project id = %q, want %q", storedCanvasSession.ProjectID, canvas.ID) + } +} + +func TestDeleteProjectRejectsActiveProjectOrCanvasTasks(t *testing.T) { + service, db := newProjectDeleteTestService(t) + project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive} + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "分镜", PayloadJSON: `{"projectId":"project-1"}`} + activeTask := model.Task{ID: "task-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusRunning, Prompt: "生成中"} + for _, item := range []any{&project, &canvas, &activeTask} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + err := service.DeleteProject("user-1", project.ID) + if err == nil || err.Error() != "项目仍有进行中的生成任务,请等待任务完成或取消后再删除" { + t.Fatalf("DeleteProject() error = %v", err) + } + var storedProject model.Project + if err := db.First(&storedProject, "id = ?", project.ID).Error; err != nil { + t.Fatalf("project should remain after rejected deletion: %v", err) + } + var storedTask model.Task + if err := db.First(&storedTask, "id = ?", activeTask.ID).Error; err != nil { + t.Fatal(err) + } + if storedTask.ProjectID != canvas.ID { + t.Fatalf("active task project id = %q, want %q", storedTask.ProjectID, canvas.ID) + } +} + +func TestRepositoryDeleteProjectRechecksActiveTasksInsideTransaction(t *testing.T) { + service, db := newProjectDeleteTestService(t) + project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive} + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "分镜", PayloadJSON: `{"projectId":"project-1"}`} + activeTask := model.Task{ID: "task-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusQueued, Prompt: "排队中"} + for _, item := range []any{&project, &canvas, &activeTask} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + err := service.repo.DeleteProject("user-1", project.ID, []model.CanvasProject{canvas}) + if !errors.Is(err, repository.ErrProjectHasActiveTasks) { + t.Fatalf("DeleteProject() error = %v, want active-task conflict", err) + } + var storedProject model.Project + if err := db.First(&storedProject, "id = ?", project.ID).Error; err != nil { + t.Fatalf("project should remain after repository conflict: %v", err) + } + var storedCanvas model.CanvasProject + if err := db.First(&storedCanvas, "id = ?", canvas.ID).Error; err != nil { + t.Fatal(err) + } + if storedCanvas.ProjectID != project.ID { + t.Fatalf("canvas project id = %q, want %q", storedCanvas.ProjectID, project.ID) + } +} + +func TestDeleteProjectRejectsCanvasPayloadThatCannotBeUpdated(t *testing.T) { + service, db := newProjectDeleteTestService(t) + project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive} + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "分镜", PayloadJSON: "{"} + for _, item := range []any{&project, &canvas} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := service.DeleteProject("user-1", project.ID); err == nil { + t.Fatal("DeleteProject() error = nil for malformed canvas payload") + } + var storedProject model.Project + if err := db.First(&storedProject, "id = ?", project.ID).Error; err != nil { + t.Fatalf("project should remain after payload validation failure: %v", err) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index d261ce68f..b8adc5d30 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "log" "math" "mime" "mime/multipart" @@ -106,6 +107,17 @@ type providerError struct { Message string `json:"message"` } +// providerPayloadError keeps the upstream reason available for protocol +// fallback decisions while exposing only the categorized message to callers. +// Provider bodies may contain secrets or internal diagnostics and must not be +// copied into user-facing errors or logs. +type providerPayloadError struct { + raw string + message string +} + +func (e providerPayloadError) Error() string { return e.message } + type providerHTTPError struct { StatusCode int Status string @@ -123,6 +135,7 @@ type providerOutboundPolicyContext struct { type providerAnalyticsContext struct { Service *Service + Billing taskBillingLifecycle UserID string TaskID string BillingOrderID string @@ -139,6 +152,9 @@ type providerAnalyticsContext struct { func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context { metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID} + if service != nil { + metadata.Billing = service.taskBilling() + } // 账单模式随请求上下文传递,流式协议据此只为 Token 计费开启 usage 终态块。 if service != nil && task.BillingOrderID != "" { if order, err := service.repo.BillingOrder(task.BillingOrderID); err == nil { @@ -175,10 +191,61 @@ func withProviderRequestKind(ctx context.Context, requestKind string) context.Co } func (e providerHTTPError) Error() string { - if e.StatusCode == 524 { + switch e.StatusCode { + case 524: return "上游网关超时(524):模型请求可能仍在服务端执行并产生费用,请勿立即重试,请先到供应商后台核对任务或账单" + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return "模型服务拒绝了请求,请检查模型和参数" + case http.StatusUnauthorized, http.StatusForbidden: + return "模型服务鉴权失败,请检查 API Key 和模型权限" + case http.StatusNotFound: + return "模型或模型接口不存在,请检查渠道配置" + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + return "模型服务响应超时,请稍后重试" + case http.StatusTooManyRequests: + return "模型服务请求过于频繁或额度不足,请稍后重试" + } + if e.StatusCode >= http.StatusInternalServerError { + return fmt.Sprintf("模型服务暂时不可用(HTTP %d)", e.StatusCode) + } + return fmt.Sprintf("模型服务请求失败(HTTP %d)", e.StatusCode) +} + +func providerUserFacingErrorMessage(err error) string { + if err == nil { + return "模型服务请求失败" + } + if errors.Is(err, context.Canceled) { + return "模型请求已取消" + } + if errors.Is(err, context.DeadlineExceeded) { + return "模型服务响应超时,请稍后重试" + } + var appErr *AppError + if errors.As(err, &appErr) && strings.TrimSpace(appErr.Message) != "" { + return appErr.Message + } + var httpErr providerHTTPError + if errors.As(err, &httpErr) { + return httpErr.Error() + } + return "连接模型服务失败,请检查渠道地址和网络" +} + +func providerPayloadErrorMessage(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch { + case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"): + return "请求内容未通过模型服务安全审核,请调整后重试" + case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"): + return "模型服务额度不足,请检查渠道余额或配额" + case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")): + return "模型不存在或当前渠道未获得模型权限" + case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"): + return "模型服务拒绝了请求,请检查模型和参数" + default: + return "模型服务返回失败,请检查请求内容或渠道配置" } - return fmt.Sprintf("接口请求失败:%s %s", e.Status, e.Body) } func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) { @@ -311,6 +378,10 @@ func isAgentToolChoiceCompatibilityError(err error) bool { return false } message := strings.ToLower(err.Error()) + var payloadErr providerPayloadError + if errors.As(err, &payloadErr) { + message += " " + strings.ToLower(payloadErr.raw) + } return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode") } @@ -3192,18 +3263,20 @@ func doJSON(req *http.Request, target interface{}) error { } if payload, ok := target.(*imageResponse); ok { if payload.Error != nil && payload.Error.Message != "" { - return errors.New(payload.Error.Message) + return errors.New(providerPayloadErrorMessage(payload.Error.Message)) } if payload.Code != nil && *payload.Code != 0 { - return errors.New(defaultString(payload.Msg, "请求失败")) + return errors.New(providerPayloadErrorMessage(payload.Msg)) } } if payload, ok := target.(*map[string]interface{}); ok { if code, ok := (*payload)["code"].(float64); ok && code != 0 { - return errors.New(defaultString(stringField(*payload, "msg"), "请求失败")) + rawMessage := stringField(*payload, "msg") + return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)} } if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" { - return errors.New(stringField(errValue, "message")) + rawMessage := stringField(errValue, "message") + return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)} } } return nil @@ -3370,7 +3443,7 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in if req.Header.Get("x-goog-api-key") != "" { apiFormat = "gemini" } - log := model.ApiCallLog{ + callLog := model.ApiCallLog{ UserID: metadata.UserID, ChannelID: metadata.ChannelID, TaskID: metadata.TaskID, BillingOrderID: metadata.BillingOrderID, Source: "backend-task", Capability: metadata.Capability, Operation: metadata.Operation, RequestKind: requestKind, Billable: req.Method == http.MethodPost && requestKind != "cancel", @@ -3382,23 +3455,26 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in channelSlotFailure := false if code, message := ChannelSlotFailureDetails(requestErr); code != "" { channelSlotFailure = true - log.ErrorCode = code - log.Error = message + callLog.ErrorCode = code + callLog.Error = message } if requestKind == "create" && metadata.Capability == "video" { - log.VideoSeconds = metadata.VideoSeconds - if log.VideoSeconds <= 0 { + callLog.VideoSeconds = metadata.VideoSeconds + if callLog.VideoSeconds <= 0 { if strings.Contains(strings.ToLower(metadata.Model), "seedance") || strings.Contains(req.URL.Path, "/contents/generations/tasks") { - log.VideoSeconds = 5 + callLog.VideoSeconds = 5 } else { - log.VideoSeconds = 6 + callLog.VideoSeconds = 6 } } } - metadata.Service.EnrichAPICallLog(&log, responseBody) - if err := metadata.Service.LogAPICall(log); err != nil { - if !channelSlotFailure { - _ = metadata.Service.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对") + metadata.Service.EnrichAPICallLog(&callLog, responseBody) + if err := metadata.Service.LogAPICall(callLog); err != nil { + if !channelSlotFailure && metadata.Billing != nil { + if uncertainErr := metadata.Billing.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对"); uncertainErr != nil { + // 这里无法把日志落库错误返回给已完成的 HTTP 请求,只能把计费边界失败写入进程日志,交给待核对审计继续处理。 + log.Printf("provider billing uncertainty update failed: task_id=%s billing_order_id=%s error=%v", metadata.TaskID, metadata.BillingOrderID, uncertainErr) + } } } } diff --git a/backend/internal/service/provider_billing_boundary_test.go b/backend/internal/service/provider_billing_boundary_test.go new file mode 100644 index 000000000..cd60c6d44 --- /dev/null +++ b/backend/internal/service/provider_billing_boundary_test.go @@ -0,0 +1,23 @@ +package service + +import ( + "context" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestWithProviderAnalyticsUsesTaskBillingCoordinator(t *testing.T) { + billing := newTaskBillingCoordinator(&taskBillingRepositoryStub{}) + service := &Service{taskBillingCoordinator: billing} + task := model.Task{ID: "task-1", UserID: "user-1"} + + ctx := withProviderAnalytics(context.Background(), service, task) + metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if !ok { + t.Fatal("withProviderAnalytics() did not attach provider metadata") + } + if metadata.Billing != billing { + t.Fatalf("provider analytics billing = %p, want injected coordinator %p", metadata.Billing, billing) + } +} diff --git a/backend/internal/service/provider_task_cancellation.go b/backend/internal/service/provider_task_cancellation.go index 56075faf6..9c6ed624a 100644 --- a/backend/internal/service/provider_task_cancellation.go +++ b/backend/internal/service/provider_task_cancellation.go @@ -41,7 +41,9 @@ func (s *Service) startProviderCancellationReconciliation() { continue } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _ = s.reconcileProviderCancellation(ctx, task) + if err := s.reconcileProviderCancellation(ctx, task); err != nil { + _ = s.log(task.UserID, task.ID, "error", "上游取消状态对账失败", err.Error()) + } cancel() } }() @@ -89,6 +91,7 @@ func (s *Service) requestProviderCancellation(ctx context.Context, task *model.T } func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model.Task) error { + billing := s.taskBilling() if task.ProviderCancelAttempts >= providerCancellationMaxAttempts { return s.markProviderCancellationUncertain(task, "上游取消状态长时间未确认,费用待核对") } @@ -111,7 +114,7 @@ func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model switch outcome { case providerCancellationConfirmed: - if err := s.RefundBilling(task.BillingOrderID, "上游已确认取消"); err != nil { + if err := billing.RefundBilling(task.BillingOrderID, "上游已确认取消"); err != nil { return s.deferProviderCancellation(task, "上游已取消,但积分退回失败:"+err.Error()) } now := time.Now() @@ -121,17 +124,24 @@ func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model _ = s.log(task.UserID, task.ID, "info", "上游已确认取消,积分已退回", providerStatus) case providerCancellationSucceeded: errorText := "取消请求发出前上游已完成,费用已结算" - if err := s.SettleBilling(task.BillingOrderID, task.ProviderRequestID); err != nil { + var billingErr error + if err := billing.SettleBilling(task.BillingOrderID, task.ProviderRequestID); err != nil { errorText = "取消请求发出前上游已完成,但费用结算失败,需人工核对:" + err.Error() - _ = s.MarkBillingUncertain(task.BillingOrderID, errorText) + billingErr = fmt.Errorf("取消确认后的计费结算失败:%w", err) + if uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, errorText); uncertainErr != nil { + billingErr = errors.Join(billingErr, fmt.Errorf("记录取消确认后的计费待核对状态失败:%w", uncertainErr)) + } } if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, errorText, nil, nil); err != nil { return err } _ = s.log(task.UserID, task.ID, "warn", "上游任务在取消确认前已完成", providerStatus) + if billingErr != nil { + return billingErr + } case providerCancellationFailed: errorText := "上游任务已失败,取消状态无法确认,积分已退回" - if err := s.RefundBilling(task.BillingOrderID, errorText); err != nil { + if err := billing.RefundBilling(task.BillingOrderID, errorText); err != nil { return s.deferProviderCancellation(task, "上游任务已失败,但积分退回失败:"+err.Error()) } if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, errorText, nil, nil); err != nil { @@ -155,7 +165,7 @@ func (s *Service) markProviderCancellationUncertain(task *model.Task, errorText if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, message, nil, nil); err != nil { return err } - if err := s.MarkBillingUncertain(task.BillingOrderID, message); err != nil { + if err := s.taskBilling().MarkBillingUncertain(task.BillingOrderID, message); err != nil { _ = s.log(task.UserID, task.ID, "error", "上游取消已转入人工核对,但计费状态更新失败", err.Error()) return err } diff --git a/backend/internal/service/provider_task_recovery.go b/backend/internal/service/provider_task_recovery.go index b4489321d..80d9bb7ea 100644 --- a/backend/internal/service/provider_task_recovery.go +++ b/backend/internal/service/provider_task_recovery.go @@ -63,6 +63,7 @@ func (s *Service) AdminQueryFailedVideoTask(ctx context.Context, actor *model.Us } func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, claimUserID string) (*ProviderTaskQueryResult, error) { + billing := s.taskBilling() if task == nil || task.ID == "" { return nil, BadAuthRequest("任务不存在") } @@ -121,7 +122,11 @@ func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, cl return nil, err } task.LeaseOwner = owner - defer func() { _ = s.repo.ReleaseTaskProviderRecovery(task.ID, owner) }() + defer func() { + if releaseErr := s.repo.ReleaseTaskProviderRecovery(task.ID, owner); releaseErr != nil { + _ = s.log(task.UserID, task.ID, "error", "人工查询租约释放失败", releaseErr.Error()) + } + }() queryCtx := withProviderAnalytics(ctx, s, *task) queryCtx = withProviderOutboundPolicy(queryCtx, input.Config) @@ -148,18 +153,24 @@ func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, cl task.PollStage = strings.ToLower(providerStatus) task.NextPollAt = nil if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, nil, false); err != nil { - _ = s.MarkBillingUncertain(task.BillingOrderID, "人工查询确认上游成功,但任务结果未保存:"+err.Error()) + uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, "人工查询确认上游成功,但任务结果未保存:"+err.Error()) _ = s.log(task.UserID, task.ID, "error", "人工查询已取得视频,但任务恢复失败", err.Error()) + if uncertainErr != nil { + return nil, errors.Join(err, fmt.Errorf("记录任务结果未保存的计费待核对状态失败:%w", uncertainErr)) + } return nil, err } if err := s.RegisterTaskOutputFromTask(*task); err != nil { _ = s.log(task.UserID, task.ID, "error", "任务恢复成功但项目产物登记失败", err.Error()) } billingSettled := true - if err := s.SettleBilling(task.BillingOrderID, providerRequestID); err != nil { + if err := billing.SettleBilling(task.BillingOrderID, providerRequestID); err != nil { billingSettled = false - _ = s.MarkBillingUncertain(task.BillingOrderID, "人工查询确认生成成功,但积分结算失败:"+err.Error()) + uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, "人工查询确认生成成功,但积分结算失败:"+err.Error()) _ = s.log(task.UserID, task.ID, "error", "任务恢复成功但积分结算失败,已进入待核对", err.Error()) + if uncertainErr != nil { + return nil, errors.Join(err, fmt.Errorf("记录任务恢复后的计费待核对状态失败:%w", uncertainErr)) + } } else { _ = s.log(task.UserID, task.ID, "info", "人工查询确认生成成功,任务已恢复并完成结算", providerStatus) } diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 4a3df4080..0100a3623 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -238,6 +238,44 @@ func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) { } } +func TestProviderHTTPErrorDoesNotExposeResponseBody(t *testing.T) { + message := (providerHTTPError{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + Body: `{"error":{"message":"api-key=secret"}}`, + }).Error() + if strings.Contains(message, "api-key") || strings.Contains(message, "secret") || strings.Contains(message, `{"error"`) { + t.Fatalf("providerHTTPError exposed upstream response body: %q", message) + } + if !strings.Contains(message, "HTTP 502") { + t.Fatalf("providerHTTPError.Error() = %q", message) + } +} + +func TestProviderPayloadErrorMessageUsesSafeActionableCategories(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "moderation", raw: "request blocked by content policy: prompt=private", want: "安全审核"}, + {name: "quota", raw: "insufficient quota for api-key=secret", want: "额度不足"}, + {name: "model access", raw: "model not found for tenant secret-id", want: "模型不存在"}, + {name: "unknown", raw: "trace_id=private internal stack", want: "模型服务返回失败"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := providerPayloadErrorMessage(tt.raw) + if !strings.Contains(message, tt.want) { + t.Fatalf("providerPayloadErrorMessage() = %q, want category %q", message, tt.want) + } + if strings.Contains(message, "secret") || strings.Contains(message, "private") { + t.Fatalf("provider payload detail leaked: %q", message) + } + }) + } +} + func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) { tests := map[string]string{ "1440": "1440p", diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index f0fc609b5..b3a41a8e0 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -403,13 +403,29 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim if err != nil { resource.Status = model.ResourceStatusFailed resource.Error = err.Error() - _ = s.repo.SaveResource(&resource) + if saveErr := s.repo.SaveResource(&resource); saveErr != nil { + return nil, errors.Join(err, fmt.Errorf("记录资源失败状态失败:%w", saveErr)) + } return nil, err } resource.Status = model.ResourceStatusReady resource.ETag = etag if err := s.repo.SaveResource(&resource); err != nil { - return nil, err + cleanupErr := s.deleteStoredResourceObject(userID, &resource) + if cleanupErr == nil { + if deleteErr := s.repo.DeleteResource(userID, resource.ID); deleteErr != nil { + return nil, errors.Join(err, fmt.Errorf("清理资源记录失败:%w", deleteErr)) + } + return nil, fmt.Errorf("保存资源就绪状态失败:%w", err) + } + + resource.Status = model.ResourceStatusFailed + resource.Error = fmt.Sprintf("保存资源就绪状态失败,物理对象清理失败:%v", cleanupErr) + statusErr := s.repo.SaveResource(&resource) + if statusErr != nil { + return nil, errors.Join(err, cleanupErr, fmt.Errorf("记录资源失败状态失败:%w", statusErr)) + } + return nil, errors.Join(err, fmt.Errorf("清理已上传资源对象失败:%w", cleanupErr)) } s.recordActivity(userID, "resource", 1) return &resource, nil diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index e3737924f..0e5055e0a 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -7,11 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "io" - "mime/multipart" - "os" - "path/filepath" - "regexp" "strings" "sync" "time" @@ -22,28 +17,35 @@ import ( ) type Service struct { - repo *repository.Repository - dataDir string - runtimeCapabilities RuntimeCapabilities - cancelMu sync.Mutex - registrationMu sync.Mutex - emailCodeMu sync.Mutex - redeemBatchMu sync.Mutex - storageMu sync.Mutex - characterTaskMu sync.Mutex - activeCancels map[string]context.CancelFunc - pendingStorage map[string]int64 - coordinator *runtimeCoordinator - runtimeErr error - workerID string - routeCatalogMu sync.RWMutex - routeCatalogRefreshMu sync.Mutex - routeCatalog *routeCatalogSnapshot - routeCatalogTTL time.Duration - routeCatalogMaxStale time.Duration - routeCatalogVersion int64 - routeHealthMu sync.Mutex - routeHealthBlocked map[string]time.Time + repo *repository.Repository + dataDir string + runtimeCapabilities RuntimeCapabilities + cancelMu sync.Mutex + registrationMu sync.Mutex + emailCodeMu sync.Mutex + redeemBatchMu sync.Mutex + storageMu sync.Mutex + characterTaskMu sync.Mutex + activeCancels map[string]context.CancelFunc + pendingStorage map[string]int64 + coordinator *runtimeCoordinator + taskBillingCoordinator *taskBillingCoordinator + taskTerminalCoordinator *taskTerminalCoordinator + taskRouteExecutor *taskRouteExecutor + taskWorkerCoordinator *taskWorkerCoordinator + taskLifecycleCoordinator *taskLifecycleCoordinator + sessionCreationCoordinator *sessionCreationCoordinator + sessionUploadCoordinator *sessionUploadCoordinator + runtimeErr error + workerID string + routeCatalogMu sync.RWMutex + routeCatalogRefreshMu sync.Mutex + routeCatalog *routeCatalogSnapshot + routeCatalogTTL time.Duration + routeCatalogMaxStale time.Duration + routeCatalogVersion int64 + routeHealthMu sync.Mutex + routeHealthBlocked map[string]time.Time } const taskWorkerConcurrency = 3 @@ -81,224 +83,43 @@ type SessionDetail struct { Results []model.Result `json:"results"` } -type TaskSummary struct { - ID string `json:"id"` - SessionID string `json:"sessionId,omitempty"` - ProjectID string `json:"projectId,omitempty"` - Type string `json:"type"` - Status model.TaskStatus `json:"status"` - Stage string `json:"stage"` - Progress int `json:"progress"` - Prompt string `json:"prompt"` - Operation string `json:"operation,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - ProviderRequestID string `json:"providerRequestId,omitempty"` - ProviderCancelStatus model.ProviderCancelStatus `json:"providerCancelStatus,omitempty"` - ProviderCancelError string `json:"providerCancelError,omitempty"` - ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` - ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` - ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - PreviewURL string `json:"previewUrl,omitempty"` - PreviewKind string `json:"previewKind,omitempty"` - Attempts int `json:"attempts"` - StartedAt *time.Time `json:"startedAt"` - CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Billing *TaskBillingSummary `json:"billing,omitempty"` - ClientContext *TaskClientContext `json:"clientContext,omitempty"` -} - -type TaskClientContext struct { - ConversationID string `json:"conversationId"` - MessageID string `json:"messageId"` - BatchIndex int `json:"batchIndex,omitempty"` - BatchCount int `json:"batchCount,omitempty"` -} - -type TaskBillingSummary struct { - AmountMicrocredits int64 `json:"amountMicrocredits"` - Status model.BillingStatus `json:"status"` -} - type TaskListOptions struct { Limit int ProjectID string ActiveOnly bool } -type agentStoryboardInput struct { - References []string `json:"references"` - CanvasSnapshot map[string]any `json:"canvasSnapshot"` - Requirements string `json:"requirements"` - CanvasAssets []storyboardAsset `json:"canvasAssets"` - ProjectStyle storyboardProjectStyle `json:"projectStyle"` - Characters []storyboardCharacterCard `json:"characters"` - Config providerConfig `json:"config"` - ShotDuration int `json:"shotDurationSeconds"` - ShotCount int `json:"shotCount"` -} - -type storyboardProjectStyle struct { - PresetID string `json:"presetId"` - Title string `json:"title"` - Prompt string `json:"prompt"` - ProfileJSON string `json:"profileJson,omitempty"` -} - -type storyboardCharacterCard struct { - AssetID string `json:"assetId"` - VersionID string `json:"versionId"` - Name string `json:"name"` - Definition map[string]any `json:"definition"` -} - -type storyboardAsset struct { - ID string `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Tags []string `json:"tags"` - Prompt string `json:"prompt"` - CharacterAssetID string `json:"characterAssetId,omitempty"` - CharacterVersionID string `json:"characterVersionId,omitempty"` -} - -type agentStoryboardPlan struct { - Title string `json:"title"` - Logline string `json:"logline"` - StyleGuide string `json:"styleGuide"` - Characters []string `json:"characters"` - Locations []string `json:"locations"` - Shots []agentStoryboardShot `json:"shots"` - Raw map[string]interface{} `json:"-"` -} - -type agentStoryboardShot struct { - Title string `json:"title"` - Description string `json:"description"` - Duration int `json:"durationSeconds"` - Dialogue string `json:"dialogue"` - ShotSize string `json:"shotSize"` - Emotion string `json:"emotion"` - Lighting string `json:"lightingAndAtmosphere"` - AudioEffects string `json:"audioEffects"` - VisualPrompt string `json:"visualPrompt"` - VideoPrompt string `json:"videoPrompt"` - Camera string `json:"camera"` - Motion string `json:"motion"` - TimeBeats string `json:"timeBeats"` - Negative string `json:"negativePrompt"` - AssetTags []string `json:"assetTags"` - CharacterIDs []string `json:"characterIds"` - Intent string `json:"narrativeIntent"` - ViewerPOV string `json:"viewerPOV"` - Performance string `json:"performanceBlocking"` - MustHave []string `json:"mustHave"` - Optional []string `json:"optionalDetails"` - ContinuityOut string `json:"continuityOut"` -} - func New(repo *repository.Repository, dataDir string) *Service { return NewWithRuntimeCapabilities(repo, dataDir, RuntimeCapabilities{}) } func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service { coordinator, err := newRuntimeCoordinator(repo.Dialect()) - return &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)} + service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)} + service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo) + service.taskTerminalCoordinator = newTaskTerminalCoordinator(service) + service.taskRouteExecutor = newTaskRouteExecutor(service) + service.taskWorkerCoordinator = newTaskWorkerCoordinator(service) + service.taskLifecycleCoordinator = newTaskLifecycleCoordinator(service) + service.sessionCreationCoordinator = newSessionCreationCoordinator(service) + service.sessionUploadCoordinator = newSessionUploadCoordinator(service) + return service +} + +func (s *Service) taskBilling() *taskBillingCoordinator { + if s.taskBillingCoordinator != nil { + return s.taskBillingCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 + return newTaskBillingCoordinator(s.repo) } func (s *Service) StartWorker() { - s.startTextReplayCleanup() - s.startProviderCancellationReconciliation() - s.startBillingReviewAudit() - go func() { - slots := make(chan struct{}, maxChannelConcurrencyLimit) - dispatch := func() { - setting, err := s.runtimeConcurrencySetting() - if err != nil { - return - } - workerConcurrency := setting.WorkerConcurrency - for len(slots) < workerConcurrency { - releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute) - if err != nil || !acquired { - return - } - task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) - if err != nil || task == nil { - releaseGlobal() - return - } - slots <- struct{}{} - go func(task *model.Task) { - defer func() { <-slots; releaseGlobal() }() - _ = s.processClaimedTask(task) - }(task) - } - } - - dispatch() - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for range ticker.C { - dispatch() - } - }() + s.taskWorker().start() } func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) { - prompt := strings.TrimSpace(req.Prompt) - if prompt == "" { - return nil, errors.New("prompt is required") - } - if err := validateStoryboardContext(req.ProjectStyle, req.Characters); err != nil { - return nil, err - } - compactedSnapshot := compactPersistedValue(req.CanvasSnapshot) - snapshotJSON, _ := json.Marshal(compactedSnapshot) - session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)} - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - s.storageMu.Lock() - usage, err := s.repo.UserStorageUsage(userID) - if err != nil { - s.storageMu.Unlock() - return nil, err - } - incomingBytes := int64(len([]byte(prompt))*2 + len(snapshotJSON)) - if err := validateStructuredStorageQuotaWithPolicy(usage, "session", true, incomingBytes, policy.Resource); err != nil { - s.storageMu.Unlock() - return nil, err - } - if err := s.repo.Create(&session); err != nil { - s.storageMu.Unlock() - return nil, err - } - if err := s.repo.Create(&model.Message{ID: newID(), UserID: userID, SessionID: session.ID, Role: "user", Content: prompt}); err != nil { - cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID) - s.storageMu.Unlock() - if cleanupErr != nil { - return nil, fmt.Errorf("创建会话消息失败:%v;清理会话失败:%w", err, cleanupErr) - } - return nil, err - } - s.storageMu.Unlock() - taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, LogicalModelID: req.LogicalModelID, Input: map[string]any{"mode": "text", "references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "projectStyle": req.ProjectStyle, "characters": req.Characters, "config": req.Config}} - if _, err := s.CreateTask(userID, taskReq); err != nil { - s.storageMu.Lock() - cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID) - s.storageMu.Unlock() - if cleanupErr != nil { - return nil, fmt.Errorf("创建会话任务失败:%v;清理会话失败:%w", err, cleanupErr) - } - return nil, err - } - s.recordActivity(userID, "agent_message", 1) - return s.SessionDetail(userID, session.ID) + return s.sessionCreation().create(userID, req) } func channelModelNames(channel model.ModelChannel) []string { @@ -328,237 +149,6 @@ func (s *Service) SessionDetail(userID string, id string) (*SessionDetail, error return &SessionDetail{Session: *session, Messages: messages, Tasks: taskSummaries, Results: results}, nil } -func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) { - prompt := strings.TrimSpace(req.Prompt) - if prompt == "" { - return nil, errors.New("prompt is required") - } - normalizedInput, err := normalizeTaskInput(req.Input) - if err != nil { - return nil, err - } - var routed *RoutedModel - logicalModelID := strings.TrimSpace(req.LogicalModelID) - if logicalModelID != "" { - intent := ModelRequestIntentFromTaskInput(normalizedInput, req.Type, req.Operation) - routed, err = s.ResolveLogicalModel(logicalModelID, intent) - if err != nil { - return nil, err - } - normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) - } - // 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。 - if isTextReplayTaskRequest(normalizedInput) { - return s.createTextReplayTask(userID, req, normalizedInput) - } - if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil { - return nil, err - } - if err := s.ValidateTaskCapability(normalizedInput); err != nil { - return nil, err - } - if containsInlineMediaDataURL(normalizedInput) { - return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储") - } - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - activeTasks, err := s.repo.ActiveTaskCountForUser(userID) - if err != nil { - return nil, err - } - if activeTasks >= int64(policy.Task.ActiveTaskLimit) { - return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) - } - taskType := req.Type - if taskType == "" { - taskType = "video_image_to_video" - } - task := model.Task{ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model} - if routed != nil { - task.LogicalModelID = routed.LogicalModel.ID - task.LogicalModelRevisionID = routed.Revision.ID - task.RouteID = routed.Route.ID - task.ChannelModelID = routed.ChannelModel.ID - task.RouteRun = 1 - task.Model = routed.LogicalModel.Code - task.Provider = "managed" - } - if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil { - return nil, err - } - billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput) - if err != nil { - return nil, err - } - if err := s.protectTaskSecrets(normalizedInput); err != nil { - return nil, err - } - inputJSON, _ := json.Marshal(normalizedInput) - task.InputJSON = string(inputJSON) - if billingOrder != nil { - task.BillingOrderID = billingOrder.ID - } - err = s.createTaskWithinStorageQuota(&task, billingOrder, policy) - if errors.Is(err, repository.ErrActiveTaskLimit) { - return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) - } - if errors.Is(err, repository.ErrInsufficientCredits) { - return nil, BadAuthRequest("积分不足,请先使用兑换码充值") - } - if errors.Is(err, repository.ErrLogicalModelUnavailable) { - return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择") - } - if err != nil { - return nil, err - } - s.recordActivity(userID, "task", 1) - _ = s.log(userID, task.ID, "info", "任务已进入队列", "") - return taskForOutput(task), nil -} - -func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any { - config, _ := input["config"].(map[string]any) - nextConfig := make(map[string]any, len(config)+2) - for key, value := range config { - switch key { - case "channelId", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig": - continue - default: - nextConfig[key] = value - } - } - for key, value := range routed.Defaults { - canonical := canonicalCapabilityOptionName(key) - if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" { - nextConfig[canonical] = providerConfigOptionValue(value) - } - } - // 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。 - if options, ok := input["capabilityOptions"].(map[string]any); ok { - for key, value := range options { - canonical := canonicalCapabilityOptionName(key) - if isProviderCapabilityOption(canonical) { - nextConfig[canonical] = providerConfigOptionValue(value) - } - } - } - nextConfig["channelId"] = routed.ChannelModel.ChannelID - nextConfig["model"] = routed.ChannelModel.ModelKey - input["config"] = nextConfig - return input -} - -func providerConfigOptionValue(value any) string { - switch typed := value.(type) { - case string: - return typed - case json.Number: - return typed.String() - default: - return fmt.Sprint(value) - } -} - -// 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。 -func normalizeTaskInput(input map[string]any) (map[string]any, error) { - if input == nil { - return map[string]any{}, nil - } - encoded, err := json.Marshal(input) - if err != nil { - return nil, BadAuthRequest("任务输入格式无效") - } - var normalized map[string]any - if err := json.Unmarshal(encoded, &normalized); err != nil { - return nil, BadAuthRequest("任务输入格式无效") - } - if snapshot, ok := normalized["canvasSnapshot"]; ok { - normalized["canvasSnapshot"] = compactPersistedValue(snapshot) - } - return normalized, nil -} - -// createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay, -// 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。 -func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) { - prompt := strings.TrimSpace(req.Prompt) - if prompt == "" { - prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"])) - } - if prompt == "" { - return nil, errors.New("prompt is required") - } - taskType := strings.TrimSpace(req.Type) - if taskType == "" { - taskType = "text" - } - task := model.Task{ - ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, - Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5, - Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model), - } - if err := s.protectTaskSecrets(normalizedInput); err != nil { - return nil, err - } - inputJSON, _ := json.Marshal(normalizedInput) - task.InputJSON = string(inputJSON) - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil { - return nil, err - } - _ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "") - return taskForOutput(task), nil -} - -func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error { - if !taskInputUsesCustomChannel(input) { - return nil - } - return s.RequireFeature(FeatureCustomChannels) -} - -func taskInputUsesCustomChannel(input map[string]any) bool { - config, ok := input["config"].(map[string]any) - if !ok { - return false - } - channelID, _ := config["channelId"].(string) - baseURL, _ := config["baseUrl"].(string) - apiKey, _ := config["apiKey"].(string) - if strings.TrimSpace(channelID) != "" || systemChannelIDFromBaseURL(baseURL) != "" { - return false - } - return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" -} - -func compactPersistedValue(value interface{}) interface{} { - switch item := value.(type) { - case map[string]interface{}: - result := make(map[string]interface{}, len(item)) - for key, child := range item { - if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") { - result[key] = "" - continue - } - result[key] = compactPersistedValue(child) - } - return result - case []interface{}: - result := make([]interface{}, len(item)) - for index, child := range item { - result[index] = compactPersistedValue(child) - } - return result - default: - return value - } -} - func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) { return s.TasksWithOptions(userID, TaskListOptions{Limit: limit}) } @@ -624,1506 +214,19 @@ func (s *Service) refreshTaskProviderState(task *model.Task) error { } func (s *Service) RetryTask(userID string, id string) (*model.Task, error) { - task, err := s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled { - return nil, errors.New("only failed or cancelled tasks can be retried") - } - if task.ProviderCancelStatus == model.ProviderCancelStatusRequested { - return nil, BadAuthRequest("上游取消状态仍在确认中,请确认费用结果后再重试") - } - if task.BillingOrderID != "" { - if order, orderErr := s.repo.BillingOrder(task.BillingOrderID); orderErr == nil && order.Status == model.BillingStatusUncertain { - return nil, BadAuthRequest("上一次调用费用仍在核对中,处理完成前不能重复提交") - } - } - if isContentModerationFailure(task.Error) { - return nil, BadAuthRequest(contentModerationRetryMessage) - } - decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) - if err != nil { - return nil, err - } - var billingInput map[string]any - if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil { - return nil, err - } - if err := s.prepareLogicalTaskRetry(task, billingInput); err != nil { - return nil, err - } - if err := s.requireCustomChannelsForTaskInput(billingInput); err != nil { - return nil, err - } - billingOrder, err := s.taskBillingOrder(userID, task, billingInput) - if err != nil { - return nil, err - } - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil { - return nil, err - } - task, err = s.repo.RetryTaskWithBilling(userID, task, billingOrder, policy.Task.ActiveTaskLimit) - if errors.Is(err, repository.ErrInsufficientCredits) { - return nil, BadAuthRequest("积分不足,请先使用兑换码充值") - } - if errors.Is(err, repository.ErrActiveTaskLimit) { - return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) - } - if errors.Is(err, repository.ErrTaskNotRetryable) { - return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试") - } - if err != nil { - return nil, err - } - if task.SessionID != "" { - if session, err := s.repo.SessionForUser(task.UserID, task.SessionID); err == nil { - session.Status = model.SessionStatusActive - session.CanvasOpsJSON = "" - _ = s.repo.Save(session) - } - } - _ = s.log(userID, task.ID, "info", "任务已重新入队", "") - return taskForOutput(*task), nil + return s.taskLifecycle().retryTask(userID, id) } -func (s *Service) CancelTask(_ context.Context, userID string, id string) (*model.Task, error) { - task, err := s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - if task.Status == model.TaskStatusRunning { - return nil, errors.New("任务已开始生成,无法取消") - } - if task.Status == model.TaskStatusSucceeded { - return nil, errors.New("completed task cannot be cancelled") - } - now := time.Now() - if task.Status == model.TaskStatusQueued { - cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusQueued, now) - if err != nil { - return nil, err - } - if cancelled { - if err := s.RefundBilling(task.BillingOrderID, "任务在调用上游前取消"); err != nil { - return nil, err - } - task, err = s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - } else { - task, err = s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - } - } - // 排队任务可能在条件更新前被 worker 抢占,重新读取后必须再次阻止取消。 - if task.Status == model.TaskStatusRunning { - return nil, errors.New("任务已开始生成,无法取消") - } - if task.Status != model.TaskStatusCancelled { - return nil, errors.New("task cannot be cancelled in its current state") - } - if task.SessionID != "" { - _ = s.markSessionFailed(*task, "会话任务已取消。") - } - if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil { - _ = s.log(userID, task.ID, "error", "文本回放草稿归并失败", err.Error()) - } - _ = s.log(userID, task.ID, "warn", "任务已取消", "") - return taskForOutput(*task), nil +func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*model.Task, error) { + return s.taskLifecycle().cancelTask(ctx, userID, id) } func (s *Service) TaskLogs(userID string, id string) ([]model.TaskLog, error) { return s.repo.TaskLogs(userID, id) } -func taskSummariesForOutput(tasks []model.Task) []TaskSummary { - return taskSummariesForOutputWithBilling(tasks, nil) -} - -func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary { - result := make([]TaskSummary, 0, len(tasks)) - for _, task := range tasks { - summary := taskSummaryForOutput(task) - if order, ok := orders[task.ID]; ok { - summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status} - if summary.ProviderRequestID == "" { - summary.ProviderRequestID = order.ProviderRequestID - } - } - result = append(result, summary) - } - return result -} - -func taskBillingTaskIDs(tasks []model.Task) []string { - ids := make([]string, 0, len(tasks)) - seen := map[string]struct{}{} - for _, task := range tasks { - if task.BillingOrderID == "" { - continue - } - if _, ok := seen[task.ID]; ok { - continue - } - seen[task.ID] = struct{}{} - ids = append(ids, task.ID) - } - return ids -} - -func taskSummaryForOutput(task model.Task) TaskSummary { - errorCode := "" - if isContentModerationFailure(task.Error) { - errorCode = contentModerationErrorCode - } - previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) - return TaskSummary{ - ID: task.ID, - SessionID: task.SessionID, - ProjectID: task.ProjectID, - Type: task.Type, - Status: task.Status, - Stage: task.Stage, - Progress: task.Progress, - Prompt: truncateRunes(task.Prompt, 500), - Operation: task.Operation, - Provider: task.Provider, - Model: task.Model, - ProviderRequestID: task.ProviderRequestID, - ProviderCancelStatus: task.ProviderCancelStatus, - ProviderCancelError: task.ProviderCancelError, - ProviderCancelAttempts: task.ProviderCancelAttempts, - ProviderCancelRequestedAt: task.ProviderCancelRequestedAt, - ProviderCancelledAt: task.ProviderCancelledAt, - ErrorCode: errorCode, - PreviewURL: previewURL, - PreviewKind: previewKind, - Attempts: task.Attempts, - StartedAt: task.StartedAt, - CompletedAt: task.CompletedAt, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - ClientContext: taskClientContext(task.InputJSON), - } -} - -// 列表只暴露创作页恢复所需的关联 ID,不下发完整任务输入或其他 metadata。 -func taskClientContext(raw string) *TaskClientContext { - var input struct { - Metadata struct { - Source string `json:"source"` - ConversationID string `json:"conversationId"` - MessageID string `json:"messageId"` - BatchIndex int `json:"batchIndex"` - BatchCount int `json:"batchCount"` - } `json:"metadata"` - } - if json.Unmarshal([]byte(raw), &input) != nil || input.Metadata.Source != "create-page" || input.Metadata.ConversationID == "" || input.Metadata.MessageID == "" { - return nil - } - return &TaskClientContext{ - ConversationID: input.Metadata.ConversationID, - MessageID: input.Metadata.MessageID, - BatchIndex: input.Metadata.BatchIndex, - BatchCount: input.Metadata.BatchCount, - } -} - -// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 -func taskMediaPreview(raw string, taskType string) (string, string) { - if strings.TrimSpace(raw) == "" { - return "", "" - } - var payload any - if json.Unmarshal([]byte(raw), &payload) != nil { - return "", "" - } - defaultKind := "image" - if strings.Contains(strings.ToLower(taskType), "video") { - defaultKind = "video" - } - return findTaskMediaPreview(payload, defaultKind) -} - -func findTaskMediaPreview(value any, hint string) (string, string) { - switch item := value.(type) { - case string: - text := strings.TrimSpace(item) - if !strings.HasPrefix(text, "/api/resources/") && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") { - return "", "" - } - kind := hint - lower := strings.ToLower(text) - if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") { - kind = "video" - } else if kind != "video" { - kind = "image" - } - return text, kind - case []any: - for _, child := range item { - if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" { - return previewURL, previewKind - } - } - case map[string]any: - for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} { - child, exists := item[key] - if !exists { - continue - } - childHint := hint - if key == "video" { - childHint = "video" - } else if key == "images" || key == "image" { - childHint = "image" - } - if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" { - return previewURL, previewKind - } - } - } - return "", "" -} - -func truncateRunes(value string, limit int) string { - text := []rune(value) - if len(text) <= limit { - return value - } - return string(text[:limit]) + "..." -} - -func taskForOutput(task model.Task) *model.Task { - task.InputJSON = publicTaskInputJSON(task.InputJSON) - // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 - task.LogicalModelRevisionID = "" - task.RouteID = "" - task.ChannelModelID = "" - return &task -} - -func publicTaskInputJSON(raw string) string { - if strings.TrimSpace(raw) == "" { - return "" - } - var input map[string]any - if err := json.Unmarshal([]byte(raw), &input); err != nil { - return "" - } - public := map[string]any{} - // 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。 - for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} { - if value, ok := input[key]; ok { - public[key] = value - } - } - if len(public) == 0 { - return "" - } - data, _ := json.Marshal(public) - return string(data) -} - -func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) { - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - maxBytes := megabytes(policy.Resource.SessionUploadMB) - if header == nil || header.Size > maxBytes { - return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) - } - day, err := s.reserveSessionUploadQuota(userID, header.Size) - if err != nil { - return nil, err - } - reserved := true - defer func() { - if reserved { - s.releaseUserUploadQuota(userID, day, header.Size) - } - }() - file, err := header.Open() - if err != nil { - return nil, err - } - defer file.Close() - uploadDir := filepath.Join(s.dataDir, "uploads") - if err := os.MkdirAll(uploadDir, 0o750); err != nil { - return nil, err - } - if strings.TrimSpace(sessionID) != "" { - if _, err := s.repo.SessionForUser(userID, sessionID); err != nil { - return nil, err - } - } - storedName := newID() + "-" + filepath.Base(header.Filename) - path := filepath.Join(uploadDir, storedName) - dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640) - if err != nil { - return nil, err - } - size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1)) - closeErr := dst.Close() - if err != nil { - _ = os.Remove(path) - return nil, err - } - if closeErr != nil { - _ = os.Remove(path) - return nil, closeErr - } - if size > maxBytes { - _ = os.Remove(path) - return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) - } - item := model.SessionFile{ID: newID(), UserID: userID, SessionID: sessionID, FileName: header.Filename, MimeType: header.Header.Get("Content-Type"), Path: path, Size: size} - if err := s.repo.Create(&item); err != nil { - _ = os.Remove(path) - return nil, err - } - s.commitUserUploadQuota(userID, header.Size) - reserved = false - return &item, nil -} - func (s *Service) ProcessNextTask() error { - task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) - if err != nil || task == nil { - return err - } - return s.processClaimedTask(task) -} - -func (s *Service) processClaimedTask(task *model.Task) error { - _ = s.log(task.UserID, task.ID, "info", "后端任务开始处理", "") - policy, err := s.RuntimePolicy() - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task)) - defer cancel() - leaseDone := make(chan struct{}) - leaseLost := make(chan error, 1) - go func() { - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - if err := s.repo.RenewTaskLease(task.ID, s.workerID, 45*time.Second); err != nil { - leaseLost <- err - cancel() - return - } - case <-leaseDone: - return - } - } - }() - defer close(leaseDone) - s.registerActiveTask(task.ID, cancel) - defer s.unregisterActiveTask(task.ID) - - task.Stage = "调用生成模型" - task.Progress = 35 - _ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress) - routeAttempt, err := s.beginTaskRouteAttempt(task) - if err != nil { - task.Status = model.TaskStatusFailed - task.Stage = "路由准备失败" - task.Error = s.UserFacingErrorMessage(err) - task.CompletedAt = ptr(time.Now()) - _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) - if isRouteDispatchUncertain(err) { - _ = s.MarkBillingUncertain(task.BillingOrderID, task.Error) - } else { - _ = s.RefundBilling(task.BillingOrderID, "路由准备失败,上游请求未发出") - } - return err - } - if err := s.MarkBillingRunning(task.BillingOrderID); err != nil { - task.Status = model.TaskStatusFailed - task.Stage = "计费准备失败" - task.Error = s.UserFacingErrorMessage(err) - task.CompletedAt = ptr(time.Now()) - _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) - _ = s.RefundBilling(task.BillingOrderID, "计费准备失败,上游请求未发出") - return err - } - var result map[string]interface{} - var canvasOps []map[string]interface{} - for { - if dispatchErr := s.markRouteAttemptDispatching(routeAttempt); dispatchErr != nil { - err = dispatchErr - break - } - result, canvasOps, err = s.processTask(ctx, *task) - if stateErr := s.refreshTaskProviderState(task); stateErr != nil { - return stateErr - } - s.finishTaskRouteAttempt(routeAttempt, task, err) - if err == nil { - break - } - nextAttempt, routeErr := s.nextRouteAttemptAfterFailure(task, routeAttempt, err) - if routeErr != nil { - _ = s.log(task.UserID, task.ID, "warn", "备用路由不可用,保留原始失败", routeErr.Error()) - break - } - if nextAttempt == nil { - break - } - routeAttempt = nextAttempt - _ = s.log(task.UserID, task.ID, "warn", "上游未创建任务,切换备用能力路由", nextAttempt.RouteID) - } - providerSucceeded := err == nil - if err == nil { - result, err = s.persistGeneratedMediaResult(task.UserID, result) - } - if err == nil { - _, err = s.finalizeCharacterTurnaroundTask(*task, result) - } - if err != nil { - channelSlotFailedBeforeRequest := false - if code, _ := ChannelSlotFailureDetails(err); code != "" { - channelSlotFailedBeforeRequest = true - } - select { - case leaseErr := <-leaseLost: - _ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error()) - return leaseErr - default: - } - if errors.Is(err, context.Canceled) { - task.Status = model.TaskStatusCancelled - task.Stage = "任务已取消" - task.Error = "任务已取消" - task.CompletedAt = ptr(time.Now()) - _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) - if channelSlotFailedBeforeRequest { - _ = s.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出") - } else { - _ = s.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确") - } - _ = s.markSessionFailed(*task, "会话任务已取消。") - if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); compactErr != nil { - _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) - } - _ = s.log(task.UserID, task.ID, "warn", "任务已取消", "") - return nil - } - 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 - } - err = errors.New(taskTimeoutMessage(task.Type)) - } - task.Status = model.TaskStatusFailed - task.Stage = "任务失败" - task.Error = s.UserFacingErrorMessage(err) - task.CompletedAt = ptr(time.Now()) - _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) - if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusFailed); compactErr != nil { - _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) - } - if providerSucceeded || (!channelSlotFailedBeforeRequest && s.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err)) { - _ = s.MarkBillingUncertain(task.BillingOrderID, task.Error) - } else { - _ = s.RefundBilling(task.BillingOrderID, task.Error) - } - _ = s.markSessionFailed(*task, task.Error) - _ = s.log(task.UserID, task.ID, "error", "任务处理失败", task.Error) - return err - } - latest, err := s.repo.Task(task.ID) - if err != nil { - return err - } - if latest.Status == model.TaskStatusCancelled { - if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); compactErr != nil { - _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) - } - _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") - _ = s.markSessionFailed(*latest, "会话任务已取消。") - _ = s.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "") - return nil - } - resultJSON, _ := json.Marshal(result) - opsJSON, _ := json.Marshal(canvasOps) - task.Stage = "持久化生成结果" - task.Progress = 90 - _ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress) - if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, opsJSON, len(canvasOps) > 0); err != nil { - if errors.Is(err, repository.ErrTaskStateConflict) { - latest, latestErr := s.repo.Task(task.ID) - if latestErr == nil && latest.Status == model.TaskStatusCancelled { - _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") - _ = s.markSessionFailed(*latest, "会话任务已取消。") - _ = s.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "") - return nil - } - } - task.Status = model.TaskStatusFailed - task.Stage = "任务结果保存失败" - task.Error = s.UserFacingErrorMessage(err) - task.CompletedAt = ptr(time.Now()) - _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) - if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusFailed); compactErr != nil { - _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) - } - _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已成功但任务结果未保存:"+task.Error) - _ = s.markSessionFailed(*task, task.Error) - _ = s.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error) - return err - } - if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusSucceeded); compactErr != nil { - _ = s.log(task.UserID, task.ID, "error", "文本回放窗口更新失败", compactErr.Error()) - } - if completedTask, fetchErr := s.repo.Task(task.ID); fetchErr == nil { - if registerErr := s.RegisterTaskOutputFromTask(*completedTask); registerErr != nil { - // 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。 - _ = s.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error()) - } - } - if err := s.SettleBilling(task.BillingOrderID, ""); err != nil { - _ = s.MarkBillingUncertain(task.BillingOrderID, "生成成功但积分结算失败:"+err.Error()) - _ = s.log(task.UserID, task.ID, "error", "积分结算失败,已进入待核对", err.Error()) - } - _ = s.log(task.UserID, task.ID, "info", "任务完成,结果已持久化", "") - return nil -} - -func taskFailureMessage(err error) string { - if err == nil { - return "任务处理失败" - } - return truncateRunes(err.Error(), 2_000) -} - -func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration { - switch { - case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows": - return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute - case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"): - return max(time.Duration(policy.VideoTimeoutMinutes)*time.Minute, 5*time.Minute) - case strings.HasPrefix(taskType, "canvas_image"): - return time.Duration(policy.ImageTimeoutMinutes) * time.Minute - case strings.HasPrefix(taskType, "canvas_audio"): - return time.Duration(policy.AudioTimeoutMinutes) * time.Minute - case strings.HasPrefix(taskType, "canvas_text"): - return time.Duration(policy.TextTimeoutMinutes) * time.Minute - default: - return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute - } -} - -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_")) { - return false - } - var input canvasGenerationInput - if json.Unmarshal([]byte(decryptedInput), &input) != nil { - return false - } - resolved, resolveErr := s.resolveProviderConfig(input.Config) - return resolveErr == nil && resolved.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) -} - -func taskTimeoutMessage(taskType string) string { - if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") { - return "视频生成等待超时,请稍后到任务中心查看或重试。" - } - if strings.HasPrefix(taskType, "canvas_image") { - return "图片生成等待超时,请稍后重试。" - } - return "任务执行超时,请稍后重试。" -} - -func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { - decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) - if err != nil { - return nil, nil, err - } - task.InputJSON = decryptedInput - ctx = withProviderAnalytics(ctx, s, task) - if task.Type == "agent_storyboard_rows" { - return s.processStoryboardRowsTask(ctx, task) - } - if strings.HasPrefix(task.Type, "canvas_") || canRunProviderTask(task) { - result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) - return result, nil, err - } - if task.Type == "agent_storyboard" { - return s.processAgentStoryboardTask(ctx, task) - } - if strings.HasPrefix(task.Type, "video_") { - result, ops := buildVideoWorkflowResult(task) - return result, ops, nil - } - result, ops := buildAgentResult(task) - return result, ops, nil -} - -func canRunProviderTask(task model.Task) bool { - if !strings.HasPrefix(task.Type, "video_") || strings.TrimSpace(task.InputJSON) == "" { - return false - } - var input map[string]any - if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { - return false - } - mode, _ := input["mode"].(string) - config, ok := input["config"].(map[string]any) - if mode != "video" || !ok || strings.TrimSpace(fmt.Sprint(config["model"])) == "" { - return false - } - return strings.TrimSpace(fmt.Sprint(config["channelId"])) != "" || (strings.TrimSpace(fmt.Sprint(config["baseUrl"])) != "" && strings.TrimSpace(fmt.Sprint(config["apiKey"])) != "") -} - -func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { - input := agentStoryboardInput{} - if strings.TrimSpace(task.InputJSON) != "" { - if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { - return nil, nil, fmt.Errorf("Agent 会话输入解析失败:%w", err) - } - } - if !providerConfigReady(input.Config) { - return nil, nil, errors.New("请先配置可用的文本模型") - } - if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { - return nil, nil, err - } - assets := input.CanvasAssets - if len(assets) == 0 { - assets = extractStoryboardAssets(input.CanvasSnapshot) - } - config, err := s.resolveProviderConfig(input.Config) - if err != nil { - return nil, nil, err - } - ctx = withProviderOutboundPolicy(ctx, config) - plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, 0, 0) - if err != nil { - return nil, nil, err - } - result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true}) - if err != nil { - return nil, nil, err - } - text, _ := result["text"].(string) - plan, err := parseAgentStoryboardPlan(text) - if err == nil { - normalizeAutomaticStoryboardDurations(&plan, 0) - err = validateStoryboardPlan(plan, 0, 0, input.Characters) - } - if err != nil { - plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, 0, 0) - if err != nil { - return nil, nil, err - } - } - if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { - _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) - } - return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle) -} - -func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { - input := agentStoryboardInput{} - if strings.TrimSpace(task.InputJSON) != "" { - if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { - return nil, nil, fmt.Errorf("脚本任务输入解析失败:%w", err) - } - } - if !providerConfigReady(input.Config) { - return nil, nil, errors.New("请先配置可用的文本模型") - } - if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { - return nil, nil, err - } - assets := input.CanvasAssets - if len(assets) == 0 { - assets = extractStoryboardAssets(input.CanvasSnapshot) - } - config, err := s.resolveProviderConfig(input.Config) - if err != nil { - return nil, nil, err - } - ctx = withProviderOutboundPolicy(ctx, config) - plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount) - if err != nil { - return nil, nil, err - } - result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true}) - if err != nil { - return nil, nil, err - } - text, _ := result["text"].(string) - plan, err := parseAgentStoryboardPlan(text) - if err == nil { - normalizeAutomaticStoryboardDurations(&plan, input.ShotDuration) - err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters) - } - if err != nil { - plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, input.ShotDuration, input.ShotCount) - if err != nil { - return nil, nil, err - } - } - if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { - _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) - } - rows := make([]map[string]any, 0, len(plan.Shots)) - for index, shot := range plan.Shots { - imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) - videoPromptVariables := storyboardVideoPromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) - imagePrompt, promptErr := s.compileStoryboardImagePrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) - if promptErr != nil { - return nil, nil, promptErr - } - videoPrompt, promptErr := s.compileStoryboardVideoPrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) - if promptErr != nil { - return nil, nil, promptErr - } - matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) - referenceNodeIDs := make([]string, 0, len(matchedAssets)) - for _, asset := range matchedAssets { - referenceNodeIDs = append(referenceNodeIDs, asset.ID) - } - rows = append(rows, map[string]any{ - "shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description, - "dialogue": shot.Dialogue, "characters": storyboardRowCharacters(shot, input.Characters), "shotSize": shot.ShotSize, "emotion": shot.Emotion, - "lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects, - "imageGenerationPrompt": imagePrompt, "videoMotionPrompt": videoPrompt, - "imagePromptTemplateVariables": imagePromptVariables, "videoPromptTemplateVariables": videoPromptVariables, - "camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative, - "narrativeIntent": shot.Intent, "viewerPOV": shot.ViewerPOV, "performanceBlocking": shot.Performance, - "mustHave": shot.MustHave, "optionalDetails": shot.Optional, "continuityOut": shot.ContinuityOut, - "referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags, - }) - } - return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil -} - -const maxStoryboardRepairAttempts = 2 - -func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, config providerConfig, originalText string, validationErr error, shotDuration int, shotCount int) (agentStoryboardPlan, error) { - currentText := originalText - currentErr := validationErr - for attempt := 1; attempt <= maxStoryboardRepairAttempts; attempt++ { - _ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55+attempt*10) - repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, task.Prompt, currentErr, input, currentText) - if promptErr != nil { - return agentStoryboardPlan{}, promptErr - } - repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true}) - if repairErr != nil { - return agentStoryboardPlan{}, fmt.Errorf("分镜结构修复失败:%w", repairErr) - } - repairedText, _ := repaired["text"].(string) - plan, parseErr := parseAgentStoryboardPlan(repairedText) - if parseErr == nil { - normalizeAutomaticStoryboardDurations(&plan, shotDuration) - parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters) - } - if parseErr == nil { - return plan, nil - } - currentText = repairedText - currentErr = parseErr - } - return agentStoryboardPlan{}, fmt.Errorf("分镜模型结构修复后仍不合法:%w", currentErr) -} - -func providerConfigReady(config providerConfig) bool { - return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != "")) -} - -func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { - jsonText, err := extractJSONText(raw) - if err != nil { - return agentStoryboardPlan{}, err - } - if err := validateStoryboardJSONFields(jsonText); err != nil { - return agentStoryboardPlan{}, err - } - var plan agentStoryboardPlan - if err := json.Unmarshal([]byte(jsonText), &plan); err != nil { - return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err) - } - plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜") - plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案") - plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "严格沿用当前项目画风,保持角色、空间、道具、色彩和视觉媒介一致。") - if len(plan.Shots) == 0 { - return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots") - } - if len(plan.Shots) > 12 { - return agentStoryboardPlan{}, fmt.Errorf("分镜数量最多 12 个,实际返回 %d 个", len(plan.Shots)) - } - for i := range plan.Shots { - if strings.TrimSpace(plan.Shots[i].Title) == "" { - plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1) - } - plan.Shots[i].CharacterIDs = nonNilStrings(plan.Shots[i].CharacterIDs) - plan.Shots[i].AssetTags = nonNilStrings(plan.Shots[i].AssetTags) - plan.Shots[i].Optional = nonNilStrings(plan.Shots[i].Optional) - plan.Shots[i].Intent = defaultString(strings.TrimSpace(plan.Shots[i].Intent), strings.TrimSpace(plan.Shots[i].Description)) - plan.Shots[i].ViewerPOV = defaultString(strings.TrimSpace(plan.Shots[i].ViewerPOV), "客观观察当前主要角色与事件") - plan.Shots[i].Performance = defaultString(strings.TrimSpace(plan.Shots[i].Performance), strings.TrimSpace(plan.Shots[i].Description)) - plan.Shots[i].ContinuityOut = defaultString(strings.TrimSpace(plan.Shots[i].ContinuityOut), "保持本镜头结尾的人物位置、动作状态、道具和光线方向进入下一镜") - if len(plan.Shots[i].MustHave) == 0 { - plan.Shots[i].MustHave = []string{"主要角色身份与当前版本稳定", "主要动作完成并有清晰落点", "结尾状态可供下一镜继承"} - } - if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" { - plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description) - } - if strings.TrimSpace(plan.Shots[i].VisualPrompt) == "" { - return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 visualPrompt", i+1) - } - if strings.TrimSpace(plan.Shots[i].Camera) == "" || strings.TrimSpace(plan.Shots[i].Motion) == "" || strings.TrimSpace(plan.Shots[i].TimeBeats) == "" { - return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 camera、motion 或 timeBeats", i+1) - } - if plan.Shots[i].Duration <= 0 || plan.Shots[i].Duration > 60 { - return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 的 durationSeconds 必须在 1 到 60 之间", i+1) - } - } - return plan, nil -} - -func validateStoryboardJSONFields(jsonText string) error { - var root map[string]json.RawMessage - if err := json.Unmarshal([]byte(jsonText), &root); err != nil { - return fmt.Errorf("分镜 JSON 解析失败:%w", err) - } - for _, field := range []string{"title", "logline", "styleGuide", "characters", "locations", "shots"} { - if _, ok := root[field]; !ok { - return fmt.Errorf("分镜 JSON 缺少受保护字段 %s", field) - } - } - var shots []map[string]json.RawMessage - if err := json.Unmarshal(root["shots"], &shots); err != nil { - return errors.New("分镜 JSON 的 shots 必须是数组") - } - required := []string{"title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetTags"} - for index, shot := range shots { - for _, field := range required { - if _, ok := shot[field]; !ok { - return fmt.Errorf("镜头 %d 缺少受保护字段 %s", index+1, field) - } - } - } - return nil -} - -func nonNilStrings(values []string) []string { - if values == nil { - return []string{} - } - return values -} - -func validateStoryboardContext(projectStyle storyboardProjectStyle, characters []storyboardCharacterCard) error { - if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" { - return errors.New("请先设置项目画风,再生成分镜") - } - if strings.TrimSpace(projectStyle.ProfileJSON) != "" { - if _, err := validateStyleProfileJSON(projectStyle.ProfileJSON); err != nil { - return err - } - } - for _, character := range characters { - if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { - return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") - } - } - return nil -} - -func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error { - if target == 0 { - return nil - } - if target != 5 && target != 10 && target != 15 && target != 30 { - return fmt.Errorf("不支持的单镜头时长:%d 秒", target) - } - for index, shot := range plan.Shots { - if shot.Duration != target { - return fmt.Errorf("镜头 %d 的时长必须是 %d 秒", index+1, target) - } - } - return nil -} - -func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard) error { - if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 { - return errors.New("styleGuide 最多 120 个中文字符") - } - if err := validateStoryboardShotDuration(plan, shotDuration); err != nil { - return err - } - if err := validateStoryboardShotCount(plan, shotCount); err != nil { - return err - } - if err := validateStoryboardCharacterIDs(plan, characters); err != nil { - return err - } - return nil -} - -func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { - if target == 0 { - return nil - } - if target < 1 || target > 10 { - return fmt.Errorf("分镜数量必须在 1 到 10 之间") - } - if len(plan.Shots) != target { - return fmt.Errorf("分镜数量必须是 %d,实际生成 %d", target, len(plan.Shots)) - } - return nil -} - -func validateStoryboardComplexity(plan agentStoryboardPlan) error { - issues := make([]string, 0) - for index, shot := range plan.Shots { - shotNumber := index + 1 - if len(shot.CharacterIDs) > 2 { - issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, len(shot.CharacterIDs))) - } - if len(shot.MustHave) > 3 { - issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave))) - } - if beats := storyboardBeatCount(shot.TimeBeats); beats > 3 { - issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个时间节拍,最多 3 个", shotNumber, beats)) - } - if movements := storyboardCameraMovementCount(shot.Motion); movements > 1 { - issues = append(issues, fmt.Sprintf("镜头 %d 包含 %d 种主运镜,最多 1 种", shotNumber, movements)) - } - dialogueLimit := max(24, shot.Duration*5) - if dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)); dialogueLength > dialogueLimit { - issues = append(issues, fmt.Sprintf("镜头 %d 台词 %d 字,%d 秒镜头最多约 %d 字", shotNumber, dialogueLength, shot.Duration, dialogueLimit)) - } - } - if len(issues) == 0 { - return nil - } - return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";")) -} - -func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int) { - if plan == nil || target != 0 { - return - } - for index := range plan.Shots { - shot := &plan.Shots[index] - dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)) - requiredDuration := (dialogueLength + 4) / 5 - shot.Duration = min(60, max(1, shot.Duration, requiredDuration)) - } -} - -func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { - allowed := make(map[string]bool, len(characters)) - for _, character := range characters { - allowed[character.AssetID] = true - } - for index, shot := range plan.Shots { - for _, assetID := range shot.CharacterIDs { - if !allowed[assetID] { - return fmt.Errorf("镜头 %d 引用了不存在或非当前版本的角色 assetId:%s", index+1, assetID) - } - } - } - return nil -} - -func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any { - byID := make(map[string]storyboardCharacterCard, len(characters)) - for _, character := range characters { - byID[character.AssetID] = character - } - result := make([]map[string]any, 0, len(shot.CharacterIDs)) - for _, assetID := range shot.CharacterIDs { - character, ok := byID[assetID] - if !ok { - continue - } - result = append(result, map[string]any{ - "characterName": character.Name, - "characterAssetId": character.AssetID, - "characterVersionId": character.VersionID, - }) - } - return result -} - -func storyboardBeatCount(value string) int { - parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' }) - count := 0 - for _, part := range parts { - if strings.TrimSpace(part) != "" { - count++ - } - } - timecodeCount := len(storyboardTimecodePattern.FindAllString(value, -1)) - return max(count, timecodeCount) -} - -var storyboardTimecodePattern = regexp.MustCompile(`\d+(?:\.\d+)?\s*[-~—至到]\s*\d+(?:\.\d+)?\s*秒`) - -func storyboardCameraMovementCount(value string) int { - movements := []string{"推进", "推近", "拉远", "摇摄", "横移", "侧移", "跟拍", "跟随", "升降", "上升", "下降", "环绕", "俯冲", "变焦", "甩镜", "穿越"} - count := 0 - for _, movement := range movements { - if strings.Contains(value, movement) { - count++ - } - } - return count -} - -func extractJSONText(raw string) (string, error) { - // Text models may prepend an explanation or append a Markdown fence despite - // the JSON-only contract. Scan complete JSON values instead of pairing the - // first opening brace with the final closing brace, which can merge prose and - // make an otherwise valid character breakdown fail validation. - for start := 0; start < len(raw); start++ { - if raw[start] != '{' && raw[start] != '[' { - continue - } - end := jsonValueEnd(raw, start) - if end < start { - continue - } - candidate := raw[start : end+1] - var decoded interface{} - if json.Unmarshal([]byte(candidate), &decoded) == nil { - return candidate, nil - } - } - return "", errors.New("模型返回的不是 JSON") -} - -func jsonValueEnd(source string, start int) int { - stack := make([]byte, 0, 8) - inString := false - escaped := false - for index := start; index < len(source); index++ { - value := source[index] - if inString { - if escaped { - escaped = false - } else if value == '\\' { - escaped = true - } else if value == '"' { - inString = false - } - continue - } - switch value { - case '"': - inString = true - case '{', '[': - stack = append(stack, value) - case '}', ']': - if len(stack) == 0 { - return -1 - } - opener := stack[len(stack)-1] - if (value == '}' && opener != '{') || (value == ']' && opener != '[') { - return -1 - } - stack = stack[:len(stack)-1] - if len(stack) == 0 { - return index - } - } - } - return -1 -} - -func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset, projectStyle storyboardProjectStyle) (map[string]interface{}, []map[string]interface{}, error) { - prefix := "agent-" + task.ID - scriptID := prefix + "-script" - sceneID := prefix + "-scenes" - styleID := prefix + "-style" - referenceID := prefix + "-assets" - finalID := prefix + "-final" - sceneX := 380 - styleX := sceneX + 380 - ops := []map[string]any{ - nodeOpWithMetadata(scriptID, "text", "剧本 · "+shortTitle(plan.Title, 24), 0, 0, map[string]any{"workflowKind": "script", "workflowTitle": "剧本", "status": "success", "content": strings.Join([]string{plan.Title, "", plan.Logline, "", task.Prompt}, "\n")}), - nodeOpWithMetadata(sceneID, "text", "场景设定", sceneX, 0, map[string]any{"workflowKind": "scene", "workflowTitle": "场景", "status": "success", "content": listContent("场景", plan.Locations)}), - nodeOpWithMetadata(styleID, "text", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "styleProfileJson": projectStyle.ProfileJSON, "status": "success", "content": projectStyle.Prompt, "prompt": projectStyle.Prompt}), - nodeOpWithMetadata(referenceID, "text", "参考素材组", 0, 270, map[string]any{"workflowKind": "reference_set", "workflowTitle": "参考素材组", "status": "success", "content": storyboardAssetsContent(assets)}), - nodeOpWithMetadata(finalID, "video", "成片 · 待生成", styleX, 270, map[string]any{"workflowKind": "final", "workflowTitle": "成片", "status": "idle"}), - connectOp(scriptID, sceneID), - } - resultShots := make([]map[string]any, 0, len(plan.Shots)) - for index, shot := range plan.Shots { - videoPrompt, err := s.compileStoryboardVideoPrompt(task.UserID, projectStyle.Prompt, plan.StyleGuide, shot) - if err != nil { - return nil, nil, err - } - shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1) - matchedAssets := matchStoryboardAssetsForShot(assets, shot) - assetIDs := make([]string, 0, len(matchedAssets)) - for _, asset := range matchedAssets { - assetIDs = append(assetIDs, asset.ID) - } - ops = append(ops, - nodeOpWithMetadata(shotID, "video", fmt.Sprintf("镜头 %d · %s", index+1, shortTitle(shot.Title, 18)), index*360, 560, map[string]any{ - "workflowKind": "shot", - "workflowTitle": shot.Title, - "workflowDescription": shotDescription(shot), - "shotIndex": index + 1, - "generationMode": "video", - "prompt": videoPrompt, - "composerContent": shotComposerContent(videoPrompt, matchedAssets), - "videoEditOperation": "text_to_video", - "assetTags": shot.AssetTags, - "referenceAssetNodeIds": assetIDs, - "status": "idle", - }), - connectOp(scriptID, shotID), - connectOp(shotID, finalID), - ) - for _, asset := range matchedAssets { - ops = append(ops, connectOp(asset.ID, shotID)) - } - resultShots = append(resultShots, map[string]any{"title": shot.Title, "description": shot.Description, "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs}) - } - ops = append(ops, map[string]any{"type": "select_nodes", "ids": shotIDs(prefix, len(plan.Shots))}) - result := map[string]any{ - "taskId": task.ID, - "operation": task.Operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "title": plan.Title, - "logline": plan.Logline, - "styleGuide": plan.StyleGuide, - "characters": plan.Characters, - "locations": plan.Locations, - "shots": resultShots, - } - return result, ops, nil -} - -func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset { - rawNodes, _ := snapshot["nodes"].([]interface{}) - assets := make([]storyboardAsset, 0, len(rawNodes)) - for _, raw := range rawNodes { - node, _ := raw.(map[string]interface{}) - if node == nil { - continue - } - metadata, _ := node["metadata"].(map[string]interface{}) - if metadata == nil { - metadata = map[string]interface{}{} - } - nodeType := stringValue(node["type"]) - isCharacterCard := stringValue(metadata["workflowKind"]) == "character" && stringValue(metadata["characterAssetId"]) != "" && stringValue(metadata["characterVersionId"]) != "" - if nodeType != "image" && !isCharacterCard { - continue - } - id := stringValue(node["id"]) - if id == "" { - continue - } - tags := stringSlice(metadata["assetTags"]) - prompt := stringValue(metadata["prompt"]) - content := stringValue(metadata["content"]) - if len(tags) == 0 && prompt == "" && content == "" && !isCharacterCard { - continue - } - assets = append(assets, storyboardAsset{ - ID: id, - Title: defaultString(stringValue(node["title"]), "未命名图片"), - Type: defaultString(nodeType, "reference"), - Tags: tags, - Prompt: prompt, - CharacterAssetID: stringValue(metadata["characterAssetId"]), - CharacterVersionID: stringValue(metadata["characterVersionId"]), - }) - if len(assets) >= 30 { - break - } - } - return assets -} - -func matchStoryboardAssets(assets []storyboardAsset, shotTags []string) []storyboardAsset { - wanted := map[string]bool{} - for _, tag := range shotTags { - for _, token := range storyboardTagTokens(tag) { - wanted[token] = true - } - } - if len(wanted) == 0 { - return nil - } - matched := make([]storyboardAsset, 0) - for _, asset := range assets { - tokens := map[string]bool{} - for _, token := range storyboardTagTokens(asset.Title) { - tokens[token] = true - } - for _, tag := range asset.Tags { - for _, token := range storyboardTagTokens(tag) { - tokens[token] = true - } - } - if storyboardTokensMatch(wanted, tokens) { - matched = append(matched, asset) - } - if len(matched) >= 6 { - break - } - } - return matched -} - -func matchStoryboardAssetsForShot(assets []storyboardAsset, shot agentStoryboardShot) []storyboardAsset { - matched := make([]storyboardAsset, 0, 6) - seen := make(map[string]bool, 6) - wantedCharacters := make(map[string]bool, len(shot.CharacterIDs)) - for _, assetID := range shot.CharacterIDs { - wantedCharacters[assetID] = true - } - for _, asset := range assets { - if len(matched) >= 6 { - break - } - if !seen[asset.ID] && wantedCharacters[asset.CharacterAssetID] { - matched = append(matched, asset) - seen[asset.ID] = true - } - } - for _, asset := range matchStoryboardAssets(assets, shot.AssetTags) { - if len(matched) >= 6 { - break - } - if !seen[asset.ID] { - matched = append(matched, asset) - seen[asset.ID] = true - } - } - return matched -} - -func storyboardTokensMatch(wanted map[string]bool, tokens map[string]bool) bool { - for want := range wanted { - if tokens[want] { - return true - } - for token := range tokens { - if meaningfulStoryboardTagToken(want) && meaningfulStoryboardTagToken(token) && (strings.Contains(token, want) || strings.Contains(want, token)) { - return true - } - } - } - return false -} - -func storyboardTagTokens(value string) []string { - normalized := strings.ToLower(strings.ReplaceAll(strings.Join(strings.Fields(strings.ReplaceAll(value, ":", ":")), ""), ",", ",")) - if normalized == "" { - return nil - } - tokens := []string{normalized} - if index := strings.Index(normalized, ":"); index >= 0 { - tokens = append(tokens, normalized[index+1:]) - } - unique := make([]string, 0, len(tokens)) - seen := map[string]bool{} - for _, token := range tokens { - if meaningfulStoryboardTagToken(token) && !seen[token] { - seen[token] = true - unique = append(unique, token) - } - } - return unique -} - -func meaningfulStoryboardTagToken(value string) bool { - if len([]rune(value)) < 2 { - return false - } - switch value { - case "角色", "环境", "场景", "道具", "武器", "风格": - return false - } - return true -} - -func listContent(title string, items []string) string { - if len(items) == 0 { - return title + "\n\n- 暂无明确内容。" - } - lines := []string{title, ""} - for _, item := range items { - if strings.TrimSpace(item) != "" { - lines = append(lines, "- "+item) - } - } - return strings.Join(lines, "\n") -} - -func storyboardAssetsContent(assets []storyboardAsset) string { - if len(assets) == 0 { - return "当前画布暂无可用图片资产。建议先给角色、环境、道具图片添加资产标签。" - } - lines := make([]string, 0, len(assets)) - for _, asset := range assets { - line := asset.Title + "\nID: " + asset.ID - if len(asset.Tags) > 0 { - line += "\n标签: " + strings.Join(asset.Tags, "、") - } - if asset.Prompt != "" { - line += "\n原提示词: " + asset.Prompt - } - lines = append(lines, line) - } - return strings.Join(lines, "\n\n") -} - -func shotDescription(shot agentStoryboardShot) string { - parts := []string{shot.Description} - if strings.TrimSpace(shot.VisualPrompt) != "" { - parts = append(parts, "画面提示词:"+shot.VisualPrompt) - } - if strings.TrimSpace(shot.Camera) != "" { - parts = append(parts, "镜头:"+shot.Camera) - } - if strings.TrimSpace(shot.Motion) != "" { - parts = append(parts, "运动:"+shot.Motion) - } - if strings.TrimSpace(shot.TimeBeats) != "" { - parts = append(parts, "时间节拍:"+shot.TimeBeats) - } - filtered := make([]string, 0, len(parts)) - for _, part := range parts { - if strings.TrimSpace(part) != "" { - filtered = append(filtered, part) - } - } - return strings.Join(filtered, "\n\n") -} - -func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { - negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") - return map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), - "表演起始状态": compactPromptText(shot.Performance, 180), - "负面要求": compactPromptText(negative, 140), - } -} - -func buildStoryboardImagePrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { - definition, _ := promptDefinition(promptOperationStoryboardFirstFrame) - prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardImagePromptValues(projectStyle, styleGuide, shot)) - return prompt -} - -func (s *Service) compileStoryboardImagePrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { - compiled, err := s.compilePrompt(userID, promptOperationStoryboardFirstFrame, storyboardImagePromptValues(projectStyle, styleGuide, shot)) - return compiled.Content, err -} - -func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { - camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次") - motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作") - timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) - negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") - values := map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), - "表演与调度": compactPromptText(shot.Performance, 180), - "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), - "时间节拍": compactPromptText(timeBeats, 240), - "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), - "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), - "负面要求": compactPromptText(negative, 160), - } - if len(shot.MustHave) > 0 { - priority := "必须完成:" + strings.Join(shot.MustHave, ";") - if len(shot.Optional) > 0 { - priority += "。可以简化:" + strings.Join(shot.Optional, ";") - } - values["执行优先级"] = compactPromptText(priority, 140) - } - return values -} - -func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { - definition, _ := promptDefinition(promptOperationStoryboardVideo) - prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) - return prompt -} - -func (s *Service) compileStoryboardVideoPrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { - compiled, err := s.compilePrompt(userID, promptOperationStoryboardVideo, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) - return compiled.Content, err -} - -func storyboardProjectVisualSummary(projectStyle string, styleGuide string) string { - identity := "" - for _, line := range strings.Split(projectStyle, "\n") { - if strings.TrimSpace(line) != "" { - identity = strings.TrimSpace(line) - break - } - } - parts := make([]string, 0, 2) - if identity != "" { - parts = append(parts, identity) - } - if strings.TrimSpace(styleGuide) != "" { - parts = append(parts, strings.TrimSpace(styleGuide)) - } - return compactPromptText(strings.Join(parts, ";"), 180) -} - -func compactPromptText(value string, limit int) string { - text := strings.TrimSpace(value) - if utf8.RuneCountInString(text) <= limit { - return text - } - runes := []rune(text) - return strings.TrimSpace(string(runes[:limit])) + "。" -} - -func shotComposerContent(prompt string, assets []storyboardAsset) string { - if len(assets) == 0 { - return prompt - } - lines := []string{"参考素材:"} - for _, asset := range assets { - label := asset.Title - if len(asset.Tags) > 0 { - label += "(" + strings.Join(asset.Tags, "、") + ")" - } - lines = append(lines, "- "+label+":@[node:"+asset.ID+"]") - } - lines = append(lines, "", "分镜视频提示词:", prompt) - return strings.Join(lines, "\n") + return s.taskWorker().processNextTask() } func shotIDs(prefix string, count int) []string { diff --git a/backend/internal/service/service_workflow_results.go b/backend/internal/service/service_workflow_results.go deleted file mode 100644 index fb9f4c9ba..000000000 --- a/backend/internal/service/service_workflow_results.go +++ /dev/null @@ -1,65 +0,0 @@ -package service - -import ( - "strings" - - "infinite-canvas/backend/internal/model" -) - -func buildAgentResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - result := map[string]any{ - "taskId": task.ID, - "operation": task.Operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "script", "title": "创意脚本", "content": task.Prompt}, - {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, - {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, - {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), - nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), - nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), - nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), - connectOp("script-"+task.ID, "scene-"+task.ID), - connectOp("scene-"+task.ID, "shot-"+task.ID), - connectOp("shot-"+task.ID, "final-"+task.ID), - } - return result, ops -} - -func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) - result := map[string]any{ - "taskId": task.ID, - "operation": operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, - {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, - {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), - nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), - nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), - nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), - connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), - connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), - connectOp("video-shot-"+task.ID, "video-result-"+task.ID), - } - return result, ops -} diff --git a/backend/internal/service/session_creation.go b/backend/internal/service/session_creation.go new file mode 100644 index 000000000..1bf71c20d --- /dev/null +++ b/backend/internal/service/session_creation.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// sessionCreationCoordinator 负责会话草稿、首条消息和首个 storyboard 任务的一致性补偿。 +// 会话详情读取与文件上传不放在这里,避免创建流程继续膨胀成通用 session service。 +type sessionCreationCoordinator struct { + service *Service +} + +func newSessionCreationCoordinator(service *Service) *sessionCreationCoordinator { + return &sessionCreationCoordinator{service: service} +} + +func (s *Service) sessionCreation() *sessionCreationCoordinator { + if s.sessionCreationCoordinator != nil { + return s.sessionCreationCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持内部测试和工具兼容。 + return newSessionCreationCoordinator(s) +} + +func (w *sessionCreationCoordinator) create(userID string, req CreateSessionRequest) (*SessionDetail, error) { + s := w.service + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return nil, errors.New("prompt is required") + } + if err := validateStoryboardContext(req.ProjectStyle, req.Characters); err != nil { + return nil, err + } + compactedSnapshot := compactPersistedValue(req.CanvasSnapshot) + snapshotJSON, err := json.Marshal(compactedSnapshot) + if err != nil { + return nil, fmt.Errorf("序列化画布快照失败:%w", err) + } + session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)} + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + if err := w.persistDraft(userID, prompt, snapshotJSON, session, policy); err != nil { + return nil, err + } + taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, LogicalModelID: req.LogicalModelID, Input: map[string]any{"mode": "text", "references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "projectStyle": req.ProjectStyle, "characters": req.Characters, "config": req.Config}} + if _, err := s.CreateTask(userID, taskReq); err != nil { + if cleanupErr := w.deleteDraft(userID, session.ID); cleanupErr != nil { + return nil, fmt.Errorf("创建会话任务失败:%v;清理会话失败:%w", err, cleanupErr) + } + return nil, err + } + s.recordActivity(userID, "agent_message", 1) + return s.SessionDetail(userID, session.ID) +} + +func (w *sessionCreationCoordinator) persistDraft(userID string, prompt string, snapshotJSON []byte, session model.Session, policy RuntimePolicySetting) error { + s := w.service + s.storageMu.Lock() + defer s.storageMu.Unlock() + usage, err := s.repo.UserStorageUsage(userID) + if err != nil { + return err + } + incomingBytes := int64(len([]byte(prompt))*2 + len(snapshotJSON)) + if err := validateStructuredStorageQuotaWithPolicy(usage, "session", true, incomingBytes, policy.Resource); err != nil { + return err + } + if err := s.repo.Create(&session); err != nil { + return err + } + if err := s.repo.Create(&model.Message{ID: newID(), UserID: userID, SessionID: session.ID, Role: "user", Content: prompt}); err != nil { + if cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID); cleanupErr != nil { + return fmt.Errorf("创建会话消息失败:%v;清理会话失败:%w", err, cleanupErr) + } + return err + } + return nil +} + +func (w *sessionCreationCoordinator) deleteDraft(userID string, sessionID string) error { + s := w.service + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.repo.DeleteSessionDraft(userID, sessionID) +} diff --git a/backend/internal/service/session_upload.go b/backend/internal/service/session_upload.go new file mode 100644 index 000000000..e11f5ef98 --- /dev/null +++ b/backend/internal/service/session_upload.go @@ -0,0 +1,129 @@ +package service + +import ( + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// sessionUploadCoordinator 只负责会话附件的文件落盘和资源额度生命周期。 +// +// 会话创建、任务排队和素材/OSS 资源上传是不同的写路径。把附件写入从 Service +// 主流程移出后,文件系统失败、会话归属校验和额度回滚不再和任务 worker 共用一段 +// 巨型方法,也便于用内存仓储覆盖失败分支。 +type sessionUploadCoordinator struct { + repo sessionUploadRepository + dataDir string + runtimePolicy func() (RuntimePolicySetting, error) + reserveQuota func(userID string, size int64) (string, error) + releaseQuota func(userID string, day string, size int64) + commitQuota func(userID string, size int64) +} + +type sessionUploadRepository interface { + SessionForUser(userID string, id string) (*model.Session, error) + Create(value any) error +} + +func newSessionUploadCoordinator(s *Service) *sessionUploadCoordinator { + return &sessionUploadCoordinator{ + repo: s.repo, + dataDir: s.dataDir, + runtimePolicy: s.RuntimePolicy, + reserveQuota: s.reserveSessionUploadQuota, + releaseQuota: s.releaseUserUploadQuota, + commitQuota: s.commitUserUploadQuota, + } +} + +func (s *Service) sessionUpload() *sessionUploadCoordinator { + if s.sessionUploadCoordinator != nil { + return s.sessionUploadCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 + return newSessionUploadCoordinator(s) +} + +func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) { + return s.sessionUpload().Store(userID, sessionID, header) +} + +func (c *sessionUploadCoordinator) Store(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) { + policy, err := c.runtimePolicy() + if err != nil { + return nil, err + } + maxBytes := megabytes(policy.Resource.SessionUploadMB) + if header == nil || header.Size > maxBytes { + return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) + } + day, err := c.reserveQuota(userID, header.Size) + if err != nil { + return nil, err + } + reserved := true + defer func() { + if reserved { + c.releaseQuota(userID, day, header.Size) + } + }() + + file, err := header.Open() + if err != nil { + return nil, err + } + defer file.Close() + + uploadDir := filepath.Join(c.dataDir, "uploads") + if err := os.MkdirAll(uploadDir, 0o750); err != nil { + return nil, err + } + if strings.TrimSpace(sessionID) != "" { + if _, err := c.repo.SessionForUser(userID, sessionID); err != nil { + return nil, err + } + } + + storedName := newID() + "-" + filepath.Base(header.Filename) + path := filepath.Join(uploadDir, storedName) + dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640) + if err != nil { + return nil, err + } + size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1)) + closeErr := dst.Close() + if err != nil { + _ = os.Remove(path) + return nil, err + } + if closeErr != nil { + _ = os.Remove(path) + return nil, closeErr + } + if size > maxBytes { + _ = os.Remove(path) + return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) + } + + item := model.SessionFile{ + ID: newID(), + UserID: userID, + SessionID: sessionID, + FileName: header.Filename, + MimeType: header.Header.Get("Content-Type"), + Path: path, + Size: size, + } + if err := c.repo.Create(&item); err != nil { + _ = os.Remove(path) + return nil, err + } + c.commitQuota(userID, header.Size) + reserved = false + return &item, nil +} diff --git a/backend/internal/service/storyboard.go b/backend/internal/service/storyboard.go new file mode 100644 index 000000000..c3e49c2b4 --- /dev/null +++ b/backend/internal/service/storyboard.go @@ -0,0 +1,675 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +type agentStoryboardInput struct { + References []string `json:"references"` + CanvasSnapshot map[string]any `json:"canvasSnapshot"` + Requirements string `json:"requirements"` + CanvasAssets []storyboardAsset `json:"canvasAssets"` + ProjectStyle storyboardProjectStyle `json:"projectStyle"` + Characters []storyboardCharacterCard `json:"characters"` + Config providerConfig `json:"config"` + ShotDuration int `json:"shotDurationSeconds"` + ShotCount int `json:"shotCount"` +} + +type storyboardProjectStyle struct { + PresetID string `json:"presetId"` + Title string `json:"title"` + Prompt string `json:"prompt"` + ProfileJSON string `json:"profileJson,omitempty"` +} + +type storyboardCharacterCard struct { + AssetID string `json:"assetId"` + VersionID string `json:"versionId"` + Name string `json:"name"` + Definition map[string]any `json:"definition"` +} + +type storyboardAsset struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Tags []string `json:"tags"` + Prompt string `json:"prompt"` + CharacterAssetID string `json:"characterAssetId,omitempty"` + CharacterVersionID string `json:"characterVersionId,omitempty"` +} + +type agentStoryboardPlan struct { + Title string `json:"title"` + Logline string `json:"logline"` + StyleGuide string `json:"styleGuide"` + Characters []string `json:"characters"` + Locations []string `json:"locations"` + Shots []agentStoryboardShot `json:"shots"` + Raw map[string]interface{} `json:"-"` +} + +type agentStoryboardShot struct { + Title string `json:"title"` + Description string `json:"description"` + Duration int `json:"durationSeconds"` + Dialogue string `json:"dialogue"` + ShotSize string `json:"shotSize"` + Emotion string `json:"emotion"` + Lighting string `json:"lightingAndAtmosphere"` + AudioEffects string `json:"audioEffects"` + VisualPrompt string `json:"visualPrompt"` + VideoPrompt string `json:"videoPrompt"` + Camera string `json:"camera"` + Motion string `json:"motion"` + TimeBeats string `json:"timeBeats"` + Negative string `json:"negativePrompt"` + AssetTags []string `json:"assetTags"` + CharacterIDs []string `json:"characterIds"` + Intent string `json:"narrativeIntent"` + ViewerPOV string `json:"viewerPOV"` + Performance string `json:"performanceBlocking"` + MustHave []string `json:"mustHave"` + Optional []string `json:"optionalDetails"` + ContinuityOut string `json:"continuityOut"` +} + +func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { + jsonText, err := extractJSONText(raw) + if err != nil { + return agentStoryboardPlan{}, err + } + if err := validateStoryboardJSONFields(jsonText); err != nil { + return agentStoryboardPlan{}, err + } + var plan agentStoryboardPlan + if err := json.Unmarshal([]byte(jsonText), &plan); err != nil { + return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err) + } + plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜") + plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案") + plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "严格沿用当前项目画风,保持角色、空间、道具、色彩和视觉媒介一致。") + if len(plan.Shots) == 0 { + return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots") + } + if len(plan.Shots) > 12 { + return agentStoryboardPlan{}, fmt.Errorf("分镜数量最多 12 个,实际返回 %d 个", len(plan.Shots)) + } + for i := range plan.Shots { + if strings.TrimSpace(plan.Shots[i].Title) == "" { + plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1) + } + plan.Shots[i].CharacterIDs = nonNilStrings(plan.Shots[i].CharacterIDs) + plan.Shots[i].AssetTags = nonNilStrings(plan.Shots[i].AssetTags) + plan.Shots[i].Optional = nonNilStrings(plan.Shots[i].Optional) + plan.Shots[i].Intent = defaultString(strings.TrimSpace(plan.Shots[i].Intent), strings.TrimSpace(plan.Shots[i].Description)) + plan.Shots[i].ViewerPOV = defaultString(strings.TrimSpace(plan.Shots[i].ViewerPOV), "客观观察当前主要角色与事件") + plan.Shots[i].Performance = defaultString(strings.TrimSpace(plan.Shots[i].Performance), strings.TrimSpace(plan.Shots[i].Description)) + plan.Shots[i].ContinuityOut = defaultString(strings.TrimSpace(plan.Shots[i].ContinuityOut), "保持本镜头结尾的人物位置、动作状态、道具和光线方向进入下一镜") + if len(plan.Shots[i].MustHave) == 0 { + plan.Shots[i].MustHave = []string{"主要角色身份与当前版本稳定", "主要动作完成并有清晰落点", "结尾状态可供下一镜继承"} + } + if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" { + plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description) + } + if strings.TrimSpace(plan.Shots[i].VisualPrompt) == "" { + return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 visualPrompt", i+1) + } + if strings.TrimSpace(plan.Shots[i].Camera) == "" || strings.TrimSpace(plan.Shots[i].Motion) == "" || strings.TrimSpace(plan.Shots[i].TimeBeats) == "" { + return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 camera、motion 或 timeBeats", i+1) + } + if plan.Shots[i].Duration <= 0 || plan.Shots[i].Duration > 60 { + return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 的 durationSeconds 必须在 1 到 60 之间", i+1) + } + } + return plan, nil +} + +func validateStoryboardJSONFields(jsonText string) error { + var root map[string]json.RawMessage + if err := json.Unmarshal([]byte(jsonText), &root); err != nil { + return fmt.Errorf("分镜 JSON 解析失败:%w", err) + } + for _, field := range []string{"title", "logline", "styleGuide", "characters", "locations", "shots"} { + if _, ok := root[field]; !ok { + return fmt.Errorf("分镜 JSON 缺少受保护字段 %s", field) + } + } + var shots []map[string]json.RawMessage + if err := json.Unmarshal(root["shots"], &shots); err != nil { + return errors.New("分镜 JSON 的 shots 必须是数组") + } + required := []string{"title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetTags"} + for index, shot := range shots { + for _, field := range required { + if _, ok := shot[field]; !ok { + return fmt.Errorf("镜头 %d 缺少受保护字段 %s", index+1, field) + } + } + } + return nil +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func validateStoryboardContext(projectStyle storyboardProjectStyle, characters []storyboardCharacterCard) error { + if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" { + return errors.New("请先设置项目画风,再生成分镜") + } + if strings.TrimSpace(projectStyle.ProfileJSON) != "" { + if _, err := validateStyleProfileJSON(projectStyle.ProfileJSON); err != nil { + return err + } + } + for _, character := range characters { + if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { + return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") + } + } + return nil +} + +func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error { + if target == 0 { + return nil + } + if target != 5 && target != 10 && target != 15 && target != 30 { + return fmt.Errorf("不支持的单镜头时长:%d 秒", target) + } + for index, shot := range plan.Shots { + if shot.Duration != target { + return fmt.Errorf("镜头 %d 的时长必须是 %d 秒", index+1, target) + } + } + return nil +} + +func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard) error { + if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 { + return errors.New("styleGuide 最多 120 个中文字符") + } + if err := validateStoryboardShotDuration(plan, shotDuration); err != nil { + return err + } + if err := validateStoryboardShotCount(plan, shotCount); err != nil { + return err + } + if err := validateStoryboardCharacterIDs(plan, characters); err != nil { + return err + } + return nil +} + +func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { + if target == 0 { + return nil + } + if target < 1 || target > 10 { + return fmt.Errorf("分镜数量必须在 1 到 10 之间") + } + if len(plan.Shots) != target { + return fmt.Errorf("分镜数量必须是 %d,实际生成 %d", target, len(plan.Shots)) + } + return nil +} + +func validateStoryboardComplexity(plan agentStoryboardPlan) error { + issues := make([]string, 0) + for index, shot := range plan.Shots { + shotNumber := index + 1 + if len(shot.CharacterIDs) > 2 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, len(shot.CharacterIDs))) + } + if len(shot.MustHave) > 3 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave))) + } + if beats := storyboardBeatCount(shot.TimeBeats); beats > 3 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个时间节拍,最多 3 个", shotNumber, beats)) + } + if movements := storyboardCameraMovementCount(shot.Motion); movements > 1 { + issues = append(issues, fmt.Sprintf("镜头 %d 包含 %d 种主运镜,最多 1 种", shotNumber, movements)) + } + dialogueLimit := max(24, shot.Duration*5) + if dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)); dialogueLength > dialogueLimit { + issues = append(issues, fmt.Sprintf("镜头 %d 台词 %d 字,%d 秒镜头最多约 %d 字", shotNumber, dialogueLength, shot.Duration, dialogueLimit)) + } + } + if len(issues) == 0 { + return nil + } + return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";")) +} + +func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int) { + if plan == nil || target != 0 { + return + } + for index := range plan.Shots { + shot := &plan.Shots[index] + dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)) + requiredDuration := (dialogueLength + 4) / 5 + shot.Duration = min(60, max(1, shot.Duration, requiredDuration)) + } +} + +func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { + allowed := make(map[string]bool, len(characters)) + for _, character := range characters { + allowed[character.AssetID] = true + } + for index, shot := range plan.Shots { + for _, assetID := range shot.CharacterIDs { + if !allowed[assetID] { + return fmt.Errorf("镜头 %d 引用了不存在或非当前版本的角色 assetId:%s", index+1, assetID) + } + } + } + return nil +} + +func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any { + byID := make(map[string]storyboardCharacterCard, len(characters)) + for _, character := range characters { + byID[character.AssetID] = character + } + result := make([]map[string]any, 0, len(shot.CharacterIDs)) + for _, assetID := range shot.CharacterIDs { + character, ok := byID[assetID] + if !ok { + continue + } + result = append(result, map[string]any{ + "characterName": character.Name, + "characterAssetId": character.AssetID, + "characterVersionId": character.VersionID, + }) + } + return result +} + +func storyboardBeatCount(value string) int { + parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' }) + count := 0 + for _, part := range parts { + if strings.TrimSpace(part) != "" { + count++ + } + } + timecodeCount := len(storyboardTimecodePattern.FindAllString(value, -1)) + return max(count, timecodeCount) +} + +var storyboardTimecodePattern = regexp.MustCompile(`\d+(?:\.\d+)?\s*[-~—至到]\s*\d+(?:\.\d+)?\s*秒`) + +func storyboardCameraMovementCount(value string) int { + movements := []string{"推进", "推近", "拉远", "摇摄", "横移", "侧移", "跟拍", "跟随", "升降", "上升", "下降", "环绕", "俯冲", "变焦", "甩镜", "穿越"} + count := 0 + for _, movement := range movements { + if strings.Contains(value, movement) { + count++ + } + } + return count +} + +func extractJSONText(raw string) (string, error) { + // Text models may prepend an explanation or append a Markdown fence despite + // the JSON-only contract. Scan complete JSON values instead of pairing the + // first opening brace with the final closing brace, which can merge prose and + // make an otherwise valid character breakdown fail validation. + for start := 0; start < len(raw); start++ { + if raw[start] != '{' && raw[start] != '[' { + continue + } + end := jsonValueEnd(raw, start) + if end < start { + continue + } + candidate := raw[start : end+1] + var decoded interface{} + if json.Unmarshal([]byte(candidate), &decoded) == nil { + return candidate, nil + } + } + return "", errors.New("模型返回的不是 JSON") +} + +func jsonValueEnd(source string, start int) int { + stack := make([]byte, 0, 8) + inString := false + escaped := false + for index := start; index < len(source); index++ { + value := source[index] + if inString { + if escaped { + escaped = false + } else if value == '\\' { + escaped = true + } else if value == '"' { + inString = false + } + continue + } + switch value { + case '"': + inString = true + case '{', '[': + stack = append(stack, value) + case '}', ']': + if len(stack) == 0 { + return -1 + } + opener := stack[len(stack)-1] + if (value == '}' && opener != '{') || (value == ']' && opener != '[') { + return -1 + } + stack = stack[:len(stack)-1] + if len(stack) == 0 { + return index + } + } + } + return -1 +} +func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset { + rawNodes, _ := snapshot["nodes"].([]interface{}) + assets := make([]storyboardAsset, 0, len(rawNodes)) + for _, raw := range rawNodes { + node, _ := raw.(map[string]interface{}) + if node == nil { + continue + } + metadata, _ := node["metadata"].(map[string]interface{}) + if metadata == nil { + metadata = map[string]interface{}{} + } + nodeType := stringValue(node["type"]) + isCharacterCard := stringValue(metadata["workflowKind"]) == "character" && stringValue(metadata["characterAssetId"]) != "" && stringValue(metadata["characterVersionId"]) != "" + if nodeType != "image" && !isCharacterCard { + continue + } + id := stringValue(node["id"]) + if id == "" { + continue + } + tags := stringSlice(metadata["assetTags"]) + prompt := stringValue(metadata["prompt"]) + content := stringValue(metadata["content"]) + if len(tags) == 0 && prompt == "" && content == "" && !isCharacterCard { + continue + } + assets = append(assets, storyboardAsset{ + ID: id, + Title: defaultString(stringValue(node["title"]), "未命名图片"), + Type: defaultString(nodeType, "reference"), + Tags: tags, + Prompt: prompt, + CharacterAssetID: stringValue(metadata["characterAssetId"]), + CharacterVersionID: stringValue(metadata["characterVersionId"]), + }) + if len(assets) >= 30 { + break + } + } + return assets +} + +func matchStoryboardAssets(assets []storyboardAsset, shotTags []string) []storyboardAsset { + wanted := map[string]bool{} + for _, tag := range shotTags { + for _, token := range storyboardTagTokens(tag) { + wanted[token] = true + } + } + if len(wanted) == 0 { + return nil + } + matched := make([]storyboardAsset, 0) + for _, asset := range assets { + tokens := map[string]bool{} + for _, token := range storyboardTagTokens(asset.Title) { + tokens[token] = true + } + for _, tag := range asset.Tags { + for _, token := range storyboardTagTokens(tag) { + tokens[token] = true + } + } + if storyboardTokensMatch(wanted, tokens) { + matched = append(matched, asset) + } + if len(matched) >= 6 { + break + } + } + return matched +} + +func matchStoryboardAssetsForShot(assets []storyboardAsset, shot agentStoryboardShot) []storyboardAsset { + matched := make([]storyboardAsset, 0, 6) + seen := make(map[string]bool, 6) + wantedCharacters := make(map[string]bool, len(shot.CharacterIDs)) + for _, assetID := range shot.CharacterIDs { + wantedCharacters[assetID] = true + } + for _, asset := range assets { + if len(matched) >= 6 { + break + } + if !seen[asset.ID] && wantedCharacters[asset.CharacterAssetID] { + matched = append(matched, asset) + seen[asset.ID] = true + } + } + for _, asset := range matchStoryboardAssets(assets, shot.AssetTags) { + if len(matched) >= 6 { + break + } + if !seen[asset.ID] { + matched = append(matched, asset) + seen[asset.ID] = true + } + } + return matched +} + +func storyboardTokensMatch(wanted map[string]bool, tokens map[string]bool) bool { + for want := range wanted { + if tokens[want] { + return true + } + for token := range tokens { + if meaningfulStoryboardTagToken(want) && meaningfulStoryboardTagToken(token) && (strings.Contains(token, want) || strings.Contains(want, token)) { + return true + } + } + } + return false +} + +func storyboardTagTokens(value string) []string { + normalized := strings.ToLower(strings.ReplaceAll(strings.Join(strings.Fields(strings.ReplaceAll(value, ":", ":")), ""), ",", ",")) + if normalized == "" { + return nil + } + tokens := []string{normalized} + if index := strings.Index(normalized, ":"); index >= 0 { + tokens = append(tokens, normalized[index+1:]) + } + unique := make([]string, 0, len(tokens)) + seen := map[string]bool{} + for _, token := range tokens { + if meaningfulStoryboardTagToken(token) && !seen[token] { + seen[token] = true + unique = append(unique, token) + } + } + return unique +} + +func meaningfulStoryboardTagToken(value string) bool { + if len([]rune(value)) < 2 { + return false + } + switch value { + case "角色", "环境", "场景", "道具", "武器", "风格": + return false + } + return true +} + +func listContent(title string, items []string) string { + if len(items) == 0 { + return title + "\n\n- 暂无明确内容。" + } + lines := []string{title, ""} + for _, item := range items { + if strings.TrimSpace(item) != "" { + lines = append(lines, "- "+item) + } + } + return strings.Join(lines, "\n") +} + +func storyboardAssetsContent(assets []storyboardAsset) string { + if len(assets) == 0 { + return "当前画布暂无可用图片资产。建议先给角色、环境、道具图片添加资产标签。" + } + lines := make([]string, 0, len(assets)) + for _, asset := range assets { + line := asset.Title + "\nID: " + asset.ID + if len(asset.Tags) > 0 { + line += "\n标签: " + strings.Join(asset.Tags, "、") + } + if asset.Prompt != "" { + line += "\n原提示词: " + asset.Prompt + } + lines = append(lines, line) + } + return strings.Join(lines, "\n\n") +} + +func shotDescription(shot agentStoryboardShot) string { + parts := []string{shot.Description} + if strings.TrimSpace(shot.VisualPrompt) != "" { + parts = append(parts, "画面提示词:"+shot.VisualPrompt) + } + if strings.TrimSpace(shot.Camera) != "" { + parts = append(parts, "镜头:"+shot.Camera) + } + if strings.TrimSpace(shot.Motion) != "" { + parts = append(parts, "运动:"+shot.Motion) + } + if strings.TrimSpace(shot.TimeBeats) != "" { + parts = append(parts, "时间节拍:"+shot.TimeBeats) + } + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, "\n\n") +} + +func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { + negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") + return map[string]string{ + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), + "表演起始状态": compactPromptText(shot.Performance, 180), + "负面要求": compactPromptText(negative, 140), + } +} + +func buildStoryboardImagePrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { + definition, _ := promptDefinition(promptOperationStoryboardFirstFrame) + prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardImagePromptValues(projectStyle, styleGuide, shot)) + return prompt +} + +func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { + camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次") + motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作") + timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) + negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") + values := map[string]string{ + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), + "表演与调度": compactPromptText(shot.Performance, 180), + "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), + "时间节拍": compactPromptText(timeBeats, 240), + "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), + "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), + "负面要求": compactPromptText(negative, 160), + } + if len(shot.MustHave) > 0 { + priority := "必须完成:" + strings.Join(shot.MustHave, ";") + if len(shot.Optional) > 0 { + priority += "。可以简化:" + strings.Join(shot.Optional, ";") + } + values["执行优先级"] = compactPromptText(priority, 140) + } + return values +} + +func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { + definition, _ := promptDefinition(promptOperationStoryboardVideo) + prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) + return prompt +} + +func storyboardProjectVisualSummary(projectStyle string, styleGuide string) string { + identity := "" + for _, line := range strings.Split(projectStyle, "\n") { + if strings.TrimSpace(line) != "" { + identity = strings.TrimSpace(line) + break + } + } + parts := make([]string, 0, 2) + if identity != "" { + parts = append(parts, identity) + } + if strings.TrimSpace(styleGuide) != "" { + parts = append(parts, strings.TrimSpace(styleGuide)) + } + return compactPromptText(strings.Join(parts, ";"), 180) +} + +func compactPromptText(value string, limit int) string { + text := strings.TrimSpace(value) + if utf8.RuneCountInString(text) <= limit { + return text + } + runes := []rune(text) + return strings.TrimSpace(string(runes[:limit])) + "。" +} + +func shotComposerContent(prompt string, assets []storyboardAsset) string { + if len(assets) == 0 { + return prompt + } + lines := []string{"参考素材:"} + for _, asset := range assets { + label := asset.Title + if len(asset.Tags) > 0 { + label += "(" + strings.Join(asset.Tags, "、") + ")" + } + lines = append(lines, "- "+label+":@[node:"+asset.ID+"]") + } + lines = append(lines, "", "分镜视频提示词:", prompt) + return strings.Join(lines, "\n") +} diff --git a/backend/internal/service/storyboard_workflow.go b/backend/internal/service/storyboard_workflow.go new file mode 100644 index 000000000..abc39a75a --- /dev/null +++ b/backend/internal/service/storyboard_workflow.go @@ -0,0 +1,231 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { + input, err := parseStoryboardTaskInput(task, "Agent 会话") + if err != nil { + return nil, nil, err + } + plan, assets, err := s.generateStoryboardPlan(ctx, task, input, 0, 0) + if err != nil { + return nil, nil, err + } + return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle) +} + +func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { + input, err := parseStoryboardTaskInput(task, "脚本任务") + if err != nil { + return nil, nil, err + } + plan, assets, err := s.generateStoryboardPlan(ctx, task, input, input.ShotDuration, input.ShotCount) + if err != nil { + return nil, nil, err + } + + rows := make([]map[string]any, 0, len(plan.Shots)) + for index, shot := range plan.Shots { + imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) + videoPromptVariables := storyboardVideoPromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) + imagePrompt, promptErr := s.compileStoryboardImagePrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) + if promptErr != nil { + return nil, nil, promptErr + } + videoPrompt, promptErr := s.compileStoryboardVideoPrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) + if promptErr != nil { + return nil, nil, promptErr + } + matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) + referenceNodeIDs := make([]string, 0, len(matchedAssets)) + for _, asset := range matchedAssets { + referenceNodeIDs = append(referenceNodeIDs, asset.ID) + } + rows = append(rows, map[string]any{ + "shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description, + "dialogue": shot.Dialogue, "characters": storyboardRowCharacters(shot, input.Characters), "shotSize": shot.ShotSize, "emotion": shot.Emotion, + "lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects, + "imageGenerationPrompt": imagePrompt, "videoMotionPrompt": videoPrompt, + "imagePromptTemplateVariables": imagePromptVariables, "videoPromptTemplateVariables": videoPromptVariables, + "camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative, + "narrativeIntent": shot.Intent, "viewerPOV": shot.ViewerPOV, "performanceBlocking": shot.Performance, + "mustHave": shot.MustHave, "optionalDetails": shot.Optional, "continuityOut": shot.ContinuityOut, + "referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags, + }) + } + return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil +} + +const maxStoryboardRepairAttempts = 2 + +func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, config providerConfig, originalText string, validationErr error, shotDuration int, shotCount int) (agentStoryboardPlan, error) { + currentText := originalText + currentErr := validationErr + for attempt := 1; attempt <= maxStoryboardRepairAttempts; attempt++ { + if err := s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55+attempt*10); err != nil { + return agentStoryboardPlan{}, fmt.Errorf("更新分镜修复进度失败,上游修复请求未发出:%w", err) + } + repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, task.Prompt, currentErr, input, currentText) + if promptErr != nil { + return agentStoryboardPlan{}, promptErr + } + repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true}) + if repairErr != nil { + return agentStoryboardPlan{}, fmt.Errorf("分镜结构修复失败:%w", repairErr) + } + repairedText, _ := repaired["text"].(string) + plan, parseErr := parseAgentStoryboardPlan(repairedText) + if parseErr == nil { + normalizeAutomaticStoryboardDurations(&plan, shotDuration) + parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters) + } + if parseErr == nil { + return plan, nil + } + currentText = repairedText + currentErr = parseErr + } + return agentStoryboardPlan{}, fmt.Errorf("分镜模型结构修复后仍不合法:%w", currentErr) +} + +func providerConfigReady(config providerConfig) bool { + return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != "")) +} + +func parseStoryboardTaskInput(task model.Task, label string) (agentStoryboardInput, error) { + input := agentStoryboardInput{} + if strings.TrimSpace(task.InputJSON) == "" { + return input, nil + } + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + return agentStoryboardInput{}, fmt.Errorf("%s输入解析失败:%w", label, err) + } + return input, nil +} + +func (s *Service) generateStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, shotDuration int, shotCount int) (agentStoryboardPlan, []storyboardAsset, error) { + if !providerConfigReady(input.Config) { + return agentStoryboardPlan{}, nil, errors.New("请先配置可用的文本模型") + } + if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { + return agentStoryboardPlan{}, nil, err + } + assets := input.CanvasAssets + if len(assets) == 0 { + assets = extractStoryboardAssets(input.CanvasSnapshot) + } + config, err := s.resolveProviderConfig(input.Config) + if err != nil { + return agentStoryboardPlan{}, nil, err + } + ctx = withProviderOutboundPolicy(ctx, config) + plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, shotDuration, shotCount) + if err != nil { + return agentStoryboardPlan{}, nil, err + } + result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true}) + if err != nil { + return agentStoryboardPlan{}, nil, err + } + text, _ := result["text"].(string) + plan, err := parseAgentStoryboardPlan(text) + if err == nil { + normalizeAutomaticStoryboardDurations(&plan, shotDuration) + err = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters) + } + if err != nil { + plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, shotDuration, shotCount) + if err != nil { + return agentStoryboardPlan{}, nil, err + } + } + if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { + _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) + } + return plan, assets, nil +} + +func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset, projectStyle storyboardProjectStyle) (map[string]interface{}, []map[string]interface{}, error) { + prefix := "agent-" + task.ID + scriptID := prefix + "-script" + sceneID := prefix + "-scenes" + styleID := prefix + "-style" + referenceID := prefix + "-assets" + finalID := prefix + "-final" + sceneX := 380 + styleX := sceneX + 380 + ops := []map[string]any{ + nodeOpWithMetadata(scriptID, "text", "剧本 · "+shortTitle(plan.Title, 24), 0, 0, map[string]any{"workflowKind": "script", "workflowTitle": "剧本", "status": "success", "content": strings.Join([]string{plan.Title, "", plan.Logline, "", task.Prompt}, "\n")}), + nodeOpWithMetadata(sceneID, "text", "场景设定", sceneX, 0, map[string]any{"workflowKind": "scene", "workflowTitle": "场景", "status": "success", "content": listContent("场景", plan.Locations)}), + nodeOpWithMetadata(styleID, "text", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "styleProfileJson": projectStyle.ProfileJSON, "status": "success", "content": projectStyle.Prompt, "prompt": projectStyle.Prompt}), + nodeOpWithMetadata(referenceID, "text", "参考素材组", 0, 270, map[string]any{"workflowKind": "reference_set", "workflowTitle": "参考素材组", "status": "success", "content": storyboardAssetsContent(assets)}), + nodeOpWithMetadata(finalID, "video", "成片 · 待生成", styleX, 270, map[string]any{"workflowKind": "final", "workflowTitle": "成片", "status": "idle"}), + connectOp(scriptID, sceneID), + } + resultShots := make([]map[string]any, 0, len(plan.Shots)) + for index, shot := range plan.Shots { + videoPrompt, err := s.compileStoryboardVideoPrompt(task.UserID, projectStyle.Prompt, plan.StyleGuide, shot) + if err != nil { + return nil, nil, err + } + shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1) + matchedAssets := matchStoryboardAssetsForShot(assets, shot) + assetIDs := make([]string, 0, len(matchedAssets)) + for _, asset := range matchedAssets { + assetIDs = append(assetIDs, asset.ID) + } + ops = append(ops, + nodeOpWithMetadata(shotID, "video", fmt.Sprintf("镜头 %d · %s", index+1, shortTitle(shot.Title, 18)), index*360, 560, map[string]any{ + "workflowKind": "shot", + "workflowTitle": shot.Title, + "workflowDescription": shotDescription(shot), + "shotIndex": index + 1, + "generationMode": "video", + "prompt": videoPrompt, + "composerContent": shotComposerContent(videoPrompt, matchedAssets), + "videoEditOperation": "text_to_video", + "assetTags": shot.AssetTags, + "referenceAssetNodeIds": assetIDs, + "status": "idle", + }), + connectOp(scriptID, shotID), + connectOp(shotID, finalID), + ) + for _, asset := range matchedAssets { + ops = append(ops, connectOp(asset.ID, shotID)) + } + resultShots = append(resultShots, map[string]any{"title": shot.Title, "description": shot.Description, "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs}) + } + ops = append(ops, map[string]any{"type": "select_nodes", "ids": shotIDs(prefix, len(plan.Shots))}) + result := map[string]any{ + "taskId": task.ID, + "operation": task.Operation, + "provider": defaultString(task.Provider, "internal-agent"), + "model": defaultString(task.Model, "workflow-router"), + "title": plan.Title, + "logline": plan.Logline, + "styleGuide": plan.StyleGuide, + "characters": plan.Characters, + "locations": plan.Locations, + "shots": resultShots, + } + return result, ops, nil +} + +func (s *Service) compileStoryboardImagePrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { + compiled, err := s.compilePrompt(userID, promptOperationStoryboardFirstFrame, storyboardImagePromptValues(projectStyle, styleGuide, shot)) + return compiled.Content, err +} + +func (s *Service) compileStoryboardVideoPrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { + compiled, err := s.compilePrompt(userID, promptOperationStoryboardVideo, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) + return compiled.Content, err +} diff --git a/backend/internal/service/task_billing.go b/backend/internal/service/task_billing.go new file mode 100644 index 000000000..bd2ff7d2b --- /dev/null +++ b/backend/internal/service/task_billing.go @@ -0,0 +1,113 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// taskBillingCoordinator 只负责任务生命周期中的计费状态迁移和核对策略。 +// +// 兑换码、充值和管理员人工核对仍属于 finance service;这里收敛的是任务 +// 从排队、上游调用到终态这一条链路,避免 worker、provider 和终态协调器 +// 各自决定如何退款或进入待核对。 +type taskBillingCoordinator struct { + repo taskBillingRepository +} + +type taskBillingRepository interface { + MarkBillingRunning(orderID string) error + SettleBillingOrder(orderID string, providerRequestID string) error + RefundBillingOrder(orderID string, errorText string) error + MarkBillingUncertain(orderID string, errorText string) error + BillingOrder(orderID string) (*model.BillingOrder, error) + TaskHasSuccessfulBillableCall(taskID string) (bool, error) +} + +var ( + // errTaskBillingReview 表示订单状态已经不能由重试流程安全推断,必须先完成费用核对。 + errTaskBillingReview = errors.New("task billing order requires review") +) + +func newTaskBillingCoordinator(repo taskBillingRepository) *taskBillingCoordinator { + return &taskBillingCoordinator{repo: repo} +} + +func (c *taskBillingCoordinator) MarkBillingRunning(orderID string) error { + if orderID == "" { + return nil + } + return c.repo.MarkBillingRunning(orderID) +} + +func (c *taskBillingCoordinator) SettleBilling(orderID string, providerRequestID string) error { + if orderID == "" { + return nil + } + return c.repo.SettleBillingOrder(orderID, providerRequestID) +} + +func (c *taskBillingCoordinator) RefundBilling(orderID string, errorText string) error { + if orderID == "" { + return nil + } + return c.repo.RefundBillingOrder(orderID, truncateRunes(errorText, 1000)) +} + +func (c *taskBillingCoordinator) MarkBillingUncertain(orderID string, errorText string) error { + if orderID == "" { + return nil + } + return c.repo.MarkBillingUncertain(orderID, truncateRunes(errorText, 1000)) +} + +// CheckRetryEligibility 收敛失败/取消任务重试前的计费状态校验。 +// +// 计费订单读取失败不能被当作“没有待核对订单”继续放行,否则重试可能重复扣费。 +// 只有明确读到订单且订单不是 uncertain 时,才允许进入后续的重新计费流程。 +func (c *taskBillingCoordinator) CheckRetryEligibility(orderID string) error { + if strings.TrimSpace(orderID) == "" { + return nil + } + order, err := c.repo.BillingOrder(orderID) + if err != nil { + return fmt.Errorf("读取任务计费订单失败:%w", err) + } + if order == nil { + return fmt.Errorf("任务计费订单不存在:%s", orderID) + } + if order.Status == model.BillingStatusUncertain { + return errTaskBillingReview + } + return nil +} + +func (c *taskBillingCoordinator) BillingFailureRequiresReview(orderID string, taskID string, err error) bool { + if orderID == "" { + return false + } + if billingFailureUncertain(err) { + return true + } + order, orderErr := c.repo.BillingOrder(orderID) + if orderErr != nil || order == nil || order.Status == model.BillingStatusUncertain { + return true + } + hasSuccessfulCall, logErr := c.repo.TaskHasSuccessfulBillableCall(taskID) + return logErr != nil || hasSuccessfulCall +} + +func billingFailureUncertain(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + for _, marker := range []string{"524", "timeout", "超时", "deadline exceeded", "context canceled", "connection reset", "unexpected eof", "broken pipe"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} diff --git a/backend/internal/service/task_billing_test.go b/backend/internal/service/task_billing_test.go new file mode 100644 index 000000000..155ab51ac --- /dev/null +++ b/backend/internal/service/task_billing_test.go @@ -0,0 +1,148 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +type taskBillingRepositoryStub struct { + runningCalls []string + settleCalls []string + refundCalls []string + uncertainCalls []string + order *model.BillingOrder + orderErr error + hasSuccessfulCall bool + callErr error +} + +func (r *taskBillingRepositoryStub) MarkBillingRunning(orderID string) error { + r.runningCalls = append(r.runningCalls, orderID) + return nil +} + +func (r *taskBillingRepositoryStub) SettleBillingOrder(orderID string, providerRequestID string) error { + r.settleCalls = append(r.settleCalls, orderID+":"+providerRequestID) + return nil +} + +func (r *taskBillingRepositoryStub) RefundBillingOrder(orderID string, errorText string) error { + r.refundCalls = append(r.refundCalls, orderID+":"+errorText) + return nil +} + +func (r *taskBillingRepositoryStub) MarkBillingUncertain(orderID string, errorText string) error { + r.uncertainCalls = append(r.uncertainCalls, orderID+":"+errorText) + return nil +} + +func (r *taskBillingRepositoryStub) BillingOrder(string) (*model.BillingOrder, error) { + return r.order, r.orderErr +} + +func (r *taskBillingRepositoryStub) TaskHasSuccessfulBillableCall(string) (bool, error) { + return r.hasSuccessfulCall, r.callErr +} + +func TestTaskBillingCoordinatorEmptyOrderIsNoOp(t *testing.T) { + repo := &taskBillingRepositoryStub{} + billing := newTaskBillingCoordinator(repo) + + if err := billing.MarkBillingRunning(""); err != nil { + t.Fatalf("MarkBillingRunning() error = %v", err) + } + if err := billing.SettleBilling("", "provider-1"); err != nil { + t.Fatalf("SettleBilling() error = %v", err) + } + if err := billing.RefundBilling("", "refund"); err != nil { + t.Fatalf("RefundBilling() error = %v", err) + } + if err := billing.MarkBillingUncertain("", "uncertain"); err != nil { + t.Fatalf("MarkBillingUncertain() error = %v", err) + } + if billing.BillingFailureRequiresReview("", "task-1", errors.New("timeout")) { + t.Fatal("BillingFailureRequiresReview() = true for empty order") + } + if len(repo.runningCalls)+len(repo.settleCalls)+len(repo.refundCalls)+len(repo.uncertainCalls) != 0 { + t.Fatalf("empty order should not reach repository: %#v", repo) + } +} + +func TestTaskBillingCoordinatorRetryEligibility(t *testing.T) { + tests := []struct { + name string + orderID string + order *model.BillingOrder + orderErr error + wantErr bool + wantReview bool + }{ + {name: "empty order", wantErr: false}, + {name: "running order", orderID: "order-1", order: &model.BillingOrder{Status: model.BillingStatusRunning}, wantErr: false}, + {name: "settled order", orderID: "order-1", order: &model.BillingOrder{Status: model.BillingStatusSettled}, wantErr: false}, + {name: "refunded order", orderID: "order-1", order: &model.BillingOrder{Status: model.BillingStatusRefunded}, wantErr: false}, + {name: "uncertain order", orderID: "order-1", order: &model.BillingOrder{Status: model.BillingStatusUncertain}, wantErr: true, wantReview: true}, + {name: "missing order", orderID: "order-1", wantErr: true, wantReview: false}, + {name: "lookup failure", orderID: "order-1", orderErr: errors.New("database unavailable"), wantErr: true, wantReview: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + billing := newTaskBillingCoordinator(&taskBillingRepositoryStub{order: tt.order, orderErr: tt.orderErr}) + err := billing.CheckRetryEligibility(tt.orderID) + if (err != nil) != tt.wantErr { + t.Fatalf("CheckRetryEligibility() error = %v, wantErr %v", err, tt.wantErr) + } + if errors.Is(err, errTaskBillingReview) != tt.wantReview { + t.Fatalf("CheckRetryEligibility() review = %v, wantReview %v, err = %v", errors.Is(err, errTaskBillingReview), tt.wantReview, err) + } + }) + } +} + +func TestTaskBillingCoordinatorTruncatesFailureText(t *testing.T) { + repo := &taskBillingRepositoryStub{} + billing := newTaskBillingCoordinator(repo) + reason := strings.Repeat("中", 1001) + + if err := billing.RefundBilling("order-1", reason); err != nil { + t.Fatalf("RefundBilling() error = %v", err) + } + if err := billing.MarkBillingUncertain("order-1", reason); err != nil { + t.Fatalf("MarkBillingUncertain() error = %v", err) + } + if got := repo.refundCalls[0]; !strings.HasSuffix(got, strings.Repeat("中", 1000)+"...") { + t.Fatalf("refund reason was not truncated with compatibility suffix: %q", got) + } + if got := repo.uncertainCalls[0]; !strings.HasSuffix(got, strings.Repeat("中", 1000)+"...") { + t.Fatalf("uncertain reason was not truncated with compatibility suffix: %q", got) + } +} + +func TestTaskBillingCoordinatorFailureReviewPolicy(t *testing.T) { + tests := []struct { + name string + stub taskBillingRepositoryStub + err error + want bool + }{ + {name: "uncertain transport error", stub: taskBillingRepositoryStub{order: &model.BillingOrder{Status: model.BillingStatusSettled}}, err: errors.New("upstream timeout"), want: true}, + {name: "order lookup error", stub: taskBillingRepositoryStub{orderErr: errors.New("database unavailable")}, err: errors.New("provider unavailable"), want: true}, + {name: "uncertain order", stub: taskBillingRepositoryStub{order: &model.BillingOrder{Status: model.BillingStatusUncertain}}, err: errors.New("provider rejected"), want: true}, + {name: "successful billable call", stub: taskBillingRepositoryStub{order: &model.BillingOrder{Status: model.BillingStatusRunning}, hasSuccessfulCall: true}, err: errors.New("provider rejected"), want: true}, + {name: "normal failed call", stub: taskBillingRepositoryStub{order: &model.BillingOrder{Status: model.BillingStatusRunning}}, err: errors.New("provider rejected"), want: false}, + {name: "billable call lookup error", stub: taskBillingRepositoryStub{order: &model.BillingOrder{Status: model.BillingStatusRunning}, callErr: errors.New("database unavailable")}, err: errors.New("provider rejected"), want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + billing := newTaskBillingCoordinator(&tt.stub) + if got := billing.BillingFailureRequiresReview("order-1", "task-1", tt.err); got != tt.want { + t.Fatalf("BillingFailureRequiresReview() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/task_cancellation_test.go b/backend/internal/service/task_cancellation_test.go index 4d919d9aa..b597b3bb7 100644 --- a/backend/internal/service/task_cancellation_test.go +++ b/backend/internal/service/task_cancellation_test.go @@ -33,7 +33,7 @@ func TestCancelTaskRejectsRunningTask(t *testing.T) { } svc := &Service{repo: repository.New(db)} - if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已开始生成,无法取消" { + if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { t.Fatalf("CancelTask() error = %v", err) } @@ -45,3 +45,62 @@ func TestCancelTaskRejectsRunningTask(t *testing.T) { t.Fatalf("running task changed after cancellation attempt: status=%s completedAt=%v", stored.Status, stored.CompletedAt) } } + +func TestCancelTaskRejectsQueuedTaskWithoutChangingBillingState(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + task := model.Task{ID: "queued-task", UserID: "user-1", Status: model.TaskStatusQueued, Stage: "等待队列调度"} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + + svc := &Service{repo: repository.New(db)} + if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { + t.Fatalf("CancelTask() error = %v", err) + } + + storedTask, err := svc.repo.TaskForUser(task.UserID, task.ID) + if err != nil { + t.Fatal(err) + } + if storedTask.Status != model.TaskStatusQueued || storedTask.CompletedAt != nil { + t.Fatalf("queued task changed after cancellation attempt: status=%s completedAt=%v", storedTask.Status, storedTask.CompletedAt) + } +} + +func TestCancelTaskRejectsTaskWithProviderRequestID(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + task := model.Task{ + ID: "submitted-task", + UserID: "user-1", + Status: model.TaskStatusFailed, + ProviderRequestID: "provider-request-1", + } + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + + svc := &Service{repo: repository.New(db)} + if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { + t.Fatalf("CancelTask() error = %v", err) + } + + stored, err := svc.repo.TaskForUser(task.UserID, task.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.TaskStatusFailed || stored.ProviderRequestID != task.ProviderRequestID { + t.Fatalf("submitted task changed after cancellation attempt: status=%s providerRequestId=%q", stored.Status, stored.ProviderRequestID) + } +} diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go new file mode 100644 index 000000000..a61af5061 --- /dev/null +++ b/backend/internal/service/task_creation.go @@ -0,0 +1,269 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// CreateTask 收敛任务进入系统前的 admission 流程:输入标准化、逻辑模型路由、 +// 能力/额度校验和持久化。执行阶段由 worker 与 provider 相关模块负责。 +func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) { + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return nil, errors.New("prompt is required") + } + taskType := strings.TrimSpace(req.Type) + if err := validateTaskType(taskType); err != nil { + return nil, err + } + normalizedInput, err := normalizeTaskInput(req.Input) + if err != nil { + return nil, err + } + var routed *RoutedModel + logicalModelID := strings.TrimSpace(req.LogicalModelID) + if logicalModelID != "" { + intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation) + routed, err = s.ResolveLogicalModel(logicalModelID, intent) + if err != nil { + return nil, err + } + normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) + } + if strings.HasPrefix(taskType, "video_") && !hasExecutableProviderVideoConfig(normalizedInput) { + if mode, _ := normalizedInput["mode"].(string); mode != "video" { + return nil, errors.New("视频任务必须使用 video 模式") + } + return nil, errors.New("视频任务缺少可执行的模型配置") + } + // 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。 + if isTextReplayTaskRequest(normalizedInput) { + return s.createTextReplayTask(userID, req, normalizedInput) + } + if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil { + return nil, err + } + if err := s.ValidateTaskCapability(normalizedInput); err != nil { + return nil, err + } + if containsInlineMediaDataURL(normalizedInput) { + return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储") + } + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + activeTasks, err := s.repo.ActiveTaskCountForUser(userID) + if err != nil { + return nil, err + } + if activeTasks >= int64(policy.Task.ActiveTaskLimit) { + return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) + } + task := model.Task{ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model} + if routed != nil { + task.LogicalModelID = routed.LogicalModel.ID + task.LogicalModelRevisionID = routed.Revision.ID + task.RouteID = routed.Route.ID + task.ChannelModelID = routed.ChannelModel.ID + task.RouteRun = 1 + task.Model = routed.LogicalModel.Code + task.Provider = "managed" + } + if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil { + return nil, err + } + billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput) + if err != nil { + return nil, err + } + if err := s.protectTaskSecrets(normalizedInput); err != nil { + return nil, err + } + inputJSON, err := json.Marshal(normalizedInput) + if err != nil { + return nil, fmt.Errorf("序列化任务输入失败:%w", err) + } + task.InputJSON = string(inputJSON) + if billingOrder != nil { + task.BillingOrderID = billingOrder.ID + } + err = s.createTaskWithinStorageQuota(&task, billingOrder, policy) + if errors.Is(err, repository.ErrActiveTaskLimit) { + return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) + } + if errors.Is(err, repository.ErrInsufficientCredits) { + return nil, BadAuthRequest("积分不足,请先使用兑换码充值") + } + if errors.Is(err, repository.ErrLogicalModelUnavailable) { + return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择") + } + if err != nil { + return nil, err + } + s.recordActivity(userID, "task", 1) + _ = s.log(userID, task.ID, "info", "任务已进入队列", "") + return taskForOutput(task), nil +} + +func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any { + config, _ := input["config"].(map[string]any) + nextConfig := make(map[string]any, len(config)+2) + for key, value := range config { + switch key { + case "channelId", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig": + continue + default: + nextConfig[key] = value + } + } + for key, value := range routed.Defaults { + canonical := canonicalCapabilityOptionName(key) + if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" { + nextConfig[canonical] = providerConfigOptionValue(value) + } + } + // 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。 + if options, ok := input["capabilityOptions"].(map[string]any); ok { + for key, value := range options { + canonical := canonicalCapabilityOptionName(key) + if isProviderCapabilityOption(canonical) { + nextConfig[canonical] = providerConfigOptionValue(value) + } + } + } + nextConfig["channelId"] = routed.ChannelModel.ChannelID + nextConfig["model"] = routed.ChannelModel.ModelKey + input["config"] = nextConfig + return input +} + +func providerConfigOptionValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + default: + return fmt.Sprint(value) + } +} + +// 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。 +func normalizeTaskInput(input map[string]any) (map[string]any, error) { + if input == nil { + return map[string]any{}, nil + } + encoded, err := json.Marshal(input) + if err != nil { + return nil, BadAuthRequest("任务输入格式无效") + } + var normalized map[string]any + if err := json.Unmarshal(encoded, &normalized); err != nil { + return nil, BadAuthRequest("任务输入格式无效") + } + if snapshot, ok := normalized["canvasSnapshot"]; ok { + normalized["canvasSnapshot"] = compactPersistedValue(snapshot) + } + return normalized, nil +} + +// createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay, +// 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。 +func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) { + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"])) + } + if prompt == "" { + return nil, errors.New("prompt is required") + } + taskType := strings.TrimSpace(req.Type) + if err := validateTaskType(taskType); err != nil { + return nil, err + } + task := model.Task{ + ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, + Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5, + Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model), + } + if err := s.protectTaskSecrets(normalizedInput); err != nil { + return nil, err + } + inputJSON, _ := json.Marshal(normalizedInput) + task.InputJSON = string(inputJSON) + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil { + return nil, err + } + _ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "") + return taskForOutput(task), nil +} + +// validateTaskType 是任务进入队列前的边界校验。视频任务允许携带具体操作后缀, +// 其他任务类型必须是已实现的执行分支,避免未知类型落入假成功工作流。 +func validateTaskType(taskType string) error { + switch taskType { + case "text", "agent_storyboard", "agent_storyboard_rows", "canvas_text", "canvas_image", "canvas_video", "canvas_audio": + return nil + } + if strings.HasPrefix(taskType, "video_") && strings.TrimPrefix(taskType, "video_") != "" { + return nil + } + if taskType == "" { + return errors.New("task type is required") + } + return fmt.Errorf("不支持的任务类型:%s", taskType) +} + +func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error { + if !taskInputUsesCustomChannel(input) { + return nil + } + return s.RequireFeature(FeatureCustomChannels) +} + +func taskInputUsesCustomChannel(input map[string]any) bool { + config, ok := input["config"].(map[string]any) + if !ok { + return false + } + channelID, _ := config["channelId"].(string) + baseURL, _ := config["baseUrl"].(string) + apiKey, _ := config["apiKey"].(string) + if strings.TrimSpace(channelID) != "" || systemChannelIDFromBaseURL(baseURL) != "" { + return false + } + return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" +} + +func compactPersistedValue(value interface{}) interface{} { + switch item := value.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(item)) + for key, child := range item { + if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") { + result[key] = "" + continue + } + result[key] = compactPersistedValue(child) + } + return result + case []interface{}: + result := make([]interface{}, len(item)) + for index, child := range item { + result[index] = compactPersistedValue(child) + } + return result + default: + return value + } +} diff --git a/backend/internal/service/task_execution.go b/backend/internal/service/task_execution.go new file mode 100644 index 000000000..93b3708b6 --- /dev/null +++ b/backend/internal/service/task_execution.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// processTask 是任务执行阶段唯一的类型分派入口。 +// 任务已经在 CreateTask 阶段完成 admission;这里不把无效视频任务降级成内部工作流成功。 +func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { + if err := validateTaskType(task.Type); err != nil { + return nil, nil, err + } + decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return nil, nil, err + } + task.InputJSON = decryptedInput + ctx = withProviderAnalytics(ctx, s, task) + + if task.Type == "agent_storyboard_rows" { + return s.processStoryboardRowsTask(ctx, task) + } + if task.Type == "canvas_text" || task.Type == "canvas_image" || task.Type == "canvas_video" || task.Type == "canvas_audio" { + result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) + return result, nil, err + } + if task.Type == "agent_storyboard" { + return s.processAgentStoryboardTask(ctx, task) + } + if strings.HasPrefix(task.Type, "video_") { + if !canRunProviderTask(task) { + return nil, nil, errors.New("视频任务缺少可执行的模型配置") + } + result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) + return result, nil, err + } + return nil, nil, errors.New("任务类型没有可用的执行分支") +} + +func canRunProviderTask(task model.Task) bool { + if !strings.HasPrefix(task.Type, "video_") || strings.TrimSpace(task.InputJSON) == "" { + return false + } + var input map[string]any + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + return false + } + return hasExecutableProviderVideoConfig(input) +} + +func hasExecutableProviderVideoConfig(input map[string]any) bool { + mode, _ := input["mode"].(string) + config, ok := input["config"].(map[string]any) + if mode != "video" || !ok || stringValue(config["model"]) == "" { + return false + } + return stringValue(config["channelId"]) != "" || (stringValue(config["baseUrl"]) != "" && stringValue(config["apiKey"]) != "") +} diff --git a/backend/internal/service/task_execution_test.go b/backend/internal/service/task_execution_test.go new file mode 100644 index 000000000..a439fed86 --- /dev/null +++ b/backend/internal/service/task_execution_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestCanRunProviderTaskRequiresVideoConfig(t *testing.T) { + tests := []struct { + name string + task model.Task + want bool + }{ + { + name: "channel config", + task: model.Task{Type: "video_generate", InputJSON: `{"mode":"video","config":{"model":"veo","channelId":"channel-1"}}`}, + want: true, + }, + { + name: "direct config", + task: model.Task{Type: "video_generate", InputJSON: `{"mode":"video","config":{"model":"veo","baseUrl":"https://example.test","apiKey":"secret"}}`}, + want: true, + }, + { + name: "missing model", + task: model.Task{Type: "video_generate", InputJSON: `{"mode":"video","config":{"channelId":"channel-1"}}`}, + want: false, + }, + { + name: "missing direct credentials", + task: model.Task{Type: "video_generate", InputJSON: `{"mode":"video","config":{"model":"veo","baseUrl":"https://example.test"}}`}, + want: false, + }, + { + name: "non video task", + task: model.Task{Type: "agent_storyboard", InputJSON: `{"mode":"video","config":{"model":"veo","channelId":"channel-1"}}`}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := canRunProviderTask(tt.task); got != tt.want { + t.Fatalf("canRunProviderTask() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProcessTaskRejectsVideoTaskWithoutProviderConfig(t *testing.T) { + svc := &Service{} + _, _, err := svc.processTask(context.Background(), model.Task{ + Type: "video_generate", + InputJSON: `{"mode":"video","config":{"model":""}}`, + }) + if err == nil { + t.Fatal("processTask() error = nil, want missing provider configuration error") + } + if err.Error() != "视频任务缺少可执行的模型配置" { + t.Fatalf("processTask() error = %q, want explicit provider configuration error", err) + } +} + +func TestValidateTaskType(t *testing.T) { + tests := []struct { + name string + taskType string + wantErr bool + }{ + {name: "missing", taskType: "", wantErr: true}, + {name: "canvas image", taskType: "canvas_image"}, + {name: "storyboard rows", taskType: "agent_storyboard_rows"}, + {name: "video operation", taskType: "video_image_to_video"}, + {name: "unknown canvas type", taskType: "canvas_unknown", wantErr: true}, + {name: "unknown type", taskType: "workflow_router", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateTaskType(tt.taskType); (err != nil) != tt.wantErr { + t.Fatalf("validateTaskType(%q) error = %v, wantErr %v", tt.taskType, err, tt.wantErr) + } + }) + } +} + +func TestProcessTaskRejectsUnknownType(t *testing.T) { + _, _, err := (&Service{}).processTask(context.Background(), model.Task{ + Type: "workflow_router", + InputJSON: `{}`, + }) + if err == nil { + t.Fatal("processTask() error = nil, want unsupported task type error") + } + if err.Error() != "不支持的任务类型:workflow_router" { + t.Fatalf("processTask() error = %q, want unsupported task type error", err) + } +} diff --git a/backend/internal/service/task_lifecycle.go b/backend/internal/service/task_lifecycle.go new file mode 100644 index 000000000..5716cd65c --- /dev/null +++ b/backend/internal/service/task_lifecycle.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// taskLifecycleCoordinator 负责任务重试与取消这类会改变任务状态的写命令。 +// 读模型和 worker 执行细节留在各自边界,避免写命令跨层拼接状态更新。 +type taskLifecycleCoordinator struct { + service *Service +} + +func newTaskLifecycleCoordinator(service *Service) *taskLifecycleCoordinator { + return &taskLifecycleCoordinator{service: service} +} + +func (s *Service) taskLifecycle() *taskLifecycleCoordinator { + if s.taskLifecycleCoordinator != nil { + return s.taskLifecycleCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 + return newTaskLifecycleCoordinator(s) +} + +func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.Task, error) { + s := w.service + task, err := s.repo.TaskForUser(userID, id) + if err != nil { + return nil, err + } + if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled { + return nil, errors.New("only failed or cancelled tasks can be retried") + } + if task.ProviderCancelStatus == model.ProviderCancelStatusRequested { + return nil, BadAuthRequest("上游取消状态仍在确认中,请确认费用结果后再重试") + } + if err := s.taskBilling().CheckRetryEligibility(task.BillingOrderID); err != nil { + if errors.Is(err, errTaskBillingReview) { + return nil, BadAuthRequest("上一次调用费用仍在核对中,处理完成前不能重复提交") + } + return nil, err + } + if isContentModerationFailure(task.Error) { + return nil, BadAuthRequest(contentModerationRetryMessage) + } + decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return nil, err + } + var billingInput map[string]any + if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil { + return nil, err + } + if err := s.prepareLogicalTaskRetry(task, billingInput); err != nil { + return nil, err + } + if err := s.requireCustomChannelsForTaskInput(billingInput); err != nil { + return nil, err + } + billingOrder, err := s.taskBillingOrder(userID, task, billingInput) + if err != nil { + return nil, err + } + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil { + return nil, err + } + task, err = s.repo.RetryTaskWithBilling(userID, task, billingOrder, policy.Task.ActiveTaskLimit) + if errors.Is(err, repository.ErrInsufficientCredits) { + return nil, BadAuthRequest("积分不足,请先使用兑换码充值") + } + if errors.Is(err, repository.ErrActiveTaskLimit) { + return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) + } + if errors.Is(err, repository.ErrTaskNotRetryable) { + return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试") + } + if err != nil { + return nil, err + } + if task.SessionID != "" { + session, err := s.repo.SessionForUser(task.UserID, task.SessionID) + if err != nil { + return nil, fmt.Errorf("重试任务时读取会话失败:%w", err) + } + session.Status = model.SessionStatusActive + session.CanvasOpsJSON = "" + if err := s.repo.Save(session); err != nil { + return nil, fmt.Errorf("重试任务时重置会话失败:%w", err) + } + } + _ = s.log(userID, task.ID, "info", "任务已重新入队", "") + return taskForOutput(*task), nil +} + +func (w *taskLifecycleCoordinator) cancelTask(_ context.Context, userID string, id string) (*model.Task, error) { + s := w.service + task, err := s.repo.TaskForUser(userID, id) + if err != nil { + return nil, err + } + // 任务创建后可能已经产生上游费用。取消接口保留用于兼容旧客户端, + // 但不能再改变任务状态、退款或结束关联会话;页面离开只应停止监听。 + if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning || task.ProviderRequestID != "" { + return nil, errors.New("任务已发起,无法取消,请等待任务完成") + } + return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status) +} diff --git a/backend/internal/service/task_output.go b/backend/internal/service/task_output.go new file mode 100644 index 000000000..720551cf3 --- /dev/null +++ b/backend/internal/service/task_output.go @@ -0,0 +1,242 @@ +package service + +import ( + "encoding/json" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// TaskSummary 是任务列表/会话详情的读模型,不直接复用数据库 Task,避免把 +// 渠道模型、供应线路和受保护输入泄露到普通用户接口。 +type TaskSummary struct { + ID string `json:"id"` + SessionID string `json:"sessionId,omitempty"` + ProjectID string `json:"projectId,omitempty"` + Type string `json:"type"` + Status model.TaskStatus `json:"status"` + Stage string `json:"stage"` + Progress int `json:"progress"` + Prompt string `json:"prompt"` + Operation string `json:"operation,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + ProviderRequestID string `json:"providerRequestId,omitempty"` + ProviderCancelStatus model.ProviderCancelStatus `json:"providerCancelStatus,omitempty"` + ProviderCancelError string `json:"providerCancelError,omitempty"` + ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` + ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` + ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + PreviewURL string `json:"previewUrl,omitempty"` + PreviewKind string `json:"previewKind,omitempty"` + Attempts int `json:"attempts"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Billing *TaskBillingSummary `json:"billing,omitempty"` + ClientContext *TaskClientContext `json:"clientContext,omitempty"` +} + +type TaskClientContext struct { + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + BatchIndex int `json:"batchIndex,omitempty"` + BatchCount int `json:"batchCount,omitempty"` +} + +type TaskBillingSummary struct { + AmountMicrocredits int64 `json:"amountMicrocredits"` + Status model.BillingStatus `json:"status"` +} + +func taskSummariesForOutput(tasks []model.Task) []TaskSummary { + return taskSummariesForOutputWithBilling(tasks, nil) +} + +func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary { + result := make([]TaskSummary, 0, len(tasks)) + for _, task := range tasks { + summary := taskSummaryForOutput(task) + if order, ok := orders[task.ID]; ok { + summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status} + if summary.ProviderRequestID == "" { + summary.ProviderRequestID = order.ProviderRequestID + } + } + result = append(result, summary) + } + return result +} + +func taskBillingTaskIDs(tasks []model.Task) []string { + ids := make([]string, 0, len(tasks)) + seen := map[string]struct{}{} + for _, task := range tasks { + if task.BillingOrderID == "" { + continue + } + if _, ok := seen[task.ID]; ok { + continue + } + seen[task.ID] = struct{}{} + ids = append(ids, task.ID) + } + return ids +} + +func taskSummaryForOutput(task model.Task) TaskSummary { + errorCode := "" + if isContentModerationFailure(task.Error) { + errorCode = contentModerationErrorCode + } + previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) + return TaskSummary{ + ID: task.ID, + SessionID: task.SessionID, + ProjectID: task.ProjectID, + Type: task.Type, + Status: task.Status, + Stage: task.Stage, + Progress: task.Progress, + Prompt: truncateRunes(task.Prompt, 500), + Operation: task.Operation, + Provider: task.Provider, + Model: task.Model, + ProviderRequestID: task.ProviderRequestID, + ProviderCancelStatus: task.ProviderCancelStatus, + ProviderCancelError: task.ProviderCancelError, + ProviderCancelAttempts: task.ProviderCancelAttempts, + ProviderCancelRequestedAt: task.ProviderCancelRequestedAt, + ProviderCancelledAt: task.ProviderCancelledAt, + ErrorCode: errorCode, + PreviewURL: previewURL, + PreviewKind: previewKind, + Attempts: task.Attempts, + StartedAt: task.StartedAt, + CompletedAt: task.CompletedAt, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + ClientContext: taskClientContext(task.InputJSON), + } +} + +// 列表只暴露创作页恢复所需的关联 ID,不下发完整任务输入或其他 metadata。 +func taskClientContext(raw string) *TaskClientContext { + var input struct { + Metadata struct { + Source string `json:"source"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + BatchIndex int `json:"batchIndex"` + BatchCount int `json:"batchCount"` + } `json:"metadata"` + } + if json.Unmarshal([]byte(raw), &input) != nil || input.Metadata.Source != "create-page" || input.Metadata.ConversationID == "" || input.Metadata.MessageID == "" { + return nil + } + return &TaskClientContext{ + ConversationID: input.Metadata.ConversationID, + MessageID: input.Metadata.MessageID, + BatchIndex: input.Metadata.BatchIndex, + BatchCount: input.Metadata.BatchCount, + } +} + +// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 +func taskMediaPreview(raw string, taskType string) (string, string) { + if strings.TrimSpace(raw) == "" { + return "", "" + } + var payload any + if json.Unmarshal([]byte(raw), &payload) != nil { + return "", "" + } + defaultKind := "image" + if strings.Contains(strings.ToLower(taskType), "video") { + defaultKind = "video" + } + return findTaskMediaPreview(payload, defaultKind) +} + +func findTaskMediaPreview(value any, hint string) (string, string) { + switch item := value.(type) { + case string: + text := strings.TrimSpace(item) + if !strings.HasPrefix(text, "/api/resources/") && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") { + return "", "" + } + kind := hint + lower := strings.ToLower(text) + if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") { + kind = "video" + } else if kind != "video" { + kind = "image" + } + return text, kind + case []any: + for _, child := range item { + if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" { + return previewURL, previewKind + } + } + case map[string]any: + for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} { + child, exists := item[key] + if !exists { + continue + } + childHint := hint + if key == "video" { + childHint = "video" + } else if key == "images" || key == "image" { + childHint = "image" + } + if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" { + return previewURL, previewKind + } + } + } + return "", "" +} + +func truncateRunes(value string, limit int) string { + text := []rune(value) + if len(text) <= limit { + return value + } + return string(text[:limit]) + "..." +} + +func taskForOutput(task model.Task) *model.Task { + task.InputJSON = publicTaskInputJSON(task.InputJSON) + // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 + task.LogicalModelRevisionID = "" + task.RouteID = "" + task.ChannelModelID = "" + return &task +} + +func publicTaskInputJSON(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + var input map[string]any + if err := json.Unmarshal([]byte(raw), &input); err != nil { + return "" + } + public := map[string]any{} + // 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。 + for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} { + if value, ok := input[key]; ok { + public[key] = value + } + } + if len(public) == 0 { + return "" + } + data, _ := json.Marshal(public) + return string(data) +} diff --git a/backend/internal/service/task_output_test.go b/backend/internal/service/task_output_test.go new file mode 100644 index 000000000..52fe6b540 --- /dev/null +++ b/backend/internal/service/task_output_test.go @@ -0,0 +1,52 @@ +package service + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestTaskForOutputRedactsRoutingAndSecrets(t *testing.T) { + task := model.Task{ + InputJSON: `{"mode":"image","metadata":{"source":"create-page"},"config":{"apiKey":"secret"},"resourceId":"resource-1"}`, + LogicalModelRevisionID: "revision-1", + RouteID: "route-1", + ChannelModelID: "channel-model-1", + } + + output := taskForOutput(task) + if output.LogicalModelRevisionID != "" || output.RouteID != "" || output.ChannelModelID != "" { + t.Fatalf("internal routing fields leaked: %+v", output) + } + var input map[string]any + if err := json.Unmarshal([]byte(output.InputJSON), &input); err != nil { + t.Fatalf("public input is not valid JSON: %v", err) + } + if _, exists := input["config"]; exists { + t.Fatal("provider config must not be exposed") + } + if input["resourceId"] != "resource-1" { + t.Fatalf("resource identity was not preserved: %#v", input) + } +} + +func TestTaskMediaPreviewUsesSafeMediaURLs(t *testing.T) { + previewURL, previewKind := taskMediaPreview(`{"images":["data:image/png;base64,AAAA","/api/resources/resource-1/file"],"video":"https://cdn.example.com/output.mp4"}`, "video") + if previewURL != "/api/resources/resource-1/file" || previewKind != "image" { + t.Fatalf("unexpected preview: url=%q kind=%q", previewURL, previewKind) + } + if previewURL, _ := taskMediaPreview(`{"url":"file:///tmp/output.mp4"}`, "video"); previewURL != "" { + t.Fatalf("unsafe local URL was exposed: %q", previewURL) + } +} + +func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) { + valid := taskClientContext(`{"metadata":{"source":"create-page","conversationId":"conversation-1","messageId":"message-1","batchIndex":2,"batchCount":4}}`) + if valid == nil || valid.ConversationID != "conversation-1" || valid.BatchIndex != 2 { + t.Fatalf("valid client context was not decoded: %+v", valid) + } + if context := taskClientContext(`{"metadata":{"source":"other","conversationId":"conversation-1","messageId":"message-1"}}`); context != nil { + t.Fatalf("unexpected context for non-create-page task: %+v", context) + } +} diff --git a/backend/internal/service/task_route_executor.go b/backend/internal/service/task_route_executor.go new file mode 100644 index 000000000..cbe90eb6f --- /dev/null +++ b/backend/internal/service/task_route_executor.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + + "infinite-canvas/backend/internal/model" +) + +// taskRouteExecutor 负责一次任务执行中的路由提交与失败切换策略。 +// Provider 具体协议仍由 processTask 和各 provider 函数负责;这里仅管理“何时结束当前路由、何时允许换路由”。 +type taskRouteExecutor struct { + port taskRouteExecutionPort +} + +type taskRouteExecutionPort interface { + markRouteAttemptDispatching(attempt *model.RouteAttempt) error + processTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) + refreshTaskProviderState(task *model.Task) error + finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) + nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) + log(userID string, taskID string, level string, message string, payload string) error +} + +type taskRouteExecutionResult struct { + result map[string]interface{} + canvasOps []map[string]interface{} + err error + providerSucceeded bool +} + +func newTaskRouteExecutor(s *Service) *taskRouteExecutor { + return &taskRouteExecutor{port: s} +} + +func (s *Service) routeExecutor() *taskRouteExecutor { + if s.taskRouteExecutor != nil { + return s.taskRouteExecutor + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持内部测试和工具兼容。 + return newTaskRouteExecutor(s) +} + +func (e *taskRouteExecutor) execute(ctx context.Context, task *model.Task, attempt *model.RouteAttempt) (taskRouteExecutionResult, error) { + var execution taskRouteExecutionResult + for { + if dispatchErr := e.port.markRouteAttemptDispatching(attempt); dispatchErr != nil { + execution.err = dispatchErr + break + } + execution.result, execution.canvasOps, execution.err = e.port.processTask(ctx, *task) + if stateErr := e.port.refreshTaskProviderState(task); stateErr != nil { + return taskRouteExecutionResult{}, stateErr + } + e.port.finishTaskRouteAttempt(attempt, task, execution.err) + if execution.err == nil { + break + } + nextAttempt, routeErr := e.port.nextRouteAttemptAfterFailure(task, attempt, execution.err) + if routeErr != nil { + _ = e.port.log(task.UserID, task.ID, "warn", "备用路由不可用,保留原始失败", routeErr.Error()) + break + } + if nextAttempt == nil { + break + } + // Route selection updates the persisted task in the service port. Keep the + // in-memory task aligned as well because the next provider execution reads + // its route from this object before it reaches the repository again. + task.RouteID = nextAttempt.RouteID + attempt = nextAttempt + _ = e.port.log(task.UserID, task.ID, "warn", "上游未创建任务,切换备用能力路由", nextAttempt.RouteID) + } + execution.providerSucceeded = execution.err == nil + return execution, nil +} diff --git a/backend/internal/service/task_route_executor_test.go b/backend/internal/service/task_route_executor_test.go new file mode 100644 index 000000000..46b36c976 --- /dev/null +++ b/backend/internal/service/task_route_executor_test.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + "errors" + "testing" + + "infinite-canvas/backend/internal/model" +) + +type taskRouteExecutionPortStub struct { + processCalls []string + dispatchCalls []string + finished []string + logs []string + nextAttempts map[string]*model.RouteAttempt + nextErrors map[string]error + processResults map[string]taskRouteExecutionStubResult +} + +type taskRouteExecutionStubResult struct { + result map[string]interface{} + canvasOps []map[string]interface{} + err error +} + +func (p *taskRouteExecutionPortStub) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { + p.dispatchCalls = append(p.dispatchCalls, attempt.RouteID) + return nil +} + +func (p *taskRouteExecutionPortStub) processTask(_ context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { + routeID := task.RouteID + p.processCalls = append(p.processCalls, routeID) + result := p.processResults[routeID] + return result.result, result.canvasOps, result.err +} + +func (p *taskRouteExecutionPortStub) refreshTaskProviderState(*model.Task) error { + return nil +} + +func (p *taskRouteExecutionPortStub) finishTaskRouteAttempt(attempt *model.RouteAttempt, _ *model.Task, taskErr error) { + if taskErr == nil { + p.finished = append(p.finished, attempt.RouteID+":succeeded") + return + } + p.finished = append(p.finished, attempt.RouteID+":failed") +} + +func (p *taskRouteExecutionPortStub) nextRouteAttemptAfterFailure(_ *model.Task, attempt *model.RouteAttempt, _ error) (*model.RouteAttempt, error) { + if err := p.nextErrors[attempt.RouteID]; err != nil { + return nil, err + } + return p.nextAttempts[attempt.RouteID], nil +} + +func (p *taskRouteExecutionPortStub) log(_ string, _ string, _ string, message string, payload string) error { + p.logs = append(p.logs, message+":"+payload) + return nil +} + +func TestTaskRouteExecutorSwitchesOnlyWhenNextRouteIsAvailable(t *testing.T) { + first := &model.RouteAttempt{RouteID: "route-a"} + second := &model.RouteAttempt{RouteID: "route-b"} + p := &taskRouteExecutionPortStub{ + nextAttempts: map[string]*model.RouteAttempt{"route-a": second}, + nextErrors: map[string]error{}, + processResults: map[string]taskRouteExecutionStubResult{ + "route-a": {err: errors.New("rejected before job")}, + "route-b": {result: map[string]interface{}{"ok": true}, canvasOps: []map[string]interface{}{{"type": "add_node"}}}, + }, + } + task := &model.Task{ID: "task-1", UserID: "user-1", RouteID: "route-a"} + + result, err := (&taskRouteExecutor{port: p}).execute(context.Background(), task, first) + if err != nil { + t.Fatalf("execute() error = %v", err) + } + if !result.providerSucceeded || result.err != nil { + t.Fatalf("unexpected execution result: %+v", result) + } + if len(p.processCalls) != 2 || p.processCalls[0] != "route-a" || p.processCalls[1] != "route-b" { + t.Fatalf("process calls = %v, want route-a then route-b", p.processCalls) + } + if len(p.finished) != 2 || p.finished[0] != "route-a:failed" || p.finished[1] != "route-b:succeeded" { + t.Fatalf("finished attempts = %v", p.finished) + } + if len(p.logs) != 1 || p.logs[0] != "上游未创建任务,切换备用能力路由:route-b" { + t.Fatalf("route switch logs = %v", p.logs) + } +} + +func TestTaskRouteExecutorStopsWhenRouteSwitchFails(t *testing.T) { + first := &model.RouteAttempt{RouteID: "route-a"} + routeError := errors.New("no usable fallback") + p := &taskRouteExecutionPortStub{ + nextAttempts: map[string]*model.RouteAttempt{}, + nextErrors: map[string]error{"route-a": routeError}, + processResults: map[string]taskRouteExecutionStubResult{ + "route-a": {err: errors.New("rejected before job")}, + }, + } + task := &model.Task{ID: "task-1", UserID: "user-1", RouteID: "route-a"} + + result, err := (&taskRouteExecutor{port: p}).execute(context.Background(), task, first) + if err != nil { + t.Fatalf("execute() error = %v", err) + } + if result.providerSucceeded || !errors.Is(result.err, p.processResults["route-a"].err) { + t.Fatalf("unexpected execution result: %+v", result) + } + if len(p.processCalls) != 1 || len(p.finished) != 1 || len(p.logs) != 1 { + t.Fatalf("expected one failed route and one warning, calls=%v finished=%v logs=%v", p.processCalls, p.finished, p.logs) + } +} diff --git a/backend/internal/service/task_terminal.go b/backend/internal/service/task_terminal.go new file mode 100644 index 000000000..a9ba83a3d --- /dev/null +++ b/backend/internal/service/task_terminal.go @@ -0,0 +1,235 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// taskTerminalCoordinator 收敛任务进入终态后的业务策略。 +// +// 任务执行本身仍由 Service 编排,但“失败如何记账、取消如何投影到会话、 +// 成功后如何收尾”必须保持一致,不能散落在 provider/worker 分支中。 +type taskTerminalCoordinator struct { + repo taskTerminalRepository + billing taskBillingLifecycle + replay taskReplayLifecycle + sessions taskSessionLifecycle + logger taskLifecycleLogger + outputs taskOutputLifecycle + userFacingMessage func(error) string +} + +type taskTerminalRepository interface { + Task(id string) (*model.Task, error) + UpdateTaskTerminalState(id string, expected model.TaskStatus, status model.TaskStatus, stage string, errorText string, completedAt time.Time) (bool, error) +} + +type taskBillingLifecycle interface { + MarkBillingUncertain(orderID string, errorText string) error + RefundBilling(orderID string, errorText string) error + BillingFailureRequiresReview(orderID string, taskID string, err error) bool + SettleBilling(orderID string, providerRequestID string) error +} + +type taskReplayLifecycle interface { + finalizeTaskTextReplay(taskID string, status model.TaskStatus) error +} + +type taskSessionLifecycle interface { + markSessionFailed(task model.Task, message string) error +} + +type taskLifecycleLogger interface { + log(userID string, taskID string, level string, message string, payload string) error +} + +type taskOutputLifecycle interface { + RegisterTaskOutputFromTask(task model.Task) error +} + +func newTaskTerminalCoordinator(s *Service) *taskTerminalCoordinator { + return &taskTerminalCoordinator{ + repo: s.repo, + billing: s.taskBilling(), + replay: s, + sessions: s, + logger: s, + outputs: s, + userFacingMessage: s.UserFacingErrorMessage, + } +} + +func (s *Service) terminalCoordinator() *taskTerminalCoordinator { + if s.taskTerminalCoordinator != nil { + return s.taskTerminalCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 + return newTaskTerminalCoordinator(s) +} + +func (c *taskTerminalCoordinator) markPreparationFailure(task *model.Task, stage string, err error, billingUncertain bool, refundReason string) error { + task.Status = model.TaskStatusFailed + task.Stage = stage + task.Error = c.userFacingMessage(err) + if terminalErr := c.markTerminalState(task); terminalErr != nil { + return errors.Join(err, terminalErr) + } + var billingErr error + if billingUncertain { + billingErr = c.billing.MarkBillingUncertain(task.BillingOrderID, task.Error) + } else { + billingErr = c.billing.RefundBilling(task.BillingOrderID, refundReason) + } + if billingErr != nil { + return errors.Join(err, fmt.Errorf("任务计费收尾失败:%w", billingErr)) + } + return err +} + +// handleExecutionFailure 返回 nil 仅表示取消已被正常收尾;普通失败仍返回原始错误, +// 让 worker 保留重试/监控所需的失败语义。 +func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err error, providerSucceeded bool, channelSlotFailedBeforeRequest bool) error { + if errors.Is(err, context.Canceled) { + task.Status = model.TaskStatusCancelled + task.Stage = "任务已取消" + task.Error = "任务已取消" + if terminalErr := c.markTerminalState(task); terminalErr != nil { + return terminalErr + } + var billingErr error + if channelSlotFailedBeforeRequest { + billingErr = c.billing.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出") + } else { + billingErr = c.billing.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确") + } + sessionErr := c.sessions.markSessionFailed(*task, "会话任务已取消。") + c.finalizeReplay(task, model.TaskStatusCancelled, "文本回放草稿归并失败") + _ = c.logger.log(task.UserID, task.ID, "warn", "任务已取消", "") + var resultErr error + if billingErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务取消后的计费收尾失败:%w", billingErr)) + } + if sessionErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务取消后的会话状态更新失败:%w", sessionErr)) + } + return resultErr + } + + task.Status = model.TaskStatusFailed + task.Stage = "任务失败" + task.Error = c.userFacingMessage(err) + if terminalErr := c.markTerminalState(task); terminalErr != nil { + return errors.Join(err, terminalErr) + } + c.finalizeReplay(task, model.TaskStatusFailed, "文本回放草稿归并失败") + var billingErr error + if providerSucceeded || (!channelSlotFailedBeforeRequest && c.billing.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err)) { + billingErr = c.billing.MarkBillingUncertain(task.BillingOrderID, task.Error) + } else { + billingErr = c.billing.RefundBilling(task.BillingOrderID, task.Error) + } + sessionErr := c.sessions.markSessionFailed(*task, task.Error) + _ = c.logger.log(task.UserID, task.ID, "error", "任务处理失败", task.Error) + resultErr := err + if billingErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务计费收尾失败:%w", billingErr)) + } + if sessionErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务失败后的会话状态更新失败:%w", sessionErr)) + } + return resultErr +} + +func (c *taskTerminalCoordinator) handleCancelledResult(task model.Task) error { + c.finalizeReplay(&task, model.TaskStatusCancelled, "文本回放草稿归并失败") + billingErr := c.billing.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") + sessionErr := c.sessions.markSessionFailed(task, "会话任务已取消。") + _ = c.logger.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "") + var resultErr error + if billingErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("丢弃已生成结果后的计费收尾失败:%w", billingErr)) + } + if sessionErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("丢弃已生成结果后的会话状态更新失败:%w", sessionErr)) + } + return resultErr +} + +// handleResultPersistenceFailure 处理“上游成功但本地结果保存失败”的收尾。 +// 返回 handled=true 表示并发取消已被识别并正常收尾,调用方不应再返回保存错误。 +func (c *taskTerminalCoordinator) handleResultPersistenceFailure(task *model.Task, saveErr error) (handled bool, err error) { + if errors.Is(saveErr, repository.ErrTaskStateConflict) { + latest, latestErr := c.repo.Task(task.ID) + if latestErr == nil && latest.Status == model.TaskStatusCancelled { + if billingErr := c.handleCancelledResult(*latest); billingErr != nil { + return true, billingErr + } + return true, nil + } + } + + task.Status = model.TaskStatusFailed + task.Stage = "任务结果保存失败" + task.Error = c.userFacingMessage(saveErr) + if terminalErr := c.markTerminalState(task); terminalErr != nil { + return false, errors.Join(saveErr, terminalErr) + } + c.finalizeReplay(task, model.TaskStatusFailed, "文本回放草稿归并失败") + billingErr := c.billing.MarkBillingUncertain(task.BillingOrderID, "上游已成功但任务结果未保存:"+task.Error) + sessionErr := c.sessions.markSessionFailed(*task, task.Error) + _ = c.logger.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error) + resultErr := saveErr + if billingErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务结果保存失败后的计费收尾失败:%w", billingErr)) + } + if sessionErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("任务结果保存失败后的会话状态更新失败:%w", sessionErr)) + } + return false, resultErr +} + +func (c *taskTerminalCoordinator) handleSuccess(task *model.Task) error { + c.finalizeReplay(task, model.TaskStatusSucceeded, "文本回放窗口更新失败") + var completionErr error + completedTask, fetchErr := c.repo.Task(task.ID) + if fetchErr != nil { + completionErr = fmt.Errorf("任务成功后读取任务产物失败:%w", fetchErr) + _ = c.logger.log(task.UserID, task.ID, "error", "任务成功但读取任务产物失败", fetchErr.Error()) + } else { + if registerErr := c.outputs.RegisterTaskOutputFromTask(*completedTask); registerErr != nil { + // 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。 + _ = c.logger.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error()) + completionErr = fmt.Errorf("任务成功后的项目产物登记失败:%w", registerErr) + } + } + if err := c.billing.SettleBilling(task.BillingOrderID, ""); err != nil { + uncertainErr := c.billing.MarkBillingUncertain(task.BillingOrderID, "生成成功但积分结算失败:"+err.Error()) + _ = c.logger.log(task.UserID, task.ID, "error", "积分结算失败,已进入待核对", err.Error()) + completionErr = errors.Join(completionErr, fmt.Errorf("积分结算失败:%w", err)) + if uncertainErr != nil { + completionErr = errors.Join(completionErr, fmt.Errorf("记录计费待核对状态失败:%w", uncertainErr)) + } + } + _ = c.logger.log(task.UserID, task.ID, "info", "任务完成,结果已持久化", "") + return completionErr +} + +func (c *taskTerminalCoordinator) markTerminalState(task *model.Task) error { + completedAt := time.Now() + task.CompletedAt = &completedAt + if _, err := c.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, completedAt); err != nil { + return fmt.Errorf("写入任务终态失败:%w", err) + } + return nil +} + +func (c *taskTerminalCoordinator) finalizeReplay(task *model.Task, status model.TaskStatus, message string) { + if err := c.replay.finalizeTaskTextReplay(task.ID, status); err != nil { + _ = c.logger.log(task.UserID, task.ID, "error", message, err.Error()) + } +} diff --git a/backend/internal/service/task_terminal_test.go b/backend/internal/service/task_terminal_test.go new file mode 100644 index 000000000..dd5215ffc --- /dev/null +++ b/backend/internal/service/task_terminal_test.go @@ -0,0 +1,270 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +type taskTerminalRepositoryStub struct { + task *model.Task + taskError error + terminalCalls int + terminalError error +} + +func (r *taskTerminalRepositoryStub) Task(string) (*model.Task, error) { + if r.taskError != nil { + return nil, r.taskError + } + if r.task == nil { + return nil, errors.New("task not found") + } + copy := *r.task + return ©, nil +} + +func (r *taskTerminalRepositoryStub) UpdateTaskTerminalState(_ string, _ model.TaskStatus, status model.TaskStatus, stage string, errorText string, completedAt time.Time) (bool, error) { + r.terminalCalls++ + if r.terminalError != nil { + return false, r.terminalError + } + if r.task != nil { + r.task.Status = status + r.task.Stage = stage + r.task.Error = errorText + r.task.CompletedAt = &completedAt + } + return true, nil +} + +type taskTerminalBillingStub struct { + uncertain []string + refund []string + settleCalls int + review bool + settleError error + uncertainError error + refundError error +} + +func (b *taskTerminalBillingStub) MarkBillingUncertain(_ string, reason string) error { + b.uncertain = append(b.uncertain, reason) + return b.uncertainError +} + +func (b *taskTerminalBillingStub) RefundBilling(_ string, reason string) error { + b.refund = append(b.refund, reason) + return b.refundError +} + +func (b *taskTerminalBillingStub) BillingFailureRequiresReview(string, string, error) bool { + return b.review +} + +func (b *taskTerminalBillingStub) SettleBilling(string, string) error { + b.settleCalls++ + return b.settleError +} + +type taskTerminalReplayStub struct { + statuses []model.TaskStatus +} + +func (r *taskTerminalReplayStub) finalizeTaskTextReplay(_ string, status model.TaskStatus) error { + r.statuses = append(r.statuses, status) + return nil +} + +type taskTerminalSessionStub struct { + messages []string + err error +} + +func (s *taskTerminalSessionStub) markSessionFailed(_ model.Task, message string) error { + s.messages = append(s.messages, message) + return s.err +} + +type taskTerminalLoggerStub struct { + messages []string +} + +func (l *taskTerminalLoggerStub) log(_ string, _ string, _ string, message string, _ string) error { + l.messages = append(l.messages, message) + return nil +} + +type taskTerminalOutputStub struct { + calls int + err error +} + +func (o *taskTerminalOutputStub) RegisterTaskOutputFromTask(model.Task) error { + o.calls++ + return o.err +} + +func newTaskTerminalCoordinatorForTest(repo taskTerminalRepository, billing *taskTerminalBillingStub, replay *taskTerminalReplayStub, sessions *taskTerminalSessionStub, logger *taskTerminalLoggerStub, outputs *taskTerminalOutputStub) *taskTerminalCoordinator { + return &taskTerminalCoordinator{ + repo: repo, + billing: billing, + replay: replay, + sessions: sessions, + logger: logger, + outputs: outputs, + userFacingMessage: func(err error) string { return "public: " + err.Error() }, + } +} + +func TestTaskTerminalCoordinatorHandlesCancellation(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1", SessionID: "session-1"} + repo := &taskTerminalRepositoryStub{task: task} + billing := &taskTerminalBillingStub{} + replay := &taskTerminalReplayStub{} + sessions := &taskTerminalSessionStub{} + logger := &taskTerminalLoggerStub{} + coordinator := newTaskTerminalCoordinatorForTest(repo, billing, replay, sessions, logger, &taskTerminalOutputStub{}) + + if err := coordinator.handleExecutionFailure(task, context.Canceled, false, false); err != nil { + t.Fatalf("handleExecutionFailure() error = %v", err) + } + if task.Status != model.TaskStatusCancelled || task.Error != "任务已取消" { + t.Fatalf("unexpected cancelled task state: status=%s error=%q", task.Status, task.Error) + } + if len(billing.uncertain) != 1 || len(billing.refund) != 0 { + t.Fatalf("unexpected billing actions: uncertain=%v refund=%v", billing.uncertain, billing.refund) + } + if len(replay.statuses) != 1 || replay.statuses[0] != model.TaskStatusCancelled { + t.Fatalf("unexpected replay statuses: %v", replay.statuses) + } + if len(sessions.messages) != 1 || len(logger.messages) != 1 || repo.terminalCalls != 1 { + t.Fatalf("expected cancellation side effects, replay=%v session=%v logs=%v terminalCalls=%d", replay.statuses, sessions.messages, logger.messages, repo.terminalCalls) + } +} + +func TestTaskTerminalCoordinatorRefundsFailureBeforeProviderRequest(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1", SessionID: "session-1"} + repo := &taskTerminalRepositoryStub{task: task} + billing := &taskTerminalBillingStub{} + replay := &taskTerminalReplayStub{} + sessions := &taskTerminalSessionStub{} + logger := &taskTerminalLoggerStub{} + coordinator := newTaskTerminalCoordinatorForTest(repo, billing, replay, sessions, logger, &taskTerminalOutputStub{}) + failure := errors.New("provider unavailable") + + if err := coordinator.handleExecutionFailure(task, failure, false, true); !errors.Is(err, failure) { + t.Fatalf("handleExecutionFailure() error = %v, want %v", err, failure) + } + if task.Status != model.TaskStatusFailed || task.Error != "public: provider unavailable" { + t.Fatalf("unexpected failed task state: status=%s error=%q", task.Status, task.Error) + } + if len(billing.refund) != 1 || len(billing.uncertain) != 0 { + t.Fatalf("unexpected billing actions: uncertain=%v refund=%v", billing.uncertain, billing.refund) + } + if len(replay.statuses) != 1 || replay.statuses[0] != model.TaskStatusFailed { + t.Fatalf("unexpected replay statuses: %v", replay.statuses) + } +} + +func TestTaskTerminalCoordinatorReturnsTerminalStateWriteError(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} + terminalError := errors.New("database unavailable") + repo := &taskTerminalRepositoryStub{task: task, terminalError: terminalError} + coordinator := newTaskTerminalCoordinatorForTest( + repo, + &taskTerminalBillingStub{}, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{}, + &taskTerminalLoggerStub{}, + &taskTerminalOutputStub{}, + ) + + providerError := errors.New("provider unavailable") + if err := coordinator.handleExecutionFailure(task, providerError, false, true); !errors.Is(err, providerError) || !errors.Is(err, terminalError) { + t.Fatalf("handleExecutionFailure() error = %v, want provider and terminal errors", err) + } +} + +func TestTaskTerminalCoordinatorReturnsBillingRefundError(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} + billingError := errors.New("billing database unavailable") + billing := &taskTerminalBillingStub{refundError: billingError} + coordinator := newTaskTerminalCoordinatorForTest( + &taskTerminalRepositoryStub{task: task}, + billing, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{}, + &taskTerminalLoggerStub{}, + &taskTerminalOutputStub{}, + ) + + providerError := errors.New("provider unavailable") + if err := coordinator.handleExecutionFailure(task, providerError, false, true); !errors.Is(err, providerError) || !errors.Is(err, billingError) { + t.Fatalf("handleExecutionFailure() error = %v, want provider and billing errors", err) + } +} + +func TestTaskTerminalCoordinatorReturnsSessionProjectionError(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1", SessionID: "session-1"} + sessionError := errors.New("session database unavailable") + coordinator := newTaskTerminalCoordinatorForTest( + &taskTerminalRepositoryStub{task: task}, + &taskTerminalBillingStub{}, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{err: sessionError}, + &taskTerminalLoggerStub{}, + &taskTerminalOutputStub{}, + ) + + providerError := errors.New("provider unavailable") + if err := coordinator.handleExecutionFailure(task, providerError, false, true); !errors.Is(err, providerError) || !errors.Is(err, sessionError) { + t.Fatalf("handleExecutionFailure() error = %v, want provider and session errors", err) + } +} + +func TestTaskTerminalCoordinatorReturnsOutputRegistrationErrorAfterSuccess(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} + outputError := errors.New("project output unavailable") + outputs := &taskTerminalOutputStub{err: outputError} + billing := &taskTerminalBillingStub{} + coordinator := newTaskTerminalCoordinatorForTest( + &taskTerminalRepositoryStub{task: task}, + billing, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{}, + &taskTerminalLoggerStub{}, + outputs, + ) + + if err := coordinator.handleSuccess(task); !errors.Is(err, outputError) { + t.Fatalf("handleSuccess() error = %v, want %v", err, outputError) + } + if outputs.calls != 1 || billing.settleCalls != 1 { + t.Fatalf("expected output registration and billing settlement, calls=%d settle=%d", outputs.calls, billing.settleCalls) + } +} + +func TestTaskTerminalCoordinatorReturnsTaskReadErrorAfterSuccess(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} + taskError := errors.New("task database unavailable") + billing := &taskTerminalBillingStub{} + coordinator := newTaskTerminalCoordinatorForTest( + &taskTerminalRepositoryStub{taskError: taskError}, + billing, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{}, + &taskTerminalLoggerStub{}, + &taskTerminalOutputStub{}, + ) + + if err := coordinator.handleSuccess(task); !errors.Is(err, taskError) { + t.Fatalf("handleSuccess() error = %v, want %v", err, taskError) + } + if billing.settleCalls != 1 { + t.Fatalf("billing settlement calls = %d, want 1", billing.settleCalls) + } +} diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go new file mode 100644 index 000000000..468b01352 --- /dev/null +++ b/backend/internal/service/task_worker.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// taskWorkerCoordinator 收敛任务领取、租约维护和执行结果落库,避免 Service 同时承担 worker 生命周期与业务命令。 +type taskWorkerCoordinator struct { + service *Service +} + +func newTaskWorkerCoordinator(service *Service) *taskWorkerCoordinator { + return &taskWorkerCoordinator{service: service} +} + +func (s *Service) taskWorker() *taskWorkerCoordinator { + if s.taskWorkerCoordinator != nil { + return s.taskWorkerCoordinator + } + // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 + return newTaskWorkerCoordinator(s) +} + +func (w *taskWorkerCoordinator) start() { + s := w.service + s.startTextReplayCleanup() + s.startProviderCancellationReconciliation() + s.startBillingReviewAudit() + go func() { + slots := make(chan struct{}, maxChannelConcurrencyLimit) + dispatch := func() { + setting, err := s.runtimeConcurrencySetting() + if err != nil { + return + } + workerConcurrency := setting.WorkerConcurrency + for len(slots) < workerConcurrency { + releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute) + if err != nil || !acquired { + return + } + task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) + if err != nil || task == nil { + releaseGlobal() + return + } + slots <- struct{}{} + go func(task *model.Task) { + defer func() { <-slots; releaseGlobal() }() + if err := w.processClaimedTask(task); err != nil { + _ = s.log(task.UserID, task.ID, "error", "后台任务处理失败", err.Error()) + } + }(task) + } + } + + dispatch() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for range ticker.C { + dispatch() + } + }() +} + +func (w *taskWorkerCoordinator) processNextTask() error { + s := w.service + task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) + if err != nil || task == nil { + return err + } + return w.processClaimedTask(task) +} + +func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error { + s := w.service + terminal := s.terminalCoordinator() + _ = s.log(task.UserID, task.ID, "info", "后端任务开始处理", "") + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task)) + defer cancel() + leaseDone := make(chan struct{}) + leaseLost := make(chan error, 1) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := s.repo.RenewTaskLease(task.ID, s.workerID, 45*time.Second); err != nil { + leaseLost <- err + cancel() + return + } + case <-leaseDone: + return + } + } + }() + defer close(leaseDone) + s.registerActiveTask(task.ID, cancel) + defer s.unregisterActiveTask(task.ID) + + task.Stage = "调用生成模型" + task.Progress = 35 + if err := s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress); err != nil { + return fmt.Errorf("更新任务进度失败,任务暂未调用上游:%w", err) + } + routeAttempt, err := s.beginTaskRouteAttempt(task) + if err != nil { + return terminal.markPreparationFailure(task, "路由准备失败", err, isRouteDispatchUncertain(err), "路由准备失败,上游请求未发出") + } + if err := s.taskBilling().MarkBillingRunning(task.BillingOrderID); err != nil { + return terminal.markPreparationFailure(task, "计费准备失败", err, false, "计费准备失败,上游请求未发出") + } + routeResult, stateErr := s.routeExecutor().execute(ctx, task, routeAttempt) + if stateErr != nil { + return stateErr + } + result, canvasOps, err := routeResult.result, routeResult.canvasOps, routeResult.err + providerSucceeded := routeResult.providerSucceeded + if err == nil { + result, err = s.persistGeneratedMediaResult(task.UserID, result) + } + if err == nil { + _, err = s.finalizeCharacterTurnaroundTask(*task, result) + } + if err != nil { + channelSlotFailedBeforeRequest := false + if code, _ := ChannelSlotFailureDetails(err); code != "" { + channelSlotFailedBeforeRequest = true + } + select { + case leaseErr := <-leaseLost: + _ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.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 + } + err = errors.New(taskTimeoutMessage(task.Type)) + } + return terminal.handleExecutionFailure(task, err, providerSucceeded, channelSlotFailedBeforeRequest) + } + latest, err := s.repo.Task(task.ID) + if err != nil { + return err + } + if latest.Status == model.TaskStatusCancelled { + return terminal.handleCancelledResult(*latest) + } + resultJSON, err := json.Marshal(result) + if err != nil { + _, terminalErr := terminal.handleResultPersistenceFailure(task, fmt.Errorf("序列化任务结果失败:%w", err)) + return terminalErr + } + opsJSON, err := json.Marshal(canvasOps) + if err != nil { + _, terminalErr := terminal.handleResultPersistenceFailure(task, fmt.Errorf("序列化画布操作失败:%w", err)) + return terminalErr + } + if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, opsJSON, len(canvasOps) > 0); err != nil { + _, terminalErr := terminal.handleResultPersistenceFailure(task, err) + return terminalErr + } + return terminal.handleSuccess(task) +} + +func taskFailureMessage(err error) string { + if err == nil { + return "任务处理失败" + } + return truncateRunes(err.Error(), 2_000) +} + +func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration { + switch { + case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows": + return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute + case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"): + return max(time.Duration(policy.VideoTimeoutMinutes)*time.Minute, 5*time.Minute) + case strings.HasPrefix(taskType, "canvas_image"): + return time.Duration(policy.ImageTimeoutMinutes) * time.Minute + case strings.HasPrefix(taskType, "canvas_audio"): + return time.Duration(policy.AudioTimeoutMinutes) * time.Minute + case strings.HasPrefix(taskType, "canvas_text"): + return time.Duration(policy.TextTimeoutMinutes) * time.Minute + default: + return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute + } +} + +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_")) { + return false + } + var input canvasGenerationInput + if json.Unmarshal([]byte(decryptedInput), &input) != nil { + return false + } + resolved, resolveErr := s.resolveProviderConfig(input.Config) + return resolveErr == nil && resolved.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) +} + +func taskTimeoutMessage(taskType string) string { + if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") { + return "视频生成等待超时,请稍后到任务中心查看或重试。" + } + if strings.HasPrefix(taskType, "canvas_image") { + return "图片生成等待超时,请稍后重试。" + } + return "任务执行超时,请稍后重试。" +} diff --git a/web/package.json b/web/package.json index 01250b92a..b4a112262 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index f84fc5116..5792dbb52 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react"; +import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react"; import { Button, InputNumber, Segmented, Select } from "antd"; import { ModelPicker } from "@/components/model-picker"; @@ -23,7 +23,6 @@ type CanvasConfigNodePanelProps = { inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number; characterCount: number }; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string) => void; - onStop: (nodeId: string) => void; onComposerToggle: () => void; workspaceMode?: CanvasWorkspaceMode; }; @@ -40,7 +39,7 @@ const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperat { label: "版本对比", value: "compare_versions" }, ]; -export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) { +export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) { const globalConfig = useEffectiveConfig(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); @@ -188,17 +187,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC - {!submissionUncertain ? ( - - ) : null} ) : null} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 8a66a39fe..e76d0e7f0 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react"; +import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video } from "lucide-react"; import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; @@ -30,7 +30,6 @@ type CanvasNodePromptPanelProps = { onPromptChange: (nodeId: string, prompt: string) => void; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; - onStop: (nodeId: string) => void; mentionReferences?: CanvasResourceReference[]; onImageSettingsOpenChange?: (open: boolean) => void; workspaceMode?: CanvasWorkspaceMode; @@ -47,7 +46,7 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12; const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; const PROMPT_EDITOR_MAX_LINES = 8; -export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { +export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; @@ -204,13 +203,12 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const renderSubmitButton = (expanded: boolean) => { const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString(); - const actionLabel = isRunning ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; + const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; return ( ); diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index cab4e3f97..3d6e1b794 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -49,7 +49,6 @@ type CanvasNodeProps = { onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (node: CanvasNodeData) => void; onRetry?: (node: CanvasNodeData) => void; - onCancelTask?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onOpenVersions?: (node: CanvasNodeData) => void; onViewImage?: (node: CanvasNodeData) => void; @@ -93,7 +92,6 @@ export const CanvasNode = React.memo(function CanvasNode({ onToggleBatch, onSetBatchPrimary, onRetry, - onCancelTask, onOpenTaskDetails, onOpenVersions, onViewImage, @@ -360,7 +358,6 @@ export const CanvasNode = React.memo(function CanvasNode({ onContentChange={onContentChange} onStopEditing={() => setIsEditingContent(false)} onRetry={onRetry} - onCancelTask={onCancelTask} onOpenTaskDetails={onOpenTaskDetails} onToggleBatch={() => onToggleBatch?.(data.id)} reduceMediaEffects={reduceMediaEffects} @@ -515,7 +512,6 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp previous.onToggleBatch === next.onToggleBatch && previous.onSetBatchPrimary === next.onSetBatchPrimary && previous.onRetry === next.onRetry && - previous.onCancelTask === next.onCancelTask && previous.onOpenTaskDetails === next.onOpenTaskDetails && previous.onOpenVersions === next.onOpenVersions && previous.onViewImage === next.onViewImage && diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx index 9f7803751..3845c9d94 100644 --- a/web/src/components/canvas/canvas-script-node.tsx +++ b/web/src/components/canvas/canvas-script-node.tsx @@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, use import { Button, Checkbox, Dropdown, Input, InputNumber, Modal, Segmented, Select, Table, Tooltip } from "antd"; import type { MenuProps } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react"; +import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { ModelPicker } from "@/components/model-picker"; @@ -74,7 +74,6 @@ export function CanvasScriptNodeContent({ onRetryBatch, onRetryBatchItem, onStopBatch, - onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, @@ -104,7 +103,6 @@ export function CanvasScriptNodeContent({ onRetryBatch: (batchId: string) => void; onRetryBatchItem: (batchId: string, itemId: string) => void; onStopBatch: (batchId: string) => void; - onCancelBatchItem: (batchId: string, itemId: string) => void; onAddRow: () => void; onRemoveRow: (rowId: string) => void; onUpdateRow: (rowId: string, patch: Partial) => void; @@ -270,7 +268,7 @@ export function CanvasScriptNodeContent({ {batch ? ( setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden> - onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} /> + onRetryBatchItem(batch.id, itemId)} /> ) : null} @@ -525,7 +523,7 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto ); } -function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void; onCancelItem: (itemId: string) => void }) { +function GenerationBatchDetails({ batch, rows, onRetryItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void }) { const shotByRowId = new Map(rows.map((row) => [row.id, row.shotNumber])); return (
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> @@ -535,7 +533,6 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba
{batch.items.map((item) => { - const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running")); const requiresPromptChange = isContentModerationError(item.errorDetails); return (
@@ -556,18 +553,6 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba ) : null} - {cancellable ? ( - - - - ) : null}
); })} diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index a2d4fc093..ccebc5657 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -312,7 +312,10 @@ export function modelGroupReferenceLimits(config: AiConfig, selected: string, ca export function inferVideoOperation(input: ModelInputSummary) { const visualInputCount = input.imageCount + input.characterCount; - if (input.audioCount > 0 || input.videoCount > 0 || visualInputCount > 2) return "reference_to_video"; + // 音频参考有独立的协议能力;不能先归类为 reference_to_video, + // 否则只声明 audio_to_video 的细分模型永远无法被兼容路由选中。 + if (input.audioCount > 0) return "audio_to_video"; + if (input.videoCount > 0 || visualInputCount > 2) return "reference_to_video"; if (visualInputCount > 0) return "image_to_video"; return "text_to_video"; } diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx index b8684b49f..25a983773 100644 --- a/web/src/pages/canvas/canvas-project-world-layers.tsx +++ b/web/src/pages/canvas/canvas-project-world-layers.tsx @@ -64,7 +64,6 @@ type CanvasProjectWorldLayersProps = { onToggleBatch: (nodeId: string) => void; onSetBatchPrimary: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void; - onCancelTask: (node: CanvasNodeData) => void; onOpenTaskDetails: (node: CanvasNodeData) => void; onOpenVersions: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; @@ -162,7 +161,6 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p onToggleBatch={props.onToggleBatch} onSetBatchPrimary={props.onSetBatchPrimary} onRetry={props.onRetry} - onCancelTask={props.onCancelTask} onOpenTaskDetails={props.onOpenTaskDetails} onOpenVersions={props.onOpenVersions} onViewImage={props.onViewImage} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index c7df57a1a..50986f07f 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -413,8 +413,6 @@ function InfiniteCanvasPage() { const { applyGenerationTaskResult, bindGenerationTask, - cancelNodeTask, - confirmStopGeneration, finishGenerationRequest, openNodeTaskDetails, runningNodeId, @@ -1417,7 +1415,7 @@ function InfiniteCanvasPage() { generateNodeRef.current = handleGenerateNode; }, [handleGenerateNode]); - const { cancelSubmittedBatchItem, enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({ + const { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({ projectId, projectLoaded, nodes, @@ -1532,7 +1530,6 @@ function InfiniteCanvasPage() { onPromptChange={handleNodePromptChange} onConfigChange={handleConfigNodeChange} onGenerate={handleGenerateNode} - onStop={confirmStopGeneration} workspaceMode={workspaceMode} onImageSettingsOpenChange={(open) => { setNodeImageSettingsOpen(open); @@ -1541,7 +1538,7 @@ function InfiniteCanvasPage() { /> ); }, - [configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode], + [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode], ); const renderCanvasNodeContent = useCallback( @@ -1575,7 +1572,6 @@ function InfiniteCanvasPage() { onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)} onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)} onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)} - onCancelBatchItem={(batchId, itemId) => cancelSubmittedBatchItem(contentNode.id, batchId, itemId)} onAddRow={() => addScriptRow(contentNode.id)} onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)} onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)} @@ -1614,7 +1610,6 @@ function InfiniteCanvasPage() { inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])} onConfigChange={handleConfigNodeChange} onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))} - onStop={confirmStopGeneration} onGenerate={(nodeId) => { const target = nodesRef.current.find((item) => item.id === nodeId); void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); @@ -1625,9 +1620,7 @@ function InfiniteCanvasPage() { }, [ addScriptRow, - cancelSubmittedBatchItem, configInputsById, - confirmStopGeneration, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, @@ -1926,7 +1919,6 @@ function InfiniteCanvasPage() { onToggleBatch={toggleBatchExpanded} onSetBatchPrimary={setBatchPrimary} onRetry={retryCanvasNode} - onCancelTask={cancelNodeTask} onOpenTaskDetails={openCanvasNodeTaskDetails} onOpenVersions={openCanvasNodeVersions} onViewImage={viewCanvasNodeImage} diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index 5d405fb0a..c869ccaff 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -253,7 +253,7 @@ export default function SharedCanvasPage() { setContextMenu(null); dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false }; document.body.style.cursor = "grabbing"; - }} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)} + }} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)} setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoRegenerate={unauthorized} extractingVideoFrame={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} /> diff --git a/web/src/pages/canvas/use-canvas-generation-batches.ts b/web/src/pages/canvas/use-canvas-generation-batches.ts index 564bb3e64..1dbcfab46 100644 --- a/web/src/pages/canvas/use-canvas-generation-batches.ts +++ b/web/src/pages/canvas/use-canvas-generation-batches.ts @@ -5,8 +5,7 @@ import { nanoid } from "nanoid"; import { generationBatchStatus, isGenerationCostUncertainError } from "@/lib/canvas/canvas-generation-batch"; import { buildGenerationConfig, createGenerationRetryContext, generationTaskMetadata, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { unchangedModeratedPrompt } from "@/lib/generation-error"; -import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center"; -import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; +import { listGenerationTasks } from "@/services/api/task-center"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas"; @@ -305,7 +304,9 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no const batch = findBatch(nodesRef.current, sourceNodeId, batchId); if (!batch) return; const nodeById = new Map(nodesRef.current.map((node) => [node.id, node])); - const stoppableItems = batch.items.filter((item) => (item.status === "waiting" || item.status === "submitting") && !nodeById.get(item.nodeId)?.metadata?.taskId); + // 只允许停止还在本地等待队列中的项目。进入 submitting 后请求可能已经被服务端接收, + // 即使前端尚未拿到 taskId 也不能再把它标成取消,避免隐藏已计费任务。 + const stoppableItems = batch.items.filter((item) => item.status === "waiting" && !nodeById.get(item.nodeId)?.metadata?.taskId); if (!stoppableItems.length) return message.info("没有尚未提交的任务"); modal.confirm({ title: "停止剩余任务?", @@ -315,9 +316,11 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no okButtonProps: { danger: true }, onOk: () => { const latestNodeById = new Map(nodesRef.current.map((node) => [node.id, node])); - const latestStoppableItems = stoppableItems.filter((item) => !latestNodeById.get(item.nodeId)?.metadata?.taskId); + const latestBatch = findBatch(nodesRef.current, sourceNodeId, batchId); + const latestStoppableItems = latestBatch + ? latestBatch.items.filter((item) => item.status === "waiting" && stoppableItems.some((candidate) => candidate.id === item.id) && !latestNodeById.get(item.nodeId)?.metadata?.taskId) + : []; const stoppableIds = new Set(latestStoppableItems.map((item) => item.id)); - latestStoppableItems.forEach((item) => controllersRef.current.get(batchItemKey(batchId, item.id))?.abort()); updateBatch(sourceNodeId, batchId, (current) => { const items = current.items.map((item) => (stoppableIds.has(item.id) ? { ...item, status: "cancelled" as const, errorDetails: undefined } : item)); const nextBatch = { ...current, items, updatedAt: new Date().toISOString() }; @@ -329,65 +332,6 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no [message, modal, nodesRef, updateBatch], ); - const cancelSubmittedBatchItem = useCallback( - (sourceNodeId: string, batchId: string, itemId: string) => { - const batch = findBatch(nodesRef.current, sourceNodeId, batchId); - const item = batch?.items.find((candidate) => candidate.id === itemId); - const node = item ? nodesRef.current.find((candidate) => candidate.id === item.nodeId) : undefined; - const taskId = item?.taskId || node?.metadata?.taskId; - if (!item || !taskId) return; - const cancellationCopy = localDreaminaCancellationCopy({ - id: taskId, - status: node?.metadata?.taskStatus === "queued" ? "queued" : "running", - stage: node?.metadata?.taskStage, - receiptRecorded: node?.metadata?.taskReceiptRecorded, - }); - modal.confirm({ - title: "取消这个后台任务?", - content: cancellationCopy?.confirmation || "任务会在后端停止,已经生成完成的其他镜头不会受影响。", - okText: cancellationCopy?.action || "取消任务", - cancelText: "继续生成", - okButtonProps: { danger: true }, - onOk: async () => { - try { - const task = await cancelGenerationTask(taskId); - const outcome = localDreaminaDetachOutcome(task); - const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task); - if (outcome?.kind !== "background") controllersRef.current.get(batchItemKey(batchId, item.id))?.abort(); - setNodes((current) => - current.map((currentNode) => { - if (currentNode.id === item.nodeId) - return { - ...currentNode, - metadata: { - ...currentNode.metadata, - ...generationTaskMetadata(task), - status: outcome?.canvasNodeStatus ?? "error", - errorDetails: outcome?.kind === "background" ? undefined : stopMessage, - }, - }; - if (currentNode.id !== sourceNodeId) return currentNode; - const batches = (currentNode.metadata?.generationBatches || []).map((currentBatch) => - currentBatch.id === batchId - ? withUpdatedItem(currentBatch, item.id, { - status: outcome?.batchItemStatus ?? "cancelled", - taskId, - errorDetails: outcome?.kind === "background" ? undefined : stopMessage, - }) - : currentBatch, - ); - return { ...currentNode, metadata: { ...currentNode.metadata, generationBatches: batches } }; - }), - ); - } catch (error) { - message.error(error instanceof Error ? error.message : "任务取消失败"); - } - }, - }); - }, - [message, modal, nodesRef, setNodes], - ); - useEffect(() => { if (!projectLoaded) return; reconcileBatches(); @@ -404,7 +348,6 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no }, [projectLoaded, reconcileBatches, scheduleWaitingItems]); return { - cancelSubmittedBatchItem, enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems, diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index 042fcb405..e03e40f1d 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -5,13 +5,12 @@ import { App } from "antd"; import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync"; import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskNode, ensureCanvasNodeAsset } from "@/services/project-asset-sync"; -import { cancelGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center"; +import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain"; import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { generationFailureMetadata } from "@/lib/generation-error"; -import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { consumeCanvasAgentGenerationContinuation } from "./use-canvas-agent-operations"; @@ -31,7 +30,6 @@ type UseCanvasGenerationOptions = { setNodes: Dispatch>; }; -const NODE_STATUS_IDLE = "idle" as const; const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_ERROR = "error" as const; @@ -194,7 +192,7 @@ export async function recoverCanvasGenerationTaskNode(input: { } export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) { - const { message, modal } = App.useApp(); + const { message } = App.useApp(); const queryClient = useQueryClient(); const generationRequestsRef = useRef(new Map()); const recoveringTaskIdsRef = useRef(new Set()); @@ -219,84 +217,6 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId); }, []); - const stopGenerationByRunningId = useCallback( - (runningId: string) => { - const affectedNodeIds = new Set(); - generationRequestsRef.current.forEach((request) => { - if (request.runningNodeId !== runningId) return; - request.controller.abort(); - generationRequestsRef.current.delete(request.targetNodeId); - affectedNodeIds.add(request.targetNodeId); - affectedNodeIds.add(request.originNodeId); - }); - setRunningNodeId((current) => (current === runningId ? null : current)); - if (!affectedNodeIds.size) return; - setNodes((current) => current.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); - }, - [setNodes], - ); - - const confirmStopGeneration = useCallback( - (nodeId: string) => { - modal.confirm({ - title: "停止生成?", - content: "当前生成请求会被中断,已经生成完成的内容会保留。", - okText: "停止", - cancelText: "继续生成", - okButtonProps: { danger: true }, - onOk: () => stopGenerationByRunningId(nodeId), - }); - }, - [modal, stopGenerationByRunningId], - ); - - const cancelNodeTask = useCallback( - (node: CanvasNodeData) => { - const taskId = node.metadata?.taskId; - if (!taskId) { - confirmStopGeneration(node.id); - return; - } - const cancellationCopy = localDreaminaCancellationCopy({ - id: taskId, - status: node.metadata?.taskStatus === "queued" ? "queued" : "running", - stage: node.metadata?.taskStage, - receiptRecorded: node.metadata?.taskReceiptRecorded, - }); - modal.confirm({ - title: cancellationCopy?.kind === "background" ? "转入后台?" : "取消任务?", - content: cancellationCopy?.confirmation || "任务会在后端停止,已生成完成的内容仍会保留。", - okText: cancellationCopy?.action || "取消任务", - cancelText: "继续生成", - okButtonProps: { danger: true }, - onOk: async () => { - const task = await cancelGenerationTask(taskId); - const outcome = localDreaminaDetachOutcome(task); - const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task); - if (outcome?.kind !== "background") generationRequestsRef.current.get(node.id)?.controller.abort(); - setNodes((current) => - current.map((item) => - item.id === node.id - ? { - ...item, - metadata: { - ...item.metadata, - ...generationTaskMetadata(task), - status: outcome?.canvasNodeStatus ?? NODE_STATUS_ERROR, - errorDetails: outcome?.kind === "background" ? undefined : stopMessage, - }, - } - : item, - ), - ); - if (outcome?.kind === "background") message.info(stopMessage); - else message.success(stopMessage); - }, - }); - }, - [confirmStopGeneration, message, modal, setNodes], - ); - const openNodeTaskDetails = useCallback( async (node: CanvasNodeData) => { const taskId = node.metadata?.taskId; @@ -584,8 +504,6 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, return { applyGenerationTaskResult, bindGenerationTask, - cancelNodeTask, - confirmStopGeneration, finishGenerationRequest, openNodeTaskDetails, runningNodeId, diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 36b40ad25..924157298 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -23,9 +23,9 @@ import { resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirem import { isGenerationTaskCancelled, logicalModelIDForConfig, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; -import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; +import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { createTextReplayPublisher } from "@/lib/creation-text-replay"; -import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; +import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; @@ -763,23 +763,6 @@ export default function CreatePage() { restoreMessageDraft(previous); }; - const cancelPendingMessage = async (item: CreationMessage) => { - if (!item.taskIds?.length || !activeConversation) return; - const settled = await Promise.allSettled(item.taskIds.map((taskId) => cancelGenerationTask(taskId))); - const fulfilled = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); - const background = fulfilled.map((task) => ({ task, outcome: localDreaminaDetachOutcome(task) })).find((entry) => entry.outcome?.kind === "background"); - if (background?.outcome?.kind === "background") { - await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: background.outcome!.creationStatus, generationStage: background.task.stage, content: background.outcome!.message })); - toast.info(background.outcome.message); - return; - } - const cancelled = fulfilled.filter((task) => task.status === "cancelled"); - if (cancelled.length === item.taskIds.length) { - const outcome = localDreaminaDetachOutcome(cancelled[0]!); - await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: outcome?.creationStatus ?? "cancelled", generationStage: "cancelled", content: outcome?.message ?? cancelled[0]!.error ?? "任务已取消" })); - } - }; - if (!hydrated || !activeConversation) return
; const handleThreadScroll = () => { @@ -830,7 +813,6 @@ export default function CreatePage() { composerFocusRef, placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), - onStop: () => abortRef.current?.abort(), }; const visibleShot = shots[visibleShotIndex]; @@ -866,7 +848,6 @@ export default function CreatePage() { modelName={item.model ? modelDisplayName(config, item.model) : ""} onRetryFailure={() => retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} - onCancel={() => void cancelPendingMessage(item)} />)}
@@ -892,7 +873,6 @@ export default function CreatePage() { busy={busy} onRetryFailure={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }} onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }} - onCancel={() => { if (visibleShot.result) void cancelPendingMessage(visibleShot.result); }} /> : null} @@ -979,14 +959,14 @@ function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversatio ; } -function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { +function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) { if (item.role === "user") return ; const mode = item.mode || "text"; const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; const heading = <>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}{mode !== "text" ? {item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}; const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed"; return
- {mode === "text" ? <>
{heading}
{item.reasoning ? : null}
{item.content ? {item.content} : 正在生成…}
: 1} heading={heading}>} + {mode === "text" ? <>
{heading}
{item.reasoning ? : null}
{item.content ? {item.content} : 正在生成…}
: 1} heading={heading}>} {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null}
; } @@ -1010,14 +990,14 @@ function CreationUserMessage({ item }: { item: CreationMessage }) { ; } -function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { +function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const assets = useAssetStore((state) => state.assets); const resultUrls = item.resultUrls || []; const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas"; - if (item.status === "pending") return ; + if (item.status === "pending") return ; if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; if (!resultUrls.length) return
没有返回可预览结果
; const isVideo = item.mode === "video"; @@ -1028,8 +1008,8 @@ function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item ; } -function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) { - return
影策正在生成{mode === "video" ? "视频" : "图像"}{onCancel ? : null}
; +function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) { + return
影策正在生成{mode === "video" ? "视频" : "图像"}
; } function CreationMessageReferences({ references }: { references: CreationReference[] }) { @@ -1097,7 +1077,6 @@ type ComposerProps = { composerFocusRef: RefObject; placeholderOverride?: string; onSubmit: () => void; - onStop: () => void; }; function CreationComposer(props: ComposerProps) { @@ -1115,7 +1094,7 @@ function CreationComposer(props: ComposerProps) { }); const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); - const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; + const actionLabel = props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -1156,19 +1135,18 @@ function CreationComposer(props: ComposerProps) { setPreviewUrl("")} /> @@ -1353,7 +1331,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom ; } -function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant, onCancel }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { +function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) { const user = shot.user; const result = shot.result; const status = result?.status || "queued"; @@ -1399,7 +1377,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
影策 AI{modelName ? {modelName} : null}{result?.createdAt ? : null}
- +
@@ -1441,7 +1419,7 @@ function StoryboardBriefAttachments({ attachments }: { attachments: CreationAtta })} setPreviewUrl("")} />; } -function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCancel, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) { +function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); }; @@ -1451,13 +1429,9 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCance const resultUrls = result.resultUrls || []; if (status === "pending" || status === "queued") { const thinking = thinkingFor(mode); - const cancellationCopy = result.taskIds?.map((taskId) => localDreaminaCancellationCopy({ id: taskId, status: "running", stage: result.generationStage, receiptRecorded: result.generationStage === "submitted" || result.generationStage === "generating" })).find(Boolean); - const hasLocalDreaminaTask = Boolean(result.taskIds?.some(isLocalDreaminaTaskId)); - const showCancellationAction = !hasLocalDreaminaTask || Boolean(cancellationCopy); return
{thinking.title}{thinking.hint} - {result.taskIds?.length && showCancellationAction ? : null}
; } if (status === "error") return
{generationErrorMessage(result.error || "")}
; diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index 478258d79..06ddebb13 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, Button, Form, Input, Modal, Select } from "antd"; import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles, Trash2 } from "lucide-react"; @@ -44,7 +44,6 @@ export default function ProjectsPage() { const [generating, setGenerating] = useState(false); const [generationStatus, setGenerationStatus] = useState(""); const [generationPreview, setGenerationPreview] = useState(""); - const generationAbortRef = useRef(null); const createOpen = searchParams.get("create") === "1"; const setCreateOpen = (open: boolean) => { const next = new URLSearchParams(searchParams); @@ -86,8 +85,6 @@ export default function ProjectsPage() { setGenerating(true); setGenerationStatus("正在创建项目…"); setGenerationPreview(""); - const controller = new AbortController(); - generationAbortRef.current = controller; try { const project = await createUniqueProjectName(story, selectedStyle); setGenerationStatus("AI 正在生成故事大纲与章节…"); @@ -100,7 +97,6 @@ export default function ProjectsPage() { (text) => { setGenerationPreview(text); }, - { signal: controller.signal }, ); const parsed = parseGeneratedStory(answer); if (!parsed.chapters.length) throw new Error("AI 没有返回有效的章节内容,请重试"); @@ -109,13 +105,8 @@ export default function ProjectsPage() { await queryClient.invalidateQueries({ queryKey: ["projects"] }); navigate(`/projects/${project.project.id}/overview`); } catch (error) { - if (controller.signal.aborted) { - message.info("已停止生成"); - } else { - message.error(error instanceof Error ? error.message : "AI 生成失败,请重试"); - } + message.error(error instanceof Error ? error.message : "AI 生成失败,请重试"); } finally { - generationAbortRef.current = null; setGenerating(false); setGenerationStatus(""); setGenerationPreview(""); @@ -142,7 +133,7 @@ export default function ProjectsPage() { const confirmDeleteProject = (projectId: string, name: string) => { modal.confirm({ title: "删除项目", - content: `确定删除「${name}」吗?项目内的章节、画布与资产将一并删除,此操作不可撤销。`, + content: `确定删除「${name}」吗?项目章节、画布关联和素材归属将一并移除;独立画布与素材库原始素材会保留。此操作不可撤销。`, okText: "删除", okButtonProps: { danger: true, loading: deleteMutation.isPending }, cancelText: "取消", @@ -294,7 +285,6 @@ export default function ProjectsPage() {
实时草稿{generationPreview ? "正在输出" : "等待模型输出"}
{generationPreview}
-
diff --git a/web/src/pages/tasks/index.tsx b/web/src/pages/tasks/index.tsx index efb870488..bdf55c709 100644 --- a/web/src/pages/tasks/index.tsx +++ b/web/src/pages/tasks/index.tsx @@ -10,8 +10,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat import { formatTaskKind, isGenerationTaskSubmissionUncertain, operationOptions, statusLabel } from "@/lib/generation-task-display"; import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task"; -import { cancelGenerationTask, createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center"; -import { localDreaminaCancellationCopy, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; +import { createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center"; import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; @@ -20,7 +19,7 @@ import { listProjects, type ProjectSummary } from "@/services/api/projects"; import { TaskGridCard } from "./task-grid-card"; import { TaskGroupHeader, type TaskGroup } from "./task-group-header"; import { TaskListRow } from "./task-list-row"; -import { formatModelName, getTaskCanvasContext, isTaskCancellable, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; +import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter"; type TaskKindFilter = "all" | "text" | "image" | "video"; @@ -151,7 +150,7 @@ export default function TasksPage() { setRetryingGroup(key); try { for (const task of retryable) { - await runAction(task.id, "retry"); + await runAction(task.id); } } finally { setRetryingGroup(""); @@ -168,8 +167,7 @@ export default function TasksPage() { creditsEnabled={creditsEnabled} actingId={actingId} onOpen={() => void openTaskDetail(task)} - onRetry={() => void runAction(task.id, "retry")} - onCancel={() => void runAction(task.id, "cancel")} + onRetry={() => void runAction(task.id)} onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })} /> ); @@ -180,8 +178,7 @@ export default function TasksPage() { task={task} actingId={actingId} onOpen={() => void openTaskDetail(task)} - onRetry={() => void runAction(task.id, "retry")} - onCancel={() => void runAction(task.id, "cancel")} + onRetry={() => void runAction(task.id)} /> ); @@ -290,32 +287,20 @@ export default function TasksPage() { }; }, [loadTasks]); - const runAction = async (id: string, action: "retry" | "cancel") => { + const runAction = async (id: string) => { const currentTask = tasksRef.current.find((task) => task.id === id); - if (action === "cancel" && currentTask && !isTaskCancellable(currentTask)) { - message.warning("任务已开始生成,无法取消"); - return; - } - if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask)) { + if (currentTask && isGenerationTaskSubmissionUncertain(currentTask)) { message.warning("提交结果尚未确认,不能自动重试;请先核对官方状态,避免重复生成。"); return; } setActingId(id); try { - const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id); + const next = await retryGenerationTask(id); setTasks((items) => items.map((item) => (item.id === id ? next : item))); setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current)); - if (action === "retry") { - setStatusFilter("active"); - setPage(1); - } - const localOutcome = localDreaminaDetachOutcome(next); - if (action === "retry") message.success("任务已重新入队"); - else if (localOutcome?.kind === "background") message.info(localOutcome.message); - else if (next.providerCancelStatus === "requested") message.info("已请求上游取消,正在确认费用状态"); - else if (next.providerCancelStatus === "confirmed") message.success("上游已确认取消,积分已退回"); - else if (next.providerCancelStatus === "uncertain") message.warning("任务已取消,上游费用待核对"); - else message.success("任务已取消,积分已退回"); + setStatusFilter("active"); + setPage(1); + message.success("任务已重新入队"); } catch (error) { message.error(error instanceof Error ? error.message : "操作失败"); } finally { @@ -324,14 +309,13 @@ export default function TasksPage() { }; const deleteLocalTask = (task: GenerationTask) => { + if (task.status === "queued" || task.status === "running") { + message.warning("任务正在执行,不能删除本机记录;请等待任务完成"); + return; + } Modal.confirm({ title: "删除本机任务记录?", - content: - localDreaminaCancellationCopy(task)?.kind === "background" - ? "任务已由官方接受;删除后仍会在后台同步官方状态。" - : task.status === "queued" - ? "任务尚未提交官方;删除会取消本机排队且不会触发官方请求。" - : "这只会删除本机任务记录,不会删除已生成的素材。", + content: "这只会删除本机任务记录,不会删除已生成的素材。", okText: "删除本机记录", okButtonProps: { danger: true }, cancelText: "保留", @@ -555,11 +539,6 @@ export default function TasksPage() { 更新官方状态 ) : null} - {detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask) ? ( - - ) : null} {detailTask.provider === "dreamina-cli" ? (