feat(assets): 素材库与画布 - 新增可交互通用文件夹
This commit is contained in:
@@ -33,6 +33,7 @@ func Models() []any {
|
||||
&model.Resource{},
|
||||
&model.Asset{},
|
||||
&model.ProjectAssetLink{},
|
||||
&model.ProjectAssetFolder{},
|
||||
&model.ProjectAssetCandidate{},
|
||||
&model.AssetVersion{},
|
||||
&model.AssetRepresentation{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
256
backend/internal/service/project_asset_folder.go
Normal file
256
backend/internal/service/project_asset_folder.go
Normal 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
|
||||
}
|
||||
50
backend/internal/service/project_asset_folder_test.go
Normal file
50
backend/internal/service/project_asset_folder_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user