fix(asset-library): 素材库 - 支持自定义分类、分页、完整预览和批量上传

This commit is contained in:
ddCat committed 2026-09-02 21:55:24 +08:00
1 parent 2ee97d45bc
commit d13ab83c4e
18 files changed
+1087 -37

No files matched your search

@@ -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)
}
@@ -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)
}
}
+2 -2
View File
@@ -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
}