diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 694f26da2..5d2fde0ae 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -523,19 +523,18 @@ type CharacterVoiceBinding struct { // Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 type Project struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` - Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` - Type string `json:"type" gorm:"size:32;index"` - AspectRatio string `json:"aspectRatio" gorm:"size:16"` - SourceType string `json:"sourceType" gorm:"size:32"` - Description string `json:"description" gorm:"type:text"` - StylePresetID string `json:"stylePresetId" gorm:"size:64"` - ActiveTaskLimit int `json:"activeTaskLimit" gorm:"default:3"` - Status ProjectStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` + Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` + Type string `json:"type" gorm:"size:32;index"` + AspectRatio string `json:"aspectRatio" gorm:"size:16"` + SourceType string `json:"sourceType" gorm:"size:32"` + Description string `json:"description" gorm:"type:text"` + StylePresetID string `json:"stylePresetId" gorm:"size:64"` + Status ProjectStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` } type ProjectUnit struct { diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index d4e0379fc..77d7728c9 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -17,7 +17,6 @@ var ( ErrInsufficientCredits = errors.New("insufficient credits") ErrRedeemCodeInvalid = errors.New("redeem code invalid") ErrActiveTaskLimit = errors.New("active task limit reached") - ErrProjectTaskLimit = errors.New("project active task limit reached") ErrTaskNotRetryable = errors.New("task is not retryable") ErrBillingStateConflict = errors.New("billing state conflict") ) @@ -187,14 +186,11 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err return count > 0, err } -func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error { +func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } - if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil { - return err - } if err := reserveBillingOrder(tx, order); err != nil { return err } @@ -202,27 +198,21 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo }) } -func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error { +func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } - if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil { - return err - } return tx.Create(task).Error }) } -func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) (*model.Task, error) { +func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int) (*model.Task, error) { var task model.Task err := r.db.Transaction(func(tx *gorm.DB) error { if err := enforceActiveTaskLimit(tx, userID, activeTaskLimit); err != nil { return err } - if err := enforceProjectTaskLimit(tx, userID, domainProjectID, projectTaskLimit); err != nil { - return err - } if order != nil { if err := reserveBillingOrder(tx, order); err != nil { return err @@ -260,23 +250,6 @@ func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) err return nil } -func enforceProjectTaskLimit(tx *gorm.DB, userID string, projectID string, limit int) error { - if projectID == "" || limit <= 0 { - return nil - } - canvasIDs := tx.Model(&model.CanvasProject{}).Select("id").Where("user_id = ? AND project_id = ?", userID, projectID) - var count int64 - if err := tx.Model(&model.Task{}). - Where("user_id = ? AND status IN ? AND (project_id = ? OR project_id IN (?))", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}, projectID, canvasIDs). - Count(&count).Error; err != nil { - return err - } - if count >= int64(limit) { - return ErrProjectTaskLimit - } - return nil -} - func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error { return r.db.Transaction(func(tx *gorm.DB) error { return reserveBillingOrder(tx, order) diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 6b154c443..58af41bb5 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -768,7 +768,7 @@ func (r *Repository) CreateProject(project *model.Project) error { func (r *Repository) UpdateProject(project *model.Project) error { return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{ "name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType, - "description": project.Description, "style_preset_id": project.StylePresetID, "active_task_limit": project.ActiveTaskLimit, + "description": project.Description, "style_preset_id": project.StylePresetID, "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt, }).Error } diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index 8f5b61aaa..bb96d5a72 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -12,24 +12,22 @@ import ( ) type CreateProjectRequest struct { - Name string `json:"name"` - Type string `json:"type"` - AspectRatio string `json:"aspectRatio"` - SourceType string `json:"sourceType"` - Description string `json:"description"` - StylePresetID string `json:"stylePresetId"` - ActiveTaskLimit int `json:"activeTaskLimit"` + Name string `json:"name"` + Type string `json:"type"` + AspectRatio string `json:"aspectRatio"` + SourceType string `json:"sourceType"` + Description string `json:"description"` + StylePresetID string `json:"stylePresetId"` } type UpdateProjectRequest struct { - Name string `json:"name"` - Type string `json:"type"` - AspectRatio string `json:"aspectRatio"` - SourceType string `json:"sourceType"` - Description *string `json:"description"` - StylePresetID *string `json:"stylePresetId"` - ActiveTaskLimit *int `json:"activeTaskLimit"` - Status string `json:"status"` + Name string `json:"name"` + Type string `json:"type"` + AspectRatio string `json:"aspectRatio"` + SourceType string `json:"sourceType"` + Description *string `json:"description"` + StylePresetID *string `json:"stylePresetId"` + Status string `json:"status"` } type CreateProjectUnitRequest struct { @@ -147,9 +145,6 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } - if project.ActiveTaskLimit <= 0 { - project.ActiveTaskLimit = 3 - } return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil } @@ -174,14 +169,7 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model. sourceType = "blank" } now := time.Now() - activeTaskLimit := req.ActiveTaskLimit - if activeTaskLimit == 0 { - activeTaskLimit = 3 - } - if activeTaskLimit < 1 || activeTaskLimit > 20 { - return model.Project{}, BadAuthRequest("项目活跃任务上限必须在 1 到 20 之间") - } - project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: strings.TrimSpace(req.StylePresetID), ActiveTaskLimit: activeTaskLimit, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: strings.TrimSpace(req.StylePresetID), Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} if err := s.repo.CreateProject(&project); err != nil { return model.Project{}, err } @@ -217,12 +205,6 @@ func (s *Service) UpdateProject(userID string, id string, req UpdateProjectReque if req.StylePresetID != nil { project.StylePresetID = strings.TrimSpace(*req.StylePresetID) } - if req.ActiveTaskLimit != nil { - if *req.ActiveTaskLimit < 1 || *req.ActiveTaskLimit > 20 { - return model.Project{}, BadAuthRequest("项目活跃任务上限必须在 1 到 20 之间") - } - project.ActiveTaskLimit = *req.ActiveTaskLimit - } if status := model.ProjectStatus(strings.TrimSpace(req.Status)); status != "" { if status != model.ProjectStatusActive && status != model.ProjectStatusArchived { return model.Project{}, BadAuthRequest("不支持的项目状态") @@ -474,43 +456,36 @@ func IsProjectNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } -// 任务仍以画布 ID 作为 projectId;这里统一解析到业务项目,避免项目限额和画布主键语义混淆。 -func (s *Service) projectTaskLimit(userID string, canvasOrProjectID string) (string, int, error) { +// 任务仍以画布 ID 作为 projectId;写入前必须解析到业务项目并阻止归档项目继续生成。 +func (s *Service) ensureTaskProjectActive(userID string, canvasOrProjectID string) error { id := strings.TrimSpace(canvasOrProjectID) if id == "" { - return "", 0, nil + return nil } if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil { if canvas.ProjectID == "" { - return "", 0, nil + return nil } project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID) if projectErr != nil { - return "", 0, projectErr + return projectErr } if project.Status == model.ProjectStatusArchived { - return "", 0, BadAuthRequest("项目已归档,无法创建生成任务") + return BadAuthRequest("项目已归档,无法创建生成任务") } - return project.ID, normalizedProjectTaskLimit(project.ActiveTaskLimit), nil + return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return "", 0, err + return err } project, err := s.repo.ProjectForUser(userID, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", 0, nil + return nil } - return "", 0, err + return err } if project.Status == model.ProjectStatusArchived { - return "", 0, BadAuthRequest("项目已归档,无法创建生成任务") + return BadAuthRequest("项目已归档,无法创建生成任务") } - return project.ID, normalizedProjectTaskLimit(project.ActiveTaskLimit), nil -} - -func normalizedProjectTaskLimit(limit int) int { - if limit <= 0 { - return 3 - } - return limit + return nil } diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 3a731e6f1..530ff9e0e 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -292,8 +292,7 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, 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} - domainProjectID, projectTaskLimit, err := s.projectTaskLimit(userID, req.ProjectID) - if err != nil { + if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil { return nil, err } billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput) @@ -308,13 +307,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if billingOrder != nil { task.BillingOrderID = billingOrder.ID } - err = s.createTaskWithinStorageQuota(&task, billingOrder, policy, domainProjectID, projectTaskLimit) + 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.ErrProjectTaskLimit) { - return nil, BadAuthRequest(fmt.Sprintf("当前项目同时排队或运行的任务最多 %d 个", projectTaskLimit)) - } if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("积分不足,请先使用兑换码充值") } @@ -419,20 +415,16 @@ func (s *Service) RetryTask(userID string, id string) (*model.Task, error) { if err != nil { return nil, err } - domainProjectID, projectTaskLimit, err := s.projectTaskLimit(userID, task.ProjectID) - if err != nil { + if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil { return nil, err } - task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit) + task, err = s.repo.RetryTaskWithBilling(userID, task.ID, 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.ErrProjectTaskLimit) { - return nil, BadAuthRequest(fmt.Sprintf("当前项目同时排队或运行的任务最多 %d 个", projectTaskLimit)) - } if errors.Is(err, repository.ErrTaskNotRetryable) { return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试") } diff --git a/backend/internal/service/storage_quota.go b/backend/internal/service/storage_quota.go index ac38c4587..19f6a33d7 100644 --- a/backend/internal/service/storage_quota.go +++ b/backend/internal/service/storage_quota.go @@ -74,7 +74,7 @@ func validateStructuredReplacementQuotaWithPolicy(usage repository.UserStorageUs return validateStructuredStorageQuotaWithPolicy(usage, kind, false, deltaBytes, policy) } -func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting, domainProjectID string, projectTaskLimit int) error { +func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error { s.storageMu.Lock() defer s.storageMu.Unlock() usage, err := s.repo.UserStorageUsage(task.UserID) @@ -86,9 +86,9 @@ func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *m return err } if billingOrder != nil { - return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit) + return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit) } - return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit) + return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit) } // 任务完成会同时扩张任务历史和 Agent 会话数据,必须在同一临界区核算并原子写入。 diff --git a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx index 3f5ab5cfd..2523181c8 100644 --- a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx +++ b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx @@ -41,7 +41,7 @@ const resourceFields: PolicyField[] = [ const concurrencyFields: PolicyField[] = [ { group: "task", name: "workerConcurrency", label: "Worker 并发", extra: "集群同时执行的后台任务数。", unit: "个", max: 999 }, { group: "task", name: "channelConcurrency", label: "全局渠道并发", extra: "渠道选择跟随系统时采用的并发上限。", unit: "个", max: 999 }, - { group: "task", name: "activeTaskLimit", label: "账号活动任务", extra: "单账号同时排队或运行的任务数。", unit: "个", max: 999 }, + { group: "task", name: "activeTaskLimit", label: "账号活动任务", extra: "单账号跨项目同时排队或运行的任务总数。", unit: "个", max: 999 }, ]; const timeoutFields: PolicyField[] = [ diff --git a/web/src/pages/projects/detail/settings.tsx b/web/src/pages/projects/detail/settings.tsx index 8d6c3708b..c4c375a7b 100644 --- a/web/src/pages/projects/detail/settings.tsx +++ b/web/src/pages/projects/detail/settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useMutation } from "@tanstack/react-query"; -import { App, Button, Input, InputNumber, Modal, Select, Tooltip } from "antd"; +import { App, Button, Input, Modal, Select, Tooltip } from "antd"; import { Archive, Check, Save, ShieldAlert } from "lucide-react"; import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal"; @@ -16,16 +16,15 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD const [aspectRatio, setAspectRatio] = useState(project.aspectRatio); const [sourceType, setSourceType] = useState(project.sourceType); const [stylePresetId, setStylePresetId] = useState(project.stylePresetId || ""); - const [activeTaskLimit, setActiveTaskLimit] = useState(project.activeTaskLimit || 3); const [archiveOpen, setArchiveOpen] = useState(false); - useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setActiveTaskLimit(project.activeTaskLimit || 3); }, [project]); - const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || activeTaskLimit !== (project.activeTaskLimit || 3), [activeTaskLimit, aspectRatio, description, name, project, sourceType, stylePresetId]); - const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, activeTaskLimit }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") }); + useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); }, [project]); + const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || ""), [aspectRatio, description, name, project, sourceType, stylePresetId]); + const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") }); const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") }); return (
-

项目设置

基础信息、项目画风与运行限额

+

项目设置

基础信息、项目画风与归档管理

基础设置

@@ -33,8 +32,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD setName(event.target.value)} /> - setDescription(event.target.value)} placeholder="一句话说明项目目标" /> - setActiveTaskLimit(value || 1)} /> + setDescription(event.target.value)} placeholder="一句话说明项目目标" />
diff --git a/web/src/services/api/projects.ts b/web/src/services/api/projects.ts index 56f718afe..404e0914e 100644 --- a/web/src/services/api/projects.ts +++ b/web/src/services/api/projects.ts @@ -13,7 +13,6 @@ export type Project = { sourceType: string; description: string; stylePresetId: string; - activeTaskLimit: number; status: "active" | "archived" | string; revision: number; createdAt: string; @@ -187,11 +186,11 @@ export function getProject(id: string) { return request(api.get(`/projects/${encodeURIComponent(id)}`)); } -export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string; activeTaskLimit?: number }) { +export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string }) { return request<{ project: Project }>(api.post("/projects", input)); } -export function updateProject(projectId: string, input: Partial>) { +export function updateProject(projectId: string, input: Partial>) { return request<{ project: Project }>(api.patch(`/projects/${encodeURIComponent(projectId)}`, input)); }