Files
ttstd-infinite-canvas/backend/internal/repository/asset_library.go
李祥生 8db7cf0300 fix(assets): 统一素材库 entity 口径并修复远端空结果遮蔽本地素材
- 个人素材库列表与 facets 统一排除 entity 类型,计数与列表同口径,修复“总数 30 但列表为空”
- 个人素材页 /assets、参考内容弹窗、项目资产页三处“远端空数组 truthy 遮蔽本地素材”统一修复:远端无可展示素材时回退本地列表,分页、分类、关键词过滤同步切换口径
- 项目资产页待确认候选不再硬编码 character 分类:分类切换联动查询,角色/道具/场景动态图标与文案,仅角色类展示“归并到角色”
- 项目资产页分类计数改为独立查询并与 tab 解耦,修复切 tab 时总数漂移;确认资产后同步失效候选与资产列表缓存;切分类重置候选页码
2026-09-15 17:28:28 +08:00

215 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 素材库页面只展示媒体与文本素材;entity 角色卡由项目资产页管理。列表与 facets 必须同口径排除,
// 否则 facets 会计入 entity,前端出现“全部计数 30 但列表为空”的矛盾。
query := userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ? AND kind <> ?", userID, "entity"), 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 = ? AND kind <> ?", userID, "entity"), 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)
}