fix(asset-library): 素材库 - 支持自定义分类、分页、完整预览和批量上传
This commit is contained in:
@@ -257,6 +257,7 @@ func migrations() []tableMigration {
|
||||
migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"),
|
||||
migrateTable[model.AnnouncementImageDraft]("announcement_image_drafts"),
|
||||
migrateTable[model.Asset]("assets"),
|
||||
migrateTable[model.AssetFolder]("asset_folders"),
|
||||
migrateTable[model.ProjectAssetLink]("project_asset_links"),
|
||||
migrateTable[model.ProjectAssetFolder]("project_asset_folders"),
|
||||
migrateTable[model.ProjectAssetCandidate]("project_asset_candidates"),
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 5
|
||||
const CurrentSchemaVersion int64 = 6
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
const assetTaxonomyCandidateIdentityChecksum = "sha256:asset-taxonomy-candidate-identity-v3-20260831-r1"
|
||||
const resourceUploadKeyChecksum = "sha256:resource-upload-key-v4-20260901"
|
||||
const paymentTopupChecksum = "sha256:payment-topup-v5-20260902"
|
||||
const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902"
|
||||
|
||||
const postgresSchemaMigrationLockID int64 = 73123910420260830
|
||||
|
||||
@@ -48,6 +49,7 @@ var schemaMigrations = []migration{
|
||||
{version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3},
|
||||
{version: 4, name: "resource_upload_key", checksum: resourceUploadKeyChecksum, apply: migrateSchemaV4},
|
||||
{version: 5, name: "payment_topup", checksum: paymentTopupChecksum, apply: migrateSchemaV5},
|
||||
{version: 6, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV6},
|
||||
}
|
||||
|
||||
func migrateSchemaV2(tx *gorm.DB) error {
|
||||
@@ -123,6 +125,13 @@ func migrateSchemaV5(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchemaV6(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil {
|
||||
return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MigrateSchema(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
|
||||
@@ -59,6 +59,7 @@ func Models() []any {
|
||||
&model.ResourceDeletionJob{},
|
||||
&model.AnnouncementImageDraft{},
|
||||
&model.Asset{},
|
||||
&model.AssetFolder{},
|
||||
&model.ProjectAssetLink{},
|
||||
&model.ProjectAssetFolder{},
|
||||
&model.ProjectAssetCandidate{},
|
||||
|
||||
@@ -345,6 +345,32 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if _, paged := c.GetQuery("page"); paged || hasUserAssetPageFilters(c) {
|
||||
page, pageErr := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if pageErr != nil || page < 1 {
|
||||
fail(c, http.StatusBadRequest, service.BadAuthRequest("页码必须是正整数"))
|
||||
return
|
||||
}
|
||||
pageSize, pageSizeErr := strconv.Atoi(c.DefaultQuery("page_size", "40"))
|
||||
if pageSizeErr != nil || pageSize < 1 {
|
||||
fail(c, http.StatusBadRequest, service.BadAuthRequest("每页数量必须是正整数"))
|
||||
return
|
||||
}
|
||||
var folderID *string
|
||||
if value, present := c.GetQuery("folder_id"); present {
|
||||
folderID = &value
|
||||
}
|
||||
assets, pageErr := svc.UserAssetsPage(user.ID, page, pageSize, service.UserAssetPageFilter{
|
||||
Kind: c.Query("kind"), Category: c.Query("category"), FolderID: folderID,
|
||||
Uncategorized: c.Query("uncategorized") == "1", Status: c.Query("status"), Query: c.Query("q"),
|
||||
})
|
||||
if pageErr != nil {
|
||||
failService(c, pageErr)
|
||||
return
|
||||
}
|
||||
ok(c, assets)
|
||||
return
|
||||
}
|
||||
assets, err := svc.UserAssetSummaries(user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -352,6 +378,84 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"assets": assets})
|
||||
})
|
||||
r.GET("/asset-folders", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
folders, err := svc.AssetFolders(user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"folders": folders})
|
||||
})
|
||||
r.POST("/asset-folders", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.CreateAssetFolderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
folder, err := svc.CreateAssetFolder(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"folder": folder})
|
||||
})
|
||||
r.PATCH("/asset-folders/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.UpdateAssetFolderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
folder, err := svc.UpdateAssetFolder(user.ID, c.Param("id"), req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"folder": folder})
|
||||
})
|
||||
r.DELETE("/asset-folders/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteAssetFolder(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id")})
|
||||
})
|
||||
r.PATCH("/assets/folder", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.MoveUserAssetsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.MoveUserAssetsToFolder(user.ID, req); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"assetIds": req.AssetIDs, "folderId": req.FolderID})
|
||||
})
|
||||
r.GET("/user-data/snapshot", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -494,6 +598,15 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
})
|
||||
}
|
||||
|
||||
func hasUserAssetPageFilters(c *gin.Context) bool {
|
||||
for _, key := range []string{"page_size", "kind", "category", "folder_id", "uncategorized", "status", "q"} {
|
||||
if _, present := c.GetQuery(key); present {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resourceResponseETag(resource *model.Resource) string {
|
||||
value := strings.Trim(strings.TrimSpace(resource.ETag), `"`)
|
||||
if value == "" {
|
||||
|
||||
@@ -75,6 +75,7 @@ type AnnouncementImageDraft struct {
|
||||
type Asset struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:80"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"`
|
||||
FolderID string `json:"folderId,omitempty" gorm:"index;size:36"`
|
||||
Kind string `json:"kind" gorm:"index;size:24"`
|
||||
Category AssetCategory `json:"category" gorm:"index;size:32"`
|
||||
Status AssetVersionStatus `json:"status" gorm:"index;size:24"`
|
||||
@@ -85,6 +86,17 @@ type Asset struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"`
|
||||
}
|
||||
|
||||
// AssetFolder 是用户素材库的一层自定义分类;业务分类仍由 Asset.Category 表达。
|
||||
type AssetFolder struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_asset_folders_user_name,priority:1"`
|
||||
Name string `json:"name" gorm:"size:80"`
|
||||
NameKey string `json:"-" gorm:"size:80;uniqueIndex:idx_asset_folders_user_name,priority:2"`
|
||||
Position int `json:"position" gorm:"index"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
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;index:idx_project_asset_links_project_folder_position,priority:1"`
|
||||
|
||||
212
backend/internal/repository/asset_library.go
Normal file
212
backend/internal/repository/asset_library.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserAssetPageFilter struct {
|
||||
Kind string
|
||||
Category string
|
||||
FolderID *string
|
||||
Uncategorized bool
|
||||
Status string
|
||||
Query string
|
||||
}
|
||||
|
||||
type UserAssetFacetRow struct {
|
||||
Key string
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (r *Repository) UserAssetsPage(userID string, page int, pageSize int, filter UserAssetPageFilter) ([]model.Asset, int64, error) {
|
||||
var assets []model.Asset
|
||||
var total int64
|
||||
query := userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), filter, true)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := query.Order("updated_at desc, id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&assets).Error
|
||||
return assets, total, err
|
||||
}
|
||||
|
||||
func (r *Repository) UserAssetFacets(userID string, status string) ([]UserAssetFacetRow, []UserAssetFacetRow, []UserAssetFacetRow, error) {
|
||||
base := func() *gorm.DB {
|
||||
return userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), UserAssetPageFilter{Status: status}, false)
|
||||
}
|
||||
var kindRows []UserAssetFacetRow
|
||||
if err := base().Select("kind AS key, COUNT(*) AS count").Group("kind").Scan(&kindRows).Error; err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var categoryRows []UserAssetFacetRow
|
||||
if err := base().Select("category AS key, COUNT(*) AS count").Group("category").Scan(&categoryRows).Error; err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var folderRows []UserAssetFacetRow
|
||||
if err := base().Select("folder_id AS key, COUNT(*) AS count").Group("folder_id").Scan(&folderRows).Error; err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return kindRows, categoryRows, folderRows, nil
|
||||
}
|
||||
|
||||
func userAssetFilteredQuery(query *gorm.DB, filter UserAssetPageFilter, includeSearch bool) *gorm.DB {
|
||||
if value := strings.TrimSpace(filter.Kind); value != "" {
|
||||
query = query.Where("kind = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.Category); value != "" {
|
||||
query = query.Where("category = ?", value)
|
||||
}
|
||||
if filter.Uncategorized {
|
||||
query = query.Where("folder_id = ''")
|
||||
} else if filter.FolderID != nil {
|
||||
query = query.Where("folder_id = ?", strings.TrimSpace(*filter.FolderID))
|
||||
}
|
||||
switch strings.TrimSpace(filter.Status) {
|
||||
case "active":
|
||||
query = query.Where("status <> ?", model.AssetVersionStatusArchived)
|
||||
case "archived":
|
||||
query = query.Where("status = ?", model.AssetVersionStatusArchived)
|
||||
case "":
|
||||
default:
|
||||
query = query.Where("status = ?", strings.TrimSpace(filter.Status))
|
||||
}
|
||||
if includeSearch {
|
||||
if value := strings.ToLower(strings.TrimSpace(filter.Query)); value != "" {
|
||||
pattern := "%" + value + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(payload_json) LIKE ?", pattern, pattern)
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (r *Repository) AssetFolders(userID string) ([]model.AssetFolder, error) {
|
||||
var folders []model.AssetFolder
|
||||
err := r.db.Where("user_id = ?", userID).Order("position asc, created_at asc").Find(&folders).Error
|
||||
return folders, err
|
||||
}
|
||||
|
||||
func (r *Repository) AssetFolderForUser(userID string, folderID string) (*model.AssetFolder, error) {
|
||||
var folder model.AssetFolder
|
||||
if err := r.db.First(&folder, "id = ? AND user_id = ?", folderID, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &folder, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AssetFolderNameExists(userID string, nameKey string, excludeID string) (bool, error) {
|
||||
query := r.db.Model(&model.AssetFolder{}).Where("user_id = ? AND name_key = ?", userID, nameKey)
|
||||
if excludeID != "" {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
var count int64
|
||||
err := query.Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *Repository) NextAssetFolderPosition(userID string) (int, error) {
|
||||
var row struct{ Maximum int }
|
||||
err := r.db.Model(&model.AssetFolder{}).Select("COALESCE(MAX(position), -1) AS maximum").Where("user_id = ?", userID).Scan(&row).Error
|
||||
return row.Maximum + 1, err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateAssetFolder(folder *model.AssetFolder) error {
|
||||
return r.db.Create(folder).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateAssetFolder(folder *model.AssetFolder) error {
|
||||
result := r.db.Model(&model.AssetFolder{}).Where("id = ? AND user_id = ?", folder.ID, folder.UserID).Updates(map[string]any{
|
||||
"name": folder.Name, "name_key": folder.NameKey, "updated_at": folder.UpdatedAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) MoveUserAssetsToFolder(userID string, assetIDs []string, folderID string) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
return moveUserAssetsToFolder(tx, userID, assetIDs, folderID)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteAssetFolder(userID string, folderID string) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var folder model.AssetFolder
|
||||
if err := tx.First(&folder, "id = ? AND user_id = ?", folderID, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var assets []model.Asset
|
||||
if err := tx.Where("user_id = ? AND folder_id = ?", userID, folderID).Find(&assets).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, len(assets))
|
||||
for index := range assets {
|
||||
ids[index] = assets[index].ID
|
||||
}
|
||||
if err := moveUserAssetsToFolder(tx, userID, ids, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Delete(&model.AssetFolder{}, "id = ? AND user_id = ?", folderID, userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func moveUserAssetsToFolder(tx *gorm.DB, userID string, assetIDs []string, folderID string) error {
|
||||
if len(assetIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var assets []model.Asset
|
||||
if err := tx.Where("user_id = ? AND id IN ?", userID, assetIDs).Find(&assets).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(assets) != len(assetIDs) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for index := range assets {
|
||||
payloadJSON, err := assetPayloadWithFolder(assets[index].PayloadJSON, folderID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", assets[index].ID, userID).Updates(map[string]any{
|
||||
"folder_id": folderID, "payload_json": payloadJSON, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assetPayloadWithFolder(payloadJSON string, folderID string, updatedAt time.Time) (string, error) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if folderID == "" {
|
||||
delete(payload, "folderId")
|
||||
} else {
|
||||
payload["folderId"] = folderID
|
||||
}
|
||||
payload["updatedAt"] = updatedAt.Format(time.RFC3339Nano)
|
||||
encoded, err := json.Marshal(payload)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func IsAssetFolderNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
102
backend/internal/repository/asset_library_test.go
Normal file
102
backend/internal/repository/asset_library_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newAssetLibraryTestRepository(t *testing.T) (*Repository, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:asset-library-test-%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return New(db), db
|
||||
}
|
||||
|
||||
func TestUserAssetsPagePaginatesAndIsolatesUsers(t *testing.T) {
|
||||
repo, db := newAssetLibraryTestRepository(t)
|
||||
now := time.Now().UTC()
|
||||
for _, asset := range []model.Asset{
|
||||
{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1","title":"海边"}`, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "asset-2", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "室内", PayloadJSON: `{"id":"asset-2","title":"室内"}`, CreatedAt: now, UpdatedAt: now.Add(time.Second)},
|
||||
{ID: "asset-3", UserID: "user-1", Kind: "text", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed, Title: "提示词", PayloadJSON: `{"id":"asset-3","title":"提示词"}`, CreatedAt: now, UpdatedAt: now.Add(2 * time.Second)},
|
||||
{ID: "asset-4", UserID: "user-2", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "他人素材", PayloadJSON: `{"id":"asset-4","title":"他人素材"}`, CreatedAt: now, UpdatedAt: now},
|
||||
} {
|
||||
if err := db.Create(&asset).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
assets, total, err := repo.UserAssetsPage("user-1", 2, 1, UserAssetPageFilter{Kind: "image", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(assets) != 1 || assets[0].ID != "asset-1" {
|
||||
t.Fatalf("page result = total %d, assets %#v; want total 2 and asset-1", total, assets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAssetFolderMovesAssetsToUncategorized(t *testing.T) {
|
||||
repo, db := newAssetLibraryTestRepository(t)
|
||||
now := time.Now().UTC()
|
||||
folder := model.AssetFolder{ID: "folder-1", UserID: "user-1", Name: "灵感", NameKey: "灵感", Position: 0, CreatedAt: now, UpdatedAt: now}
|
||||
asset := model.Asset{ID: "asset-1", UserID: "user-1", FolderID: folder.ID, Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1","folderId":"folder-1"}`, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&folder).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&asset).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteAssetFolder("user-1", folder.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var moved model.Asset
|
||||
if err := db.First(&moved, "id = ?", asset.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.FolderID != "" || strings.Contains(moved.PayloadJSON, "folderId") {
|
||||
t.Fatalf("asset after folder deletion = %#v", moved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveUserAssetsToFolderRollsBackWhenAnyAssetIsForeign(t *testing.T) {
|
||||
repo, db := newAssetLibraryTestRepository(t)
|
||||
now := time.Now().UTC()
|
||||
folder := model.AssetFolder{ID: "folder-1", UserID: "user-1", Name: "灵感", NameKey: "灵感", Position: 0, CreatedAt: now, UpdatedAt: now}
|
||||
assets := []model.Asset{
|
||||
{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1"}`, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "asset-2", UserID: "user-2", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "他人素材", PayloadJSON: `{"id":"asset-2"}`, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&folder).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := range assets {
|
||||
if err := db.Create(&assets[index]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.MoveUserAssetsToFolder("user-1", []string{"asset-1", "asset-2"}, folder.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("move error = %v, want record-not-found", err)
|
||||
}
|
||||
var unchanged model.Asset
|
||||
if err := db.First(&unchanged, "id = ?", "asset-1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unchanged.FolderID != "" {
|
||||
t.Fatalf("foreign asset caused partial move: %#v", unchanged)
|
||||
}
|
||||
}
|
||||
@@ -1082,7 +1082,7 @@ func (r *Repository) Assets(userID string) ([]model.Asset, error) {
|
||||
|
||||
func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) {
|
||||
var assets []model.Asset
|
||||
err := r.db.Select("id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
|
||||
err := r.db.Select("id", "folder_id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
@@ -1106,7 +1106,7 @@ func (r *Repository) AssetsForUserIDs(userID string, ids []string) ([]model.Asse
|
||||
func (r *Repository) UpsertAsset(asset *model.Asset) error {
|
||||
result := r.db.Model(&model.Asset{}).
|
||||
Where("id = ? AND user_id = ?", asset.ID, asset.UserID).
|
||||
Updates(map[string]any{"kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
|
||||
Updates(map[string]any{"folder_id": asset.FolderID, "kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
|
||||
if result.Error != nil || result.RowsAffected > 0 {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
203
backend/internal/service/asset_library.go
Normal file
203
backend/internal/service/asset_library.go
Normal file
@@ -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