fix(asset-library): 素材库 - 支持自定义分类、分页、完整预览和批量上传
This commit is contained in:
1 parent
2ee97d45bc
commit
d13ab83c4e
18 files changed
+1087
-37
No files matched your search
@@ -0,0 +1,203 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserAssetPage struct {
|
||||
Assets []json.RawMessage `json:"assets"`
|
||||
KindCounts map[string]int64 `json:"kindCounts"`
|
||||
CategoryCounts map[string]int64 `json:"categoryCounts"`
|
||||
FolderCounts map[string]int64 `json:"folderCounts"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type UserAssetPageFilter struct {
|
||||
Kind string
|
||||
Category string
|
||||
FolderID *string
|
||||
Uncategorized bool
|
||||
Status string
|
||||
Query string
|
||||
}
|
||||
|
||||
type CreateAssetFolderRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateAssetFolderRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type MoveUserAssetsRequest struct {
|
||||
AssetIDs []string `json:"assetIds"`
|
||||
FolderID string `json:"folderId"`
|
||||
}
|
||||
|
||||
func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter UserAssetPageFilter) (UserAssetPage, error) {
|
||||
page, pageSize = normalizeProjectPage(page, pageSize, 120)
|
||||
repoFilter := repository.UserAssetPageFilter{
|
||||
Kind: filter.Kind, Category: filter.Category, FolderID: filter.FolderID,
|
||||
Uncategorized: filter.Uncategorized, Status: filter.Status, Query: filter.Query,
|
||||
}
|
||||
assets, total, err := s.repo.UserAssetsPage(userID, page, pageSize, repoFilter)
|
||||
if err != nil {
|
||||
return UserAssetPage{}, err
|
||||
}
|
||||
rawAssets := make([]json.RawMessage, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
if strings.TrimSpace(asset.PayloadJSON) != "" {
|
||||
rawAssets = append(rawAssets, json.RawMessage(asset.PayloadJSON))
|
||||
}
|
||||
}
|
||||
kindRows, categoryRows, folderRows, err := s.repo.UserAssetFacets(userID, filter.Status)
|
||||
if err != nil {
|
||||
return UserAssetPage{}, err
|
||||
}
|
||||
return UserAssetPage{
|
||||
Assets: rawAssets, KindCounts: assetFacetMap(kindRows), CategoryCounts: assetFacetMap(categoryRows), FolderCounts: assetFacetMap(folderRows),
|
||||
Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func assetFacetMap(rows []repository.UserAssetFacetRow) map[string]int64 {
|
||||
result := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.Key] = row.Count
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) AssetFolders(userID string) ([]model.AssetFolder, error) {
|
||||
return s.repo.AssetFolders(userID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateAssetFolder(userID string, req CreateAssetFolderRequest) (model.AssetFolder, error) {
|
||||
name, nameKey, err := normalizeAssetFolderName(req.Name)
|
||||
if err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
exists, err := s.repo.AssetFolderNameExists(userID, nameKey, "")
|
||||
if err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
if exists {
|
||||
return model.AssetFolder{}, BadAuthRequest("已存在同名素材分类")
|
||||
}
|
||||
position, err := s.repo.NextAssetFolderPosition(userID)
|
||||
if err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
folder := model.AssetFolder{ID: newID(), UserID: userID, Name: name, NameKey: nameKey, Position: position, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.repo.CreateAssetFolder(&folder); err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAssetFolder(userID string, folderID string, req UpdateAssetFolderRequest) (model.AssetFolder, error) {
|
||||
folder, err := s.repo.AssetFolderForUser(userID, strings.TrimSpace(folderID))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.AssetFolder{}, BadAuthRequest("素材分类不存在")
|
||||
}
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
name, nameKey, err := normalizeAssetFolderName(req.Name)
|
||||
if err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
exists, err := s.repo.AssetFolderNameExists(userID, nameKey, folder.ID)
|
||||
if err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
if exists {
|
||||
return model.AssetFolder{}, BadAuthRequest("已存在同名素材分类")
|
||||
}
|
||||
folder.Name = name
|
||||
folder.NameKey = nameKey
|
||||
folder.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateAssetFolder(folder); err != nil {
|
||||
return model.AssetFolder{}, err
|
||||
}
|
||||
return *folder, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAssetFolder(userID string, folderID string) error {
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
err := s.repo.DeleteAssetFolder(userID, strings.TrimSpace(folderID))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BadAuthRequest("素材分类不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) MoveUserAssetsToFolder(userID string, req MoveUserAssetsRequest) error {
|
||||
ids := uniqueNonemptyStrings(req.AssetIDs)
|
||||
if len(ids) == 0 {
|
||||
return BadAuthRequest("请选择要移动的素材")
|
||||
}
|
||||
if len(ids) > 200 {
|
||||
return BadAuthRequest("一次最多移动 200 个素材")
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
folderID := strings.TrimSpace(req.FolderID)
|
||||
if folderID != "" {
|
||||
if _, err := s.repo.AssetFolderForUser(userID, folderID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BadAuthRequest("目标素材分类不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.repo.MoveUserAssetsToFolder(userID, ids, folderID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BadAuthRequest("部分素材不存在或不属于当前用户")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAssetFolderName(value string) (string, string, error) {
|
||||
name := strings.TrimSpace(value)
|
||||
if name == "" {
|
||||
return "", "", BadAuthRequest("请输入素材分类名称")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 40 {
|
||||
return "", "", BadAuthRequest("素材分类名称不能超过 40 个字符")
|
||||
}
|
||||
return name, strings.ToLower(name), nil
|
||||
}
|
||||
|
||||
func uniqueNonemptyStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type CanvasProjectsSyncRequest struct {
|
||||
|
||||
type UserDataSummary struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
@@ -54,7 +55,7 @@ func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
|
||||
}
|
||||
result := make([]UserDataSummary, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
|
||||
result = append(result, UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -78,6 +79,14 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
if asset.FolderID != "" {
|
||||
if _, err := s.repo.AssetFolderForUser(userID, asset.FolderID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return UserDataSummary{}, BadAuthRequest("素材分类不存在")
|
||||
}
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
}
|
||||
existing, existingErr := s.repo.AssetForUser(userID, asset.ID)
|
||||
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
|
||||
return UserDataSummary{}, existingErr
|
||||
@@ -104,7 +113,7 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
|
||||
if existingErr != nil {
|
||||
s.recordActivity(userID, "asset", 1)
|
||||
}
|
||||
return UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
|
||||
return UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUserAsset(userID string, id string) error {
|
||||
@@ -283,6 +292,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folderId"`
|
||||
Kind string `json:"kind"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
@@ -316,6 +326,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
|
||||
return model.Asset{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
FolderID: strings.TrimSpace(payload.FolderID),
|
||||
Kind: strings.TrimSpace(payload.Kind),
|
||||
Category: category,
|
||||
Status: status,
|
||||
|
||||
Reference in new issue
Block a user