666 lines
23 KiB
Go
666 lines
23 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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"`
|
|
StyleProfileJSON string `json:"styleProfileJson"`
|
|
DefaultImageModel string `json:"defaultImageModel"`
|
|
DefaultVideoModel string `json:"defaultVideoModel"`
|
|
}
|
|
|
|
type UpdateProjectRequest struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
AspectRatio string `json:"aspectRatio"`
|
|
SourceType string `json:"sourceType"`
|
|
Description *string `json:"description"`
|
|
CoverResourceID *string `json:"coverResourceId"`
|
|
StylePresetID *string `json:"stylePresetId"`
|
|
StyleProfileJSON *string `json:"styleProfileJson"`
|
|
DefaultImageModel *string `json:"defaultImageModel"`
|
|
DefaultVideoModel *string `json:"defaultVideoModel"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type CreateProjectUnitRequest struct {
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
SourceText string `json:"sourceText"`
|
|
Position int `json:"position"`
|
|
}
|
|
|
|
type UpdateProjectUnitRequest struct {
|
|
Title string `json:"title"`
|
|
SourceText string `json:"sourceText"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type ImportProjectUnitsRequest struct {
|
|
Units []CreateProjectUnitRequest `json:"units"`
|
|
}
|
|
|
|
type ReorderProjectUnitsRequest struct {
|
|
UnitIDs []string `json:"unitIds"`
|
|
}
|
|
|
|
type LinkCanvasUnitRequest struct {
|
|
CanvasID string `json:"canvasId"`
|
|
UnitID string `json:"unitId"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type ProjectSummary struct {
|
|
Project model.Project `json:"project"`
|
|
CanvasCount int `json:"canvasCount"`
|
|
AssetCount int64 `json:"assetCount"`
|
|
UnitCount int `json:"unitCount"`
|
|
CompletedUnitCount int `json:"completedUnitCount"`
|
|
}
|
|
|
|
type ProjectListPage struct {
|
|
Projects []ProjectSummary `json:"projects"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
Total int64 `json:"total"`
|
|
HasMore bool `json:"hasMore"`
|
|
}
|
|
|
|
type ProjectDetail struct {
|
|
Project model.Project `json:"project"`
|
|
Units []model.ProjectUnit `json:"units"`
|
|
Canvases []model.CanvasProject `json:"canvases"`
|
|
CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"`
|
|
Assets []ProjectAssetSummary `json:"assets"`
|
|
AssetFolders []model.ProjectAssetFolder `json:"assetFolders"`
|
|
Workflows []ProjectWorkflowDetail `json:"workflows"`
|
|
Shots []model.Shot `json:"shots"`
|
|
ShotRevisions []model.ShotRevision `json:"shotRevisions"`
|
|
ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"`
|
|
ShotReferences []model.ShotAssetReference `json:"shotReferences"`
|
|
AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
|
|
Tasks []TaskSummary `json:"tasks"`
|
|
}
|
|
|
|
func (s *Service) ListProjects(userID string) ([]ProjectSummary, error) {
|
|
projects, err := s.repo.Projects(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.summarizeProjects(userID, projects)
|
|
}
|
|
|
|
func (s *Service) ListProjectsPage(userID string, page int, pageSize int) (ProjectListPage, error) {
|
|
projects, total, err := s.repo.ProjectsPage(userID, page, pageSize)
|
|
if err != nil {
|
|
return ProjectListPage{}, err
|
|
}
|
|
result, err := s.summarizeProjects(userID, projects)
|
|
if err != nil {
|
|
return ProjectListPage{}, err
|
|
}
|
|
return ProjectListPage{Projects: result, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
|
|
}
|
|
|
|
func (s *Service) summarizeProjects(userID string, projects []model.Project) ([]ProjectSummary, error) {
|
|
result := make([]ProjectSummary, 0, len(projects))
|
|
for _, project := range projects {
|
|
units, unitsErr := s.repo.ProjectUnitSummaries(project.ID)
|
|
if unitsErr != nil {
|
|
return nil, unitsErr
|
|
}
|
|
canvases, canvasesErr := s.repo.ProjectCanvasSummaries(userID, project.ID)
|
|
if canvasesErr != nil {
|
|
return nil, canvasesErr
|
|
}
|
|
assetCount, assetCountErr := s.repo.ProjectAssetCount(project.ID)
|
|
if assetCountErr != nil {
|
|
return nil, assetCountErr
|
|
}
|
|
completed := 0
|
|
for _, unit := range units {
|
|
if unit.Status == model.ProjectUnitStatusCompleted {
|
|
completed++
|
|
}
|
|
}
|
|
result = append(result, ProjectSummary{Project: project, CanvasCount: len(canvases), AssetCount: assetCount, UnitCount: len(units), CompletedUnitCount: completed})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) {
|
|
project, err := s.repo.ProjectForUser(userID, id)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
// 项目读取允许降级修复:旧任务可能已成功持久化图片,但浏览器刷新中断了角色版本绑定。
|
|
if s.reconcileCharacterTurnaroundTasks(userID, project.ID) {
|
|
project, err = s.repo.ProjectForUser(userID, id)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
}
|
|
if tasks, tasksErr := s.repo.SuccessfulWorkflowTasksForProject(userID, project.ID); tasksErr == nil {
|
|
for _, task := range tasks {
|
|
if err := s.RegisterTaskOutputFromTask(task); err != nil {
|
|
_ = s.log(userID, task.ID, "error", "项目读取时工作流产物补偿失败", err.Error())
|
|
}
|
|
}
|
|
}
|
|
// 项目工作台只返回章节摘要,长篇小说正文由单章接口按需读取。
|
|
units, err := s.repo.ProjectUnitSummaries(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
canvases, err := s.repo.ProjectCanvasSummaries(userID, project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
canvasUnitLinks, err := s.repo.ProjectCanvasUnitLinks(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
assets, err := s.ProjectAssets(userID, project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
assetFolders, err := s.ProjectAssetFolders(userID, project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
workflows, err := s.ProjectWorkflows(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
shots, err := s.repo.ProjectShots(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
shotRevisions, err := s.repo.ProjectShotRevisions(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
shotArtifacts, err := s.repo.ProjectShotArtifacts(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
shotReferences, err := s.repo.ProjectShotAssetReferences(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
candidates, err := s.repo.ProjectAssetCandidates(project.ID)
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
tasks, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: project.ID})
|
|
if err != nil {
|
|
return ProjectDetail{}, err
|
|
}
|
|
return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotRevisions: shotRevisions, ShotArtifacts: shotArtifacts, ShotReferences: shotReferences, AssetCandidates: candidates, Tasks: tasks}, nil
|
|
}
|
|
|
|
func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) {
|
|
if err := s.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return model.Project{}, BadAuthRequest("项目名称不能为空")
|
|
}
|
|
projectType := strings.TrimSpace(req.Type)
|
|
if projectType == "" {
|
|
projectType = "short-drama"
|
|
}
|
|
aspectRatio := strings.TrimSpace(req.AspectRatio)
|
|
if aspectRatio == "" {
|
|
aspectRatio = "9:16"
|
|
}
|
|
sourceType := strings.TrimSpace(req.SourceType)
|
|
if sourceType == "" {
|
|
sourceType = "blank"
|
|
}
|
|
styleProfileJSON, err := validateStyleProfileJSON(req.StyleProfileJSON)
|
|
if err != nil {
|
|
return model.Project{}, BadAuthRequest(err.Error())
|
|
}
|
|
stylePresetID := strings.TrimSpace(req.StylePresetID)
|
|
if err := validateStyleProfilePreset(stylePresetID, styleProfileJSON); err != nil {
|
|
return model.Project{}, BadAuthRequest(err.Error())
|
|
}
|
|
now := time.Now()
|
|
defaultImageModel, err := normalizeProjectDefaultModel(req.DefaultImageModel)
|
|
if err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
defaultVideoModel, err := normalizeProjectDefaultModel(req.DefaultVideoModel)
|
|
if err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, DefaultImageModel: defaultImageModel, DefaultVideoModel: defaultVideoModel, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
|
if err := s.repo.CreateProject(&project); err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
if _, err := s.createProjectWorkflow(project.ID, "", "project"); err != nil {
|
|
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++
|
|
project.UpdatedAt = time.Now()
|
|
return project, nil
|
|
}
|
|
|
|
func (s *Service) UpdateProject(userID string, id string, req UpdateProjectRequest) (model.Project, error) {
|
|
project, err := s.repo.ProjectForUser(userID, id)
|
|
if err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
if name := strings.TrimSpace(req.Name); name != "" {
|
|
project.Name = name
|
|
}
|
|
if value := strings.TrimSpace(req.Type); value != "" {
|
|
project.Type = value
|
|
}
|
|
if value := strings.TrimSpace(req.AspectRatio); value != "" {
|
|
project.AspectRatio = value
|
|
}
|
|
if value := strings.TrimSpace(req.SourceType); value != "" {
|
|
project.SourceType = value
|
|
}
|
|
if req.Description != nil {
|
|
project.Description = strings.TrimSpace(*req.Description)
|
|
}
|
|
if req.CoverResourceID != nil {
|
|
coverResourceID := strings.TrimSpace(*req.CoverResourceID)
|
|
if coverResourceID != "" {
|
|
resource, resourceErr := s.repo.ResourceForUser(userID, coverResourceID)
|
|
if resourceErr != nil {
|
|
if errors.Is(resourceErr, gorm.ErrRecordNotFound) {
|
|
return model.Project{}, BadAuthRequest("项目主图不存在或不属于当前用户")
|
|
}
|
|
return model.Project{}, resourceErr
|
|
}
|
|
if resource.Status != model.ResourceStatusReady || resource.Kind != "image" {
|
|
return model.Project{}, BadAuthRequest("项目主图必须是已就绪的图片资源")
|
|
}
|
|
}
|
|
project.CoverResourceID = coverResourceID
|
|
}
|
|
if req.StylePresetID != nil {
|
|
project.StylePresetID = strings.TrimSpace(*req.StylePresetID)
|
|
}
|
|
if req.StyleProfileJSON != nil {
|
|
styleProfileJSON, profileErr := validateStyleProfileJSON(*req.StyleProfileJSON)
|
|
if profileErr != nil {
|
|
return model.Project{}, BadAuthRequest(profileErr.Error())
|
|
}
|
|
project.StyleProfileJSON = styleProfileJSON
|
|
}
|
|
if req.DefaultImageModel != nil {
|
|
defaultImageModel, modelErr := normalizeProjectDefaultModel(*req.DefaultImageModel)
|
|
if modelErr != nil {
|
|
return model.Project{}, modelErr
|
|
}
|
|
project.DefaultImageModel = defaultImageModel
|
|
}
|
|
if req.DefaultVideoModel != nil {
|
|
defaultVideoModel, modelErr := normalizeProjectDefaultModel(*req.DefaultVideoModel)
|
|
if modelErr != nil {
|
|
return model.Project{}, modelErr
|
|
}
|
|
project.DefaultVideoModel = defaultVideoModel
|
|
}
|
|
if err := validateStyleProfilePreset(project.StylePresetID, project.StyleProfileJSON); err != nil {
|
|
return model.Project{}, BadAuthRequest(err.Error())
|
|
}
|
|
if status := model.ProjectStatus(strings.TrimSpace(req.Status)); status != "" {
|
|
if status != model.ProjectStatusActive && status != model.ProjectStatusArchived {
|
|
return model.Project{}, BadAuthRequest("不支持的项目状态")
|
|
}
|
|
project.Status = status
|
|
}
|
|
project.Revision++
|
|
project.UpdatedAt = time.Now()
|
|
if err := s.repo.UpdateProject(project); err != nil {
|
|
return model.Project{}, err
|
|
}
|
|
return *project, nil
|
|
}
|
|
|
|
func normalizeProjectDefaultModel(value string) (string, error) {
|
|
modelRef := strings.TrimSpace(value)
|
|
if len(modelRef) > 500 {
|
|
return "", BadAuthRequest("项目默认模型标识过长")
|
|
}
|
|
return modelRef, nil
|
|
}
|
|
|
|
func (s *Service) DeleteProject(userID string, id string) error {
|
|
if _, err := s.repo.ProjectForUser(userID, id); err != nil {
|
|
return err
|
|
}
|
|
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) activeProjectForUser(userID string, projectID string) (*model.Project, error) {
|
|
project, err := s.repo.ProjectForUser(userID, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if project.Status == model.ProjectStatusArchived {
|
|
return nil, BadAuthRequest("项目已归档,不能修改短剧生产数据")
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
func (s *Service) CreateProjectUnit(userID string, projectID string, req CreateProjectUnitRequest) (model.ProjectUnit, error) {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
unit, err := newProjectUnit(projectID, req, req.Position)
|
|
if err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
if err := s.repo.CreateProjectUnit(&unit); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
return unit, nil
|
|
}
|
|
|
|
func (s *Service) GetProjectUnit(userID string, projectID string, unitID string) (model.ProjectUnit, error) {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
unit, err := s.repo.ProjectUnit(projectID, strings.TrimSpace(unitID))
|
|
if err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
return *unit, nil
|
|
}
|
|
|
|
func (s *Service) ImportProjectUnits(userID string, projectID string, req ImportProjectUnitsRequest) ([]model.ProjectUnit, error) {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(req.Units) == 0 || len(req.Units) > 2500 {
|
|
return nil, BadAuthRequest("一次导入的章节数量必须在 1 到 2500 之间")
|
|
}
|
|
existing, err := s.repo.ProjectUnits(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
units := make([]model.ProjectUnit, 0, len(req.Units))
|
|
for index, input := range req.Units {
|
|
unit, unitErr := newProjectUnit(projectID, input, len(existing)+index)
|
|
if unitErr != nil {
|
|
return nil, unitErr
|
|
}
|
|
units = append(units, unit)
|
|
}
|
|
if err := s.repo.ImportProjectUnits(projectID, units); err != nil {
|
|
return nil, err
|
|
}
|
|
return units, nil
|
|
}
|
|
|
|
func (s *Service) ReorderProjectUnits(userID string, projectID string, req ReorderProjectUnitsRequest) error {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return err
|
|
}
|
|
units, err := s.repo.ProjectUnits(projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(req.UnitIDs) != len(units) {
|
|
return BadAuthRequest("章节排序列表不完整")
|
|
}
|
|
existing := make(map[string]struct{}, len(units))
|
|
for _, unit := range units {
|
|
existing[unit.ID] = struct{}{}
|
|
}
|
|
seen := make(map[string]struct{}, len(req.UnitIDs))
|
|
normalizedIDs := make([]string, 0, len(req.UnitIDs))
|
|
for _, rawID := range req.UnitIDs {
|
|
id := strings.TrimSpace(rawID)
|
|
if _, ok := existing[id]; !ok {
|
|
return BadAuthRequest("章节排序包含无效章节")
|
|
}
|
|
if _, duplicate := seen[id]; duplicate {
|
|
return BadAuthRequest("章节排序包含重复章节")
|
|
}
|
|
seen[id] = struct{}{}
|
|
normalizedIDs = append(normalizedIDs, id)
|
|
}
|
|
return s.repo.ReorderProjectUnits(projectID, normalizedIDs)
|
|
}
|
|
|
|
func (s *Service) DeleteProjectUnit(userID string, projectID string, unitID string) error {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
|
|
return err
|
|
}
|
|
return s.repo.DeleteProjectUnit(projectID, unitID)
|
|
}
|
|
|
|
func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int) (model.ProjectUnit, error) {
|
|
kind := model.ProjectUnitKind(strings.TrimSpace(req.Kind))
|
|
if kind == "" {
|
|
kind = model.ProjectUnitKindChapter
|
|
}
|
|
if kind != model.ProjectUnitKindChapter && kind != model.ProjectUnitKindEpisode {
|
|
return model.ProjectUnit{}, BadAuthRequest("不支持的项目单元类型")
|
|
}
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
return model.ProjectUnit{}, BadAuthRequest("章节标题不能为空")
|
|
}
|
|
if position < 0 {
|
|
position = 0
|
|
}
|
|
now := time.Now()
|
|
unit := model.ProjectUnit{ID: newID(), ProjectID: projectID, Kind: kind, Title: title, SourceText: req.SourceText, WordCount: model.ProjectUnitWordCount(req.SourceText), Status: model.ProjectUnitStatusDraft, Position: position, CreatedAt: now, UpdatedAt: now}
|
|
return unit, nil
|
|
}
|
|
|
|
func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID string, req UpdateProjectUnitRequest) (model.ProjectUnit, error) {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
unit, err := s.repo.ProjectUnit(projectID, unitID)
|
|
if err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
sourceChanged := unit.SourceText != req.SourceText
|
|
if title := strings.TrimSpace(req.Title); title != "" {
|
|
unit.Title = title
|
|
}
|
|
unit.SourceText = req.SourceText
|
|
unit.WordCount = model.ProjectUnitWordCount(req.SourceText)
|
|
if status := model.ProjectUnitStatus(strings.TrimSpace(req.Status)); status != "" {
|
|
if status != model.ProjectUnitStatusDraft && status != model.ProjectUnitStatusReady && status != model.ProjectUnitStatusCompleted {
|
|
return model.ProjectUnit{}, BadAuthRequest("不支持的章节状态")
|
|
}
|
|
unit.Status = status
|
|
}
|
|
unit.UpdatedAt = time.Now()
|
|
if err := s.repo.UpdateProjectUnit(unit, sourceChanged); err != nil {
|
|
return model.ProjectUnit{}, err
|
|
}
|
|
return *unit, nil
|
|
}
|
|
|
|
func (s *Service) LinkCanvasUnit(userID string, projectID string, req LinkCanvasUnitRequest) (model.CanvasUnitLink, error) {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
canvasID := strings.TrimSpace(req.CanvasID)
|
|
unitID := strings.TrimSpace(req.UnitID)
|
|
if canvasID == "" || unitID == "" {
|
|
return model.CanvasUnitLink{}, BadAuthRequest("画布和章节不能为空")
|
|
}
|
|
if _, err := s.repo.CanvasProjectForUser(userID, canvasID); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
if err := s.repo.AssignCanvasToProject(userID, canvasID, projectID); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
role := strings.TrimSpace(req.Role)
|
|
if role == "" {
|
|
role = "storyboard"
|
|
}
|
|
now := time.Now()
|
|
link := model.CanvasUnitLink{ID: newID(), ProjectID: projectID, CanvasID: canvasID, UnitID: unitID, Role: role, CreatedAt: now}
|
|
if err := s.repo.UpsertCanvasUnitLink(&link); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
|
return model.CanvasUnitLink{}, err
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
func (s *Service) UnlinkCanvasUnit(userID string, projectID string, canvasID string, unitID string) error {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return err
|
|
}
|
|
canvas, err := s.repo.CanvasProjectForUser(userID, strings.TrimSpace(canvasID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if canvas.ProjectID != projectID {
|
|
return BadAuthRequest("画布不属于当前项目")
|
|
}
|
|
if _, err := s.repo.CanvasUnitLink(projectID, canvas.ID, strings.TrimSpace(unitID)); err != nil {
|
|
return err
|
|
}
|
|
return s.repo.DeleteCanvasUnitLink(projectID, canvas.ID, strings.TrimSpace(unitID))
|
|
}
|
|
|
|
func (s *Service) UnlinkCanvasProject(userID string, projectID string, canvasID string) error {
|
|
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
|
return err
|
|
}
|
|
canvas, err := s.repo.CanvasProjectForUser(userID, strings.TrimSpace(canvasID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if canvas.ProjectID != projectID {
|
|
return BadAuthRequest("画布不属于当前项目")
|
|
}
|
|
now := time.Now()
|
|
payloadJSON, err := canvasPayloadWithoutProject(canvas.PayloadJSON, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// 关系列、同步快照和更新时间必须原子更新,否则浏览器会用旧 projectId 把关系重新写回。
|
|
return s.repo.UnassignCanvasFromProject(userID, projectID, canvas.ID, payloadJSON, now, canvas.Revision)
|
|
}
|
|
|
|
func canvasPayloadWithoutProject(payloadJSON string, updatedAt time.Time) (string, error) {
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
|
|
return "", BadAuthRequest("画布数据格式错误,无法解除项目关系")
|
|
}
|
|
delete(payload, "projectId")
|
|
payload["updatedAt"] = updatedAt.Format(time.RFC3339Nano)
|
|
next, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(next), nil
|
|
}
|
|
|
|
func IsProjectNotFound(err error) bool {
|
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
|
}
|
|
|
|
// 任务仍以画布 ID 作为 projectId;写入前必须解析到业务项目并阻止归档项目继续生成。
|
|
func (s *Service) ensureTaskProjectActive(userID string, canvasOrProjectID string) error {
|
|
id := strings.TrimSpace(canvasOrProjectID)
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil {
|
|
if canvas.ProjectID == "" {
|
|
return nil
|
|
}
|
|
project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID)
|
|
if projectErr != nil {
|
|
return projectErr
|
|
}
|
|
if project.Status == model.ProjectStatusArchived {
|
|
return BadAuthRequest("项目已归档,无法创建生成任务")
|
|
}
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
project, err := s.repo.ProjectForUser(userID, id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if project.Status == model.ProjectStatusArchived {
|
|
return BadAuthRequest("项目已归档,无法创建生成任务")
|
|
}
|
|
return nil
|
|
}
|