283 lines
8.7 KiB
Go
283 lines
8.7 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"yingce/backend/internal/model"
|
|
"yingce/backend/internal/repository"
|
|
)
|
|
|
|
const projectAssetFolderMaxDepth = 8
|
|
|
|
type CreateProjectAssetFolderRequest struct {
|
|
Name string `json:"name"`
|
|
ParentID string `json:"parentId"`
|
|
Style string `json:"style"`
|
|
Theme string `json:"theme"`
|
|
}
|
|
|
|
type UpdateProjectAssetFolderRequest struct {
|
|
Name *string `json:"name"`
|
|
ParentID *string `json:"parentId"`
|
|
Style *string `json:"style"`
|
|
Theme *string `json:"theme"`
|
|
}
|
|
|
|
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
|
|
}
|
|
theme, err := validateProjectAssetFolderTheme(req.Theme)
|
|
if err != nil {
|
|
return model.ProjectAssetFolder{}, err
|
|
}
|
|
folder := model.ProjectAssetFolder{ID: newID(), ProjectID: projectID, ParentID: parentID, Name: name, NameKey: projectAssetFolderNameKey(name), Style: style, Theme: theme, 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 req.Theme != nil {
|
|
theme, themeErr := validateProjectAssetFolderTheme(*req.Theme)
|
|
if themeErr != nil {
|
|
return model.ProjectAssetFolder{}, themeErr
|
|
}
|
|
folder.Theme = theme
|
|
}
|
|
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 validateProjectAssetFolderTheme(value string) (string, error) {
|
|
theme := strings.TrimSpace(value)
|
|
if theme == "" {
|
|
return "aurora", nil
|
|
}
|
|
switch theme {
|
|
case "aurora", "obsidian", "ember", "pearl":
|
|
return theme, 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
|
|
}
|