feat(assets): 素材库与画布 - 新增可交互通用文件夹

This commit is contained in:
echoD886
2026-08-20 15:16:45 +08:00
parent 9ee71d4650
commit 935108edca
35 changed files with 2010 additions and 155 deletions

View File

@@ -33,6 +33,7 @@ func Models() []any {
&model.Resource{},
&model.Asset{},
&model.ProjectAssetLink{},
&model.ProjectAssetFolder{},
&model.ProjectAssetCandidate{},
&model.AssetVersion{},
&model.AssetRepresentation{},

View File

@@ -308,6 +308,85 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"assets": assets})
})
r.GET("/projects/:id/asset-folders", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
folders, err := svc.ProjectAssetFolders(user.ID, c.Param("id"))
if err != nil {
if service.IsProjectNotFound(err) {
fail(c, http.StatusNotFound, err)
return
}
failService(c, err)
return
}
ok(c, gin.H{"folders": folders})
})
r.POST("/projects/:id/asset-folders", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
var req service.CreateProjectAssetFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
folder, err := svc.CreateProjectAssetFolder(user.ID, c.Param("id"), req)
if err != nil {
if service.IsProjectNotFound(err) {
fail(c, http.StatusNotFound, err)
return
}
failService(c, err)
return
}
ok(c, gin.H{"folder": folder})
})
r.PATCH("/projects/:id/asset-folders/:folderId", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
var req service.UpdateProjectAssetFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
folder, err := svc.UpdateProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId"), req)
if err != nil {
if service.IsProjectNotFound(err) {
fail(c, http.StatusNotFound, err)
return
}
failService(c, err)
return
}
ok(c, gin.H{"folder": folder})
})
r.DELETE("/projects/:id/asset-folders/:folderId", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.DeleteProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId")); err != nil {
if service.IsProjectNotFound(err) {
fail(c, http.StatusNotFound, err)
return
}
failService(c, err)
return
}
ok(c, gin.H{"id": c.Param("folderId")})
})
r.POST("/projects/:id/characters", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -448,12 +527,12 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.UpdateProjectAssetCategoryRequest
var req service.UpdateProjectAssetRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
asset, err := svc.UpdateProjectAssetCategory(user.ID, c.Param("id"), c.Param("assetId"), req)
asset, err := svc.UpdateProjectAsset(user.ID, c.Param("id"), c.Param("assetId"), req)
if err != nil {
failService(c, err)
return

View File

@@ -42,9 +42,24 @@ type ProjectAssetLink struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"`
AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"`
FolderID string `json:"folderId,omitempty" gorm:"index;size:36"`
Position int `json:"position" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
}
// ProjectAssetFolder 只保存项目内的目录结构;真实媒体仍由 Asset/Resource 唯一持有。
type ProjectAssetFolder struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_folders_sibling_name,priority:1"`
ParentID string `json:"parentId,omitempty" gorm:"index;size:36;uniqueIndex:idx_project_asset_folders_sibling_name,priority:2"`
Name string `json:"name" gorm:"size:240"`
NameKey string `json:"-" gorm:"size:240;uniqueIndex:idx_project_asset_folders_sibling_name,priority:3"`
Style string `json:"style" gorm:"size:24"`
Position int `json:"position" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ProjectAssetCandidate struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ProjectID string `json:"projectId" gorm:"index;size:36"`

View File

@@ -24,6 +24,8 @@ var ErrTextReplayQuotaExceeded = errors.New("text replay quota exceeded")
var ErrTextReplayClosed = errors.New("text replay task is closed")
var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empty")
type Repository struct {
db *gorm.DB
}
@@ -968,6 +970,9 @@ func (r *Repository) DeleteProject(userID string, id string) error {
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetFolder{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
return err
}
@@ -1156,6 +1161,110 @@ func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Ass
return assets, err
}
func (r *Repository) ProjectAssetLinks(projectID string) ([]model.ProjectAssetLink, error) {
var links []model.ProjectAssetLink
err := r.db.Where("project_id = ?", projectID).Order("folder_id asc, position asc, created_at asc").Find(&links).Error
return links, err
}
func (r *Repository) ProjectAssetLink(projectID string, assetID string) (*model.ProjectAssetLink, error) {
var link model.ProjectAssetLink
if err := r.db.First(&link, "project_id = ? AND asset_id = ?", projectID, assetID).Error; err != nil {
return nil, err
}
return &link, nil
}
func (r *Repository) NextProjectAssetPosition(projectID string, folderID string) (int, error) {
var result struct{ Maximum int }
err := r.db.Model(&model.ProjectAssetLink{}).
Select("COALESCE(MAX(position), -1) AS maximum").
Where("project_id = ? AND folder_id = ?", projectID, folderID).
Scan(&result).Error
return result.Maximum + 1, err
}
func (r *Repository) MoveProjectAsset(projectID string, assetID string, folderID string, position int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.ProjectAssetLink{}).
Where("project_id = ? AND asset_id = ?", projectID, assetID).
Updates(map[string]any{"folder_id": folderID, "position": position})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
func (r *Repository) ProjectAssetFolders(projectID string) ([]model.ProjectAssetFolder, error) {
var folders []model.ProjectAssetFolder
err := r.db.Where("project_id = ?", projectID).Order("parent_id asc, position asc, created_at asc").Find(&folders).Error
return folders, err
}
func (r *Repository) ProjectAssetFolder(projectID string, folderID string) (*model.ProjectAssetFolder, error) {
var folder model.ProjectAssetFolder
if err := r.db.First(&folder, "id = ? AND project_id = ?", folderID, projectID).Error; err != nil {
return nil, err
}
return &folder, nil
}
func (r *Repository) CreateProjectAssetFolder(folder *model.ProjectAssetFolder) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(folder).Error; err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error
})
}
func (r *Repository) UpdateProjectAssetFolder(folder *model.ProjectAssetFolder) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.ProjectAssetFolder{}).
Where("id = ? AND project_id = ?", folder.ID, folder.ProjectID).
Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "position": folder.Position, "updated_at": folder.UpdatedAt})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error
})
}
func (r *Repository) DeleteProjectAssetFolder(projectID string, folderID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var childCount int64
if err := tx.Model(&model.ProjectAssetFolder{}).Where("project_id = ? AND parent_id = ?", projectID, folderID).Count(&childCount).Error; err != nil {
return err
}
var assetCount int64
if err := tx.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND folder_id = ?", projectID, folderID).Count(&assetCount).Error; err != nil {
return err
}
if childCount > 0 || assetCount > 0 {
return ErrProjectAssetFolderNotEmpty
}
result := tx.Delete(&model.ProjectAssetFolder{}, "id = ? AND project_id = ?", folderID, projectID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
// LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。
func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) {
createdLink := false

View File

@@ -73,6 +73,7 @@ type ProjectDetail struct {
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"`
ShotReferences []model.ShotAssetReference `json:"shotReferences"`
@@ -138,6 +139,10 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error)
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
@@ -154,7 +159,7 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error)
if err != nil {
return ProjectDetail{}, err
}
return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil
return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil
}
func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) {

View File

@@ -9,12 +9,14 @@ import (
)
type LinkProjectAssetRequest struct {
AssetID string `json:"assetId"`
Category string `json:"category"`
AssetID string `json:"assetId"`
Category string `json:"category"`
FolderID *string `json:"folderId"`
}
type UpdateProjectAssetCategoryRequest struct {
Category string `json:"category"`
type UpdateProjectAssetRequest struct {
Category *string `json:"category"`
FolderID *string `json:"folderId"`
}
type CreateAssetVersionRequest struct {
@@ -32,6 +34,10 @@ type ProjectAssetSummary struct {
PrimaryVersionID string `json:"primaryVersionId,omitempty"`
VersionCount int `json:"versionCount"`
Usages []string `json:"usages"`
FolderID string `json:"folderId,omitempty"`
Position int `json:"position"`
StorageKey string `json:"storageKey,omitempty"`
PreviewText string `json:"previewText,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Character *CharacterCardSummary `json:"character,omitempty"`
}
@@ -103,20 +109,16 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
if !validAssetCategory(category) {
return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类")
}
folderID, err := s.resolveProjectAssetFolderID(projectID, req.FolderID)
if err != nil {
return ProjectAssetSummary{}, err
}
linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID)
if err != nil {
return ProjectAssetSummary{}, err
}
if linked {
versions, versionErr := s.repo.AssetVersions(asset.ID)
if versionErr != nil {
return ProjectAssetSummary{}, versionErr
}
usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
if usageErr != nil {
return ProjectAssetSummary{}, usageErr
}
return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil
return s.projectAssetSummary(userID, projectID, asset)
}
now := time.Now()
asset.Category = category
@@ -135,7 +137,11 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
versions = append(versions, version)
}
asset.UpdatedAt = now
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, CreatedAt: now}
position, err := s.repo.NextProjectAssetPosition(projectID, folderID)
if err != nil {
return ProjectAssetSummary{}, err
}
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, FolderID: folderID, Position: position, CreatedAt: now}
created, err := s.repo.LinkProjectAsset(asset, initialVersion, &link)
if err != nil {
return ProjectAssetSummary{}, err
@@ -145,17 +151,9 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
if currentErr != nil {
return ProjectAssetSummary{}, currentErr
}
currentVersions, versionErr := s.repo.AssetVersions(asset.ID)
if versionErr != nil {
return ProjectAssetSummary{}, versionErr
}
usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
if usageErr != nil {
return ProjectAssetSummary{}, usageErr
}
return ProjectAssetSummary{ID: current.ID, Title: current.Title, MediaType: current.Kind, Category: current.Category, Status: current.Status, PrimaryVersionID: current.PrimaryVersionID, VersionCount: len(currentVersions), Usages: usages, UpdatedAt: current.UpdatedAt}, nil
return s.projectAssetSummary(userID, projectID, current)
}
return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: []string{}, UpdatedAt: asset.UpdatedAt}, nil
return s.projectAssetSummary(userID, projectID, asset)
}
func (s *Service) UnlinkProjectAsset(userID string, projectID string, assetID string) error {
@@ -214,7 +212,7 @@ func canvasReferencesCharacterAsset(payloadJSON string, assetID string) (bool, e
return false, nil
}
func (s *Service) UpdateProjectAssetCategory(userID string, projectID string, assetID string, req UpdateProjectAssetCategoryRequest) (ProjectAssetSummary, error) {
func (s *Service) UpdateProjectAsset(userID string, projectID string, assetID string, req UpdateProjectAssetRequest) (ProjectAssetSummary, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectAssetSummary{}, err
}
@@ -229,27 +227,38 @@ func (s *Service) UpdateProjectAssetCategory(userID string, projectID string, as
if !linked {
return ProjectAssetSummary{}, BadAuthRequest("素材尚未加入当前项目")
}
category := model.AssetCategory(strings.TrimSpace(req.Category))
if !validAssetCategory(category) {
return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类")
if req.Category != nil && req.FolderID != nil {
return ProjectAssetSummary{}, BadAuthRequest("一次只能修改素材分类或所在文件夹")
}
asset.Category = category
asset.UpdatedAt = time.Now()
if err := s.repo.UpdateAssetDomain(asset); err != nil {
return ProjectAssetSummary{}, err
if req.Category != nil {
category := model.AssetCategory(strings.TrimSpace(*req.Category))
if !validAssetCategory(category) {
return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类")
}
asset.Category = category
asset.UpdatedAt = time.Now()
if err := s.repo.UpdateAssetDomain(asset); err != nil {
return ProjectAssetSummary{}, err
}
if err := s.repo.BumpProjectRevision(projectID); err != nil {
return ProjectAssetSummary{}, err
}
} else if req.FolderID != nil {
folderID, folderErr := s.resolveProjectAssetFolderID(projectID, req.FolderID)
if folderErr != nil {
return ProjectAssetSummary{}, folderErr
}
position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID)
if positionErr != nil {
return ProjectAssetSummary{}, positionErr
}
if err := s.repo.MoveProjectAsset(projectID, asset.ID, folderID, position); err != nil {
return ProjectAssetSummary{}, err
}
} else {
return ProjectAssetSummary{}, BadAuthRequest("没有可更新的素材字段")
}
if err := s.repo.BumpProjectRevision(projectID); err != nil {
return ProjectAssetSummary{}, err
}
versions, err := s.repo.AssetVersions(asset.ID)
if err != nil {
return ProjectAssetSummary{}, err
}
usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
if err != nil {
return ProjectAssetSummary{}, err
}
return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil
return s.projectAssetSummary(userID, projectID, asset)
}
func (s *Service) CreateProjectAssetVersion(userID string, projectID string, assetID string, req CreateAssetVersionRequest) (model.AssetVersion, error) {
@@ -373,7 +382,15 @@ func (s *Service) ConfirmProjectAssetCandidate(userID string, projectID string,
candidate.Status = "confirmed"
candidate.ResolvedAssetID = assetID
candidate.UpdatedAt = now
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now}
folderID, folderErr := s.resolveProjectAssetFolderID(projectID, nil)
if folderErr != nil {
return ProjectAssetSummary{}, folderErr
}
position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID)
if positionErr != nil {
return ProjectAssetSummary{}, positionErr
}
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now}
if err := s.repo.ConfirmProjectAssetCandidate(candidate, &asset, &version, &link, createAsset); err != nil {
return ProjectAssetSummary{}, err
}
@@ -461,7 +478,12 @@ func (s *Service) projectAssetSummary(userID string, projectID string, asset *mo
if err != nil {
return ProjectAssetSummary{}, err
}
summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}
link, err := s.repo.ProjectAssetLink(projectID, asset.ID)
if err != nil {
return ProjectAssetSummary{}, err
}
storageKey, previewText := projectAssetPreview(asset.PayloadJSON)
summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, FolderID: link.FolderID, Position: link.Position, StorageKey: storageKey, PreviewText: previewText, UpdatedAt: asset.UpdatedAt}
if asset.Category == model.AssetCategoryCharacter && asset.PrimaryVersionID != "" {
card, cardErr := s.characterCard(userID, asset)
if cardErr != nil {
@@ -471,3 +493,20 @@ func (s *Service) projectAssetSummary(userID string, projectID string, asset *mo
}
return summary, nil
}
func projectAssetPreview(payloadJSON string) (string, string) {
var payload struct {
Data struct {
StorageKey string `json:"storageKey"`
Content string `json:"content"`
} `json:"data"`
}
if json.Unmarshal([]byte(payloadJSON), &payload) != nil {
return "", ""
}
previewRunes := []rune(strings.TrimSpace(payload.Data.Content))
if len(previewRunes) > 240 {
previewRunes = append(previewRunes[:240], '…')
}
return strings.TrimSpace(payload.Data.StorageKey), string(previewRunes)
}

View File

@@ -0,0 +1,256 @@
package service
import (
"errors"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
const projectAssetFolderMaxDepth = 8
type CreateProjectAssetFolderRequest struct {
Name string `json:"name"`
ParentID string `json:"parentId"`
Style string `json:"style"`
}
type UpdateProjectAssetFolderRequest struct {
Name *string `json:"name"`
ParentID *string `json:"parentId"`
Style *string `json:"style"`
}
func (s *Service) ProjectAssetFolders(userID string, projectID string) ([]model.ProjectAssetFolder, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return nil, err
}
return s.repo.ProjectAssetFolders(projectID)
}
func (s *Service) CreateProjectAssetFolder(userID string, projectID string, req CreateProjectAssetFolderRequest) (model.ProjectAssetFolder, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return model.ProjectAssetFolder{}, err
}
folders, err := s.repo.ProjectAssetFolders(projectID)
if err != nil {
return model.ProjectAssetFolder{}, err
}
name, err := validateProjectAssetFolderName(req.Name)
if err != nil {
return model.ProjectAssetFolder{}, err
}
parentID := strings.TrimSpace(req.ParentID)
if err := validateProjectAssetFolderParent(folders, "", parentID); err != nil {
return model.ProjectAssetFolder{}, err
}
if projectAssetFolderNameExists(folders, parentID, name, "") {
return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹")
}
position := nextProjectAssetFolderPosition(folders, parentID)
now := time.Now()
style, err := validateProjectAssetFolderStyle(req.Style)
if err != nil {
return model.ProjectAssetFolder{}, err
}
folder := model.ProjectAssetFolder{ID: newID(), ProjectID: projectID, ParentID: parentID, Name: name, NameKey: projectAssetFolderNameKey(name), Style: style, Position: position, CreatedAt: now, UpdatedAt: now}
if err := s.repo.CreateProjectAssetFolder(&folder); err != nil {
if isProjectAssetFolderNameConflict(err) {
return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹")
}
return model.ProjectAssetFolder{}, err
}
return folder, nil
}
func (s *Service) UpdateProjectAssetFolder(userID string, projectID string, folderID string, req UpdateProjectAssetFolderRequest) (model.ProjectAssetFolder, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return model.ProjectAssetFolder{}, err
}
folder, err := s.repo.ProjectAssetFolder(projectID, strings.TrimSpace(folderID))
if err != nil {
return model.ProjectAssetFolder{}, err
}
folders, err := s.repo.ProjectAssetFolders(projectID)
if err != nil {
return model.ProjectAssetFolder{}, err
}
if req.Name != nil {
name, nameErr := validateProjectAssetFolderName(*req.Name)
if nameErr != nil {
return model.ProjectAssetFolder{}, nameErr
}
folder.Name = name
folder.NameKey = projectAssetFolderNameKey(name)
}
if req.ParentID != nil {
parentID := strings.TrimSpace(*req.ParentID)
if err := validateProjectAssetFolderParent(folders, folder.ID, parentID); err != nil {
return model.ProjectAssetFolder{}, err
}
if parentID != folder.ParentID {
folder.ParentID = parentID
folder.Position = nextProjectAssetFolderPosition(folders, parentID)
}
}
if req.Style != nil {
style, styleErr := validateProjectAssetFolderStyle(*req.Style)
if styleErr != nil {
return model.ProjectAssetFolder{}, styleErr
}
folder.Style = style
}
if projectAssetFolderNameExists(folders, folder.ParentID, folder.Name, folder.ID) {
return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹")
}
folder.UpdatedAt = time.Now()
if err := s.repo.UpdateProjectAssetFolder(folder); err != nil {
if isProjectAssetFolderNameConflict(err) {
return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹")
}
return model.ProjectAssetFolder{}, err
}
return *folder, nil
}
func (s *Service) DeleteProjectAssetFolder(userID string, projectID string, folderID string) error {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return err
}
err := s.repo.DeleteProjectAssetFolder(projectID, strings.TrimSpace(folderID))
if errors.Is(err, repository.ErrProjectAssetFolderNotEmpty) {
return BadAuthRequest("文件夹非空,请先移动其中的素材和子文件夹")
}
return err
}
func (s *Service) resolveProjectAssetFolderID(projectID string, requested *string) (string, error) {
if requested == nil || strings.TrimSpace(*requested) == "" {
return "", nil
}
folderID := strings.TrimSpace(*requested)
if _, err := s.repo.ProjectAssetFolder(projectID, folderID); err != nil {
return "", BadAuthRequest("目标文件夹不存在或不属于当前素材库")
}
return folderID, nil
}
func validateProjectAssetFolderStyle(value string) (string, error) {
style := strings.TrimSpace(value)
if style == "" {
return "glass", nil
}
switch style {
case "glass", "stacked", "midnight", "paper", "cinema", "compact":
return style, nil
default:
return "", BadAuthRequest("不支持的文件夹样式")
}
}
func validateProjectAssetFolderName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", BadAuthRequest("文件夹名称不能为空")
}
if len([]rune(name)) > 60 {
return "", BadAuthRequest("文件夹名称不能超过 60 个字符")
}
return name, nil
}
func validateProjectAssetFolderParent(folders []model.ProjectAssetFolder, folderID string, parentID string) error {
byID := make(map[string]model.ProjectAssetFolder, len(folders))
for _, folder := range folders {
byID[folder.ID] = folder
}
ancestorDepth := 0
currentID := parentID
seen := make(map[string]struct{}, projectAssetFolderMaxDepth)
for currentID != "" {
if currentID == folderID && folderID != "" {
return BadAuthRequest("文件夹不能移动到自身或其子目录")
}
if _, exists := seen[currentID]; exists {
return BadAuthRequest("文件夹目录关系存在循环")
}
seen[currentID] = struct{}{}
current, ok := byID[currentID]
if !ok {
return BadAuthRequest("父文件夹不存在或不属于当前项目")
}
ancestorDepth++
currentID = current.ParentID
}
subtreeHeight := projectAssetFolderSubtreeHeight(folders, folderID)
if ancestorDepth+subtreeHeight > projectAssetFolderMaxDepth {
return BadAuthRequest("文件夹层级不能超过 8 层")
}
return nil
}
func projectAssetFolderSubtreeHeight(folders []model.ProjectAssetFolder, folderID string) int {
if folderID == "" {
return 1
}
maxDepth := 1
queue := []struct {
id string
depth int
}{{id: folderID, depth: 1}}
seen := map[string]struct{}{folderID: {}}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current.depth > maxDepth {
maxDepth = current.depth
}
for _, folder := range folders {
if folder.ParentID != current.id {
continue
}
if _, exists := seen[folder.ID]; exists {
continue
}
seen[folder.ID] = struct{}{}
queue = append(queue, struct {
id string
depth int
}{id: folder.ID, depth: current.depth + 1})
}
}
return maxDepth
}
func projectAssetFolderNameExists(folders []model.ProjectAssetFolder, parentID string, name string, excludeID string) bool {
for _, folder := range folders {
if folder.ID != excludeID && folder.ParentID == parentID && strings.EqualFold(folder.Name, name) {
return true
}
}
return false
}
func projectAssetFolderNameKey(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func isProjectAssetFolderNameConflict(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "idx_project_asset_folders_sibling_name") || strings.Contains(message, "unique constraint") || strings.Contains(message, "duplicate key")
}
func nextProjectAssetFolderPosition(folders []model.ProjectAssetFolder, parentID string) int {
position := 0
for _, folder := range folders {
if folder.ParentID == parentID && folder.Position >= position {
position = folder.Position + 1
}
}
return position
}

View File

@@ -0,0 +1,50 @@
package service
import (
"testing"
"infinite-canvas/backend/internal/model"
)
func TestValidateProjectAssetFolderParentRejectsCycle(t *testing.T) {
folders := []model.ProjectAssetFolder{
{ID: "root", ParentID: ""},
{ID: "child", ParentID: "root"},
{ID: "leaf", ParentID: "child"},
}
if err := validateProjectAssetFolderParent(folders, "root", "leaf"); err == nil {
t.Fatal("expected moving a folder into its descendant to fail")
}
}
func TestValidateProjectAssetFolderParentLimitsCombinedDepth(t *testing.T) {
folders := []model.ProjectAssetFolder{
{ID: "a", ParentID: ""},
{ID: "b", ParentID: "a"},
{ID: "c", ParentID: "b"},
{ID: "d", ParentID: "c"},
{ID: "e", ParentID: "d"},
{ID: "f", ParentID: "e"},
{ID: "g", ParentID: "f"},
{ID: "h", ParentID: "g"},
}
if err := validateProjectAssetFolderParent(folders, "", "h"); err == nil {
t.Fatal("expected a ninth level folder to fail")
}
if err := validateProjectAssetFolderParent(folders, "", "g"); err != nil {
t.Fatalf("expected an eighth level folder to be allowed: %v", err)
}
}
func TestProjectAssetFolderNameIsUniqueWithinSiblingOnly(t *testing.T) {
folders := []model.ProjectAssetFolder{
{ID: "first", ParentID: "one", Name: "Reference"},
{ID: "second", ParentID: "two", Name: "Reference"},
}
if !projectAssetFolderNameExists(folders, "one", "reference", "") {
t.Fatal("expected case-insensitive duplicate in the same parent")
}
if projectAssetFolderNameExists(folders, "three", "Reference", "") {
t.Fatal("did not expect a duplicate across different parents")
}
}

View File

@@ -136,7 +136,15 @@ func (s *Service) CreateProjectCharacter(userID string, projectID string, req Cr
}
asset := model.Asset{ID: assetID, UserID: userID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: name, PayloadJSON: payload, CreatedAt: now, UpdatedAt: now}
version := model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: string(definition), CreatedAt: now, UpdatedAt: now}
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now}
folderID, err := s.resolveProjectAssetFolderID(projectID, nil)
if err != nil {
return ProjectCharacterDetail{}, err
}
position, err := s.repo.NextProjectAssetPosition(projectID, folderID)
if err != nil {
return ProjectCharacterDetail{}, err
}
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now}
if err := s.repo.CreateProjectCharacter(projectID, &asset, &version, &link); err != nil {
return ProjectCharacterDetail{}, err
}
@@ -418,20 +426,14 @@ func (s *Service) prepareNextCharacterVersion(asset *model.Asset, name string, d
}
func (s *Service) projectCharacterDetail(userID string, projectID string, asset *model.Asset) (ProjectCharacterDetail, error) {
versions, err := s.repo.AssetVersions(asset.ID)
summary, err := s.projectAssetSummary(userID, projectID, asset)
if err != nil {
return ProjectCharacterDetail{}, err
}
usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
if err != nil {
return ProjectCharacterDetail{}, err
if summary.Character == nil {
return ProjectCharacterDetail{}, BadAuthRequest("角色素材缺少角色设定")
}
card, err := s.characterCard(userID, asset)
if err != nil {
return ProjectCharacterDetail{}, err
}
summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt, Character: &card}
return ProjectCharacterDetail{Asset: summary, Character: card}, nil
return ProjectCharacterDetail{Asset: summary, Character: *summary.Character}, nil
}
func (s *Service) characterCard(userID string, asset *model.Asset) (CharacterCardSummary, error) {