fix(*): 素材库与画布 - 修复批量删除刷新、选择布局及工具引用
This commit is contained in:
@@ -98,11 +98,15 @@ func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, b
|
||||
return validateStructuredReplacementQuotaWithPolicy(usage, kind, count, bytes, policy.Resource)
|
||||
}
|
||||
|
||||
func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string) error {
|
||||
func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string, purge bool) error {
|
||||
if h.svc == nil {
|
||||
return nil
|
||||
}
|
||||
return h.svc.deleteUserAssetWithResources(userID, assetID)
|
||||
return h.svc.deleteUserAssetWithResources(userID, assetID, purge)
|
||||
}
|
||||
|
||||
func (h canvasHost) PurgeUserAssetsWithResources(userID string, assetIDs []string) error {
|
||||
return h.svc.deleteUserAssetsWithResources(userID, assetIDs, true)
|
||||
}
|
||||
|
||||
func (h canvasHost) RecordActivity(userID, event string, count int) {
|
||||
@@ -182,6 +186,14 @@ func (s *Service) DeleteUserAsset(userID string, id string) error {
|
||||
return s.canvasDomain().DeleteUserAsset(userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) PurgeUserAsset(userID string, id string) error {
|
||||
return s.canvasDomain().PurgeUserAsset(userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) PurgeUserAssets(userID string, ids []string) error {
|
||||
return s.canvasDomain().PurgeUserAssets(userID, ids)
|
||||
}
|
||||
|
||||
func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) {
|
||||
return s.canvasDomain().UserAssets(userID)
|
||||
}
|
||||
|
||||
@@ -66,3 +66,47 @@ func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) {
|
||||
t.Fatalf("unreferenced media not cleaned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchivedAssetDeletionRemovesCanvasHistoryReference(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
resource := model.Resource{
|
||||
ID: "archived-history-resource", UserID: "user-1", Provider: "unsupported-test-provider",
|
||||
ObjectKey: "users/user-1/image/archived-history.png", Status: model.ResourceStatusReady,
|
||||
}
|
||||
asset := model.Asset{
|
||||
ID: "archived-history-asset", UserID: "user-1", Title: "回收站历史素材",
|
||||
Status: model.AssetVersionStatusArchived, PayloadJSON: `{"data":{"storageKey":"resource:archived-history-resource"}}`,
|
||||
}
|
||||
snapshot := model.CanvasSnapshot{
|
||||
ID: "archived-history-snapshot", CanvasID: "canvas", UserID: "user-1", Revision: 1,
|
||||
Title: "历史画布", PayloadJSON: `{}`, CreatedAt: time.Now(),
|
||||
}
|
||||
ref := model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: resource.ID}
|
||||
for _, item := range []any{&resource, &asset, &snapshot, &ref} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.DeleteUserAsset("user-1", asset.ID); err != nil {
|
||||
t.Fatalf("archived asset deletion failed: %v", err)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
model any
|
||||
want int64
|
||||
}{
|
||||
{&model.Asset{}, 0},
|
||||
{&model.Resource{}, 0},
|
||||
{&model.CanvasSnapshotResource{}, 0},
|
||||
{&model.CanvasSnapshot{}, 1},
|
||||
{&model.ResourceDeletionJob{}, 1},
|
||||
} {
|
||||
var count int64
|
||||
if err := db.Model(check.model).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != check.want {
|
||||
t.Fatalf("%T count=%d, want %d", check.model, count, check.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
backend/internal/app/resource_batch_delete_test.go
Normal file
86
backend/internal/app/resource_batch_delete_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestPurgeAssetsBatchSharedResourcesAndHistory(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
for _, id := range []string{"batch-shared", "keep-shared", "same-object", "foreign-alias"} {
|
||||
owner, objectKey := "user-1", id+".png"
|
||||
if id == "foreign-alias" {
|
||||
owner, objectKey = "user-2", "same-object.png"
|
||||
}
|
||||
if err := db.Create(&model.Resource{ID: id, UserID: owner, Provider: "unsupported-test-provider", ObjectKey: objectKey, Status: model.ResourceStatusReady}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, record := range []any{
|
||||
&model.Asset{ID: "first", UserID: "user-1", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{"resourceIds":["batch-shared","keep-shared","same-object"]}`},
|
||||
&model.Asset{ID: "second", UserID: "user-1", Status: model.AssetVersionStatusArchived, PayloadJSON: `{}`},
|
||||
&model.AssetVersion{ID: "second-version", AssetID: "second", DefinitionJSON: `{"resourceId":"batch-shared"}`},
|
||||
&model.AssetRepresentation{ID: "second-representation", AssetVersionID: "second-version", Role: "image", ResourceID: "batch-shared", MetadataJSON: `{}`},
|
||||
&model.Asset{ID: "unselected", UserID: "user-1", PayloadJSON: `{}`},
|
||||
&model.AssetVersion{ID: "unselected-version", AssetID: "unselected", DefinitionJSON: `{}`},
|
||||
&model.AssetRepresentation{ID: "unselected-representation", AssetVersionID: "unselected-version", Role: "video", MetadataJSON: `{"resourceId":"keep-shared"}`},
|
||||
&model.Task{ID: "batch-task", UserID: "user-1", Status: model.TaskStatusRunning, InputJSON: `{"resourceId":"batch-shared"}`},
|
||||
&model.CanvasProject{ID: "batch-canvas", UserID: "user-1", PayloadJSON: `{"resourceId":"batch-shared"}`},
|
||||
&model.CanvasSnapshot{ID: "batch-snapshot", CanvasID: "batch-canvas", UserID: "user-1", Revision: 1, PayloadJSON: `{}`, CreatedAt: time.Now()},
|
||||
&model.CanvasSnapshotResource{SnapshotID: "batch-snapshot", ResourceID: "batch-shared"},
|
||||
} {
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Force an error late in the resource transaction, after asset and outbox writes.
|
||||
if err := db.Exec("CREATE TRIGGER fail_batch_resource_delete BEFORE DELETE ON resources BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.PurgeUserAssets("user-1", []string{"first", "second"}); err == nil {
|
||||
t.Fatal("expected rollback")
|
||||
}
|
||||
assertCount := func(record any, want int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(record).Count(&count).Error; err != nil || count != want {
|
||||
t.Fatalf("%T count=%d want=%d err=%v", record, count, want, err)
|
||||
}
|
||||
}
|
||||
assertCount(&model.Asset{}, 3)
|
||||
assertCount(&model.Resource{}, 4)
|
||||
assertCount(&model.ResourceDeletionJob{}, 0)
|
||||
assertCount(&model.CanvasSnapshotResource{}, 1)
|
||||
assertCount(&model.AssetVersion{}, 2)
|
||||
assertCount(&model.AssetRepresentation{}, 2)
|
||||
if err := db.Exec("DROP TRIGGER fail_batch_resource_delete").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.PurgeUserAssets("user-1", []string{"first", " second ", "first"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCount(&model.Asset{}, 1)
|
||||
assertCount(&model.Resource{}, 2)
|
||||
assertCount(&model.ResourceDeletionJob{}, 1)
|
||||
assertCount(&model.CanvasSnapshotResource{}, 0)
|
||||
assertCount(&model.CanvasSnapshot{}, 1)
|
||||
assertCount(&model.Task{}, 1)
|
||||
assertCount(&model.CanvasProject{}, 1)
|
||||
assertCount(&model.AssetVersion{}, 1)
|
||||
assertCount(&model.AssetRepresentation{}, 1)
|
||||
var job model.ResourceDeletionJob
|
||||
if err := db.First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.ResourceID != "batch-shared" {
|
||||
t.Fatalf("wrong physical object queued: %s", job.ResourceID)
|
||||
}
|
||||
for _, id := range []string{"keep-shared", "foreign-alias"} {
|
||||
var resource model.Resource
|
||||
if err := db.First(&resource, "id = ?", id).Error; err != nil {
|
||||
t.Fatalf("shared resource %s lost: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,25 +18,54 @@ import (
|
||||
|
||||
qiniuAuth "github.com/qiniu/go-sdk/v7/auth"
|
||||
qiniuStorage "github.com/qiniu/go-sdk/v7/storage"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) deleteUserAssetWithResources(userID string, assetID string) error {
|
||||
asset, err := s.repo.AssetForUser(userID, assetID)
|
||||
func (s *Service) deleteUserAssetWithResources(userID string, assetID string, purge bool) error {
|
||||
return s.deleteUserAssetsWithResources(userID, []string{assetID}, purge)
|
||||
}
|
||||
|
||||
func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, purge bool) error {
|
||||
if strings.TrimSpace(userID) == "" || len(ids) == 0 || len(ids) > 1000 {
|
||||
return BadAuthRequest("每次删除须提供 1–1000 个素材 ID 和有效用户")
|
||||
}
|
||||
assetIDs := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return BadAuthRequest("素材 ID 不能为空")
|
||||
}
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = struct{}{}
|
||||
assetIDs = append(assetIDs, id)
|
||||
}
|
||||
}
|
||||
assetRecords, err := s.repo.AssetsForUserIDs(userID, assetIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assetReferences, err := s.repo.AssetBusinessReferences(userID, assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(assetRecords) != len(assetIDs) {
|
||||
return NotFound("素材不存在或无权删除")
|
||||
}
|
||||
versions, representations, err := s.repo.AssetResourceRecords(assetID)
|
||||
deleteReferencedResources := purge
|
||||
if !purge {
|
||||
// 内部普通删除仍为单素材保护路径;用户显式删除统一走 purge。
|
||||
if len(assetRecords) != 1 {
|
||||
return BadAuthRequest("普通删除仅支持单个素材")
|
||||
}
|
||||
deleteReferencedResources = assetRecords[0].Status == model.AssetVersionStatusArchived
|
||||
}
|
||||
versions, representations, err := s.repo.AssetsResourceRecords(assetIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceIDs := map[string]struct{}{}
|
||||
if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil {
|
||||
return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件")
|
||||
for _, asset := range assetRecords {
|
||||
if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil {
|
||||
return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件")
|
||||
}
|
||||
}
|
||||
for _, version := range versions {
|
||||
if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs); err != nil {
|
||||
@@ -64,12 +93,24 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
|
||||
ownedIDSet[resource.ID] = struct{}{}
|
||||
}
|
||||
|
||||
usages := make([]resourceUsage, 0, len(assetReferences))
|
||||
for _, reference := range assetReferences {
|
||||
usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||
usages := make([]resourceUsage, 0)
|
||||
if !deleteReferencedResources {
|
||||
assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetIDs[0])
|
||||
if referenceErr != nil {
|
||||
return referenceErr
|
||||
}
|
||||
for _, reference := range assetReferences {
|
||||
usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||
}
|
||||
}
|
||||
if len(ownedIDs) > 0 {
|
||||
snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, assetID, ownedIDs)
|
||||
var snapshot repository.ResourceReferenceSnapshot
|
||||
var snapshotErr error
|
||||
if deleteReferencedResources {
|
||||
snapshot, snapshotErr = s.repo.OtherAssetResourceReferences(userID, assetIDs)
|
||||
} else {
|
||||
snapshot, snapshotErr = s.repo.ResourceReferenceSnapshot(userID, assetIDs[0], ownedIDs)
|
||||
}
|
||||
if snapshotErr != nil {
|
||||
return snapshotErr
|
||||
}
|
||||
@@ -80,7 +121,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
|
||||
sharedAssetResourceIDs[reference.ResourceID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||
if !deleteReferencedResources {
|
||||
usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, document := range snapshot.Documents {
|
||||
@@ -106,7 +149,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
|
||||
}
|
||||
continue
|
||||
}
|
||||
usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title})
|
||||
if !deleteReferencedResources {
|
||||
usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sharedAssetResourceIDs) > 0 {
|
||||
@@ -139,7 +184,10 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
|
||||
deletionJobs := resourceDeletionJobs(userID, physicalObjects)
|
||||
// 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动;
|
||||
// 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。
|
||||
if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs, deletionJobs); err != nil {
|
||||
if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, deleteReferencedResources); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return NotFound("素材不存在或无权删除")
|
||||
}
|
||||
if errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) {
|
||||
return BadAuthRequest("素材仍被画布历史版本引用,已保留文件")
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestDeleteAssetDatabaseFailureLeavesPhysicalObjectAndNoOutbox(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.deleteUserAssetWithResources("user-1", "asset-1"); err == nil {
|
||||
if err := svc.deleteUserAssetWithResources("user-1", "asset-1", false); err == nil {
|
||||
t.Fatal("forced database failure should be returned")
|
||||
}
|
||||
if _, err := os.Stat(resourcePath); err != nil {
|
||||
@@ -298,6 +298,49 @@ func TestDeleteGeneratedAssetTaskReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeConfirmedAssetIgnoresTaskReferences(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
payload := `{"url":"/api/resources/purge-confirmed-resource/file"}`
|
||||
resource := model.Resource{
|
||||
ID: "purge-confirmed-resource", UserID: "user-1", Provider: "unsupported-test-provider",
|
||||
ObjectKey: "purge-confirmed.png", Status: model.ResourceStatusReady,
|
||||
}
|
||||
asset := model.Asset{
|
||||
ID: "purge-confirmed-asset", UserID: "user-1", Title: "本地已在回收站的素材",
|
||||
Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload,
|
||||
}
|
||||
task := model.Task{
|
||||
ID: "purge-confirmed-task", UserID: "user-1", Prompt: "将图片拆分为可独立编辑的图层",
|
||||
Status: model.TaskStatusRunning, InputJSON: payload, ResultJSON: payload,
|
||||
}
|
||||
for _, item := range []any{&resource, &asset, &task} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.PurgeUserAsset("user-1", asset.ID); err != nil {
|
||||
t.Fatalf("PurgeUserAsset() error = %v", err)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
model any
|
||||
want int64
|
||||
}{
|
||||
{&model.Asset{}, 0},
|
||||
{&model.Resource{}, 0},
|
||||
{&model.ResourceDeletionJob{}, 1},
|
||||
{&model.Task{}, 1},
|
||||
} {
|
||||
var count int64
|
||||
if err := db.Model(check.model).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != check.want {
|
||||
t.Fatalf("%T count=%d, want %d", check.model, count, check.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) {
|
||||
svc, db, dataDir := newResourceDeletionTestService(t)
|
||||
objectKey := "users/user-1/image/queued.png"
|
||||
@@ -330,7 +373,7 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbox(t *testing.T) {
|
||||
func TestExpiredArchivedAssetCleanupDeletesReferencedAssetAndUsesDeletionOutbox(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
old := time.Now().Add(-45 * 24 * time.Hour)
|
||||
resource := model.Resource{
|
||||
@@ -363,25 +406,8 @@ func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbo
|
||||
if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assetCount != 1 || resourceCount != 1 || jobCount != 0 {
|
||||
t.Fatalf("referenced archived asset cleanup changed data: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount)
|
||||
}
|
||||
|
||||
if err := db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", canvas.ID, canvas.UserID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.cleanupExpiredArchivedAssets()
|
||||
if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assetCount != 0 || resourceCount != 0 || jobCount != 1 {
|
||||
t.Fatalf("unreferenced archived asset did not use deletion outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount)
|
||||
t.Fatalf("referenced archived asset was not force deleted through outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ type Host interface {
|
||||
WithStorageLock(fn func() error) error
|
||||
StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error
|
||||
StructuredReplacementQuota(userID, kind string, count int, bytes int64) error
|
||||
DeleteUserAssetWithResources(userID, assetID string) error
|
||||
DeleteUserAssetWithResources(userID, assetID string, purge bool) error
|
||||
PurgeUserAssetsWithResources(userID string, assetIDs []string) error
|
||||
RecordActivity(userID, event string, count int)
|
||||
}
|
||||
|
||||
@@ -37,7 +38,8 @@ func (nopHost) WithStorageLock(fn func() error) error {
|
||||
}
|
||||
func (nopHost) StructuredQuota(string, string, bool, int64) error { return nil }
|
||||
func (nopHost) StructuredReplacementQuota(string, string, int, int64) error { return nil }
|
||||
func (nopHost) DeleteUserAssetWithResources(string, string) error { return nil }
|
||||
func (nopHost) DeleteUserAssetWithResources(string, string, bool) error { return nil }
|
||||
func (nopHost) PurgeUserAssetsWithResources(string, []string) error { return nil }
|
||||
func (nopHost) RecordActivity(string, string, int) {}
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -128,7 +128,17 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
|
||||
|
||||
func (s *Service) DeleteUserAsset(userID string, id string) error {
|
||||
return s.host.WithStorageLock(func() error {
|
||||
return s.host.DeleteUserAssetWithResources(userID, id)
|
||||
return s.host.DeleteUserAssetWithResources(userID, id, false)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) PurgeUserAsset(userID string, id string) error {
|
||||
return s.PurgeUserAssets(userID, []string{id})
|
||||
}
|
||||
|
||||
func (s *Service) PurgeUserAssets(userID string, ids []string) error {
|
||||
return s.host.WithStorageLock(func() error {
|
||||
return s.host.PurgeUserAssetsWithResources(userID, ids)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
126
backend/internal/handler/asset_delete_test.go
Normal file
126
backend/internal/handler/asset_delete_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/auth"
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAssetBatchDeleteHTTP(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := database.MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []any{
|
||||
&model.User{ID: "batch-user", Username: "batch-user", Email: "batch@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive},
|
||||
&model.AuthSession{ID: "batch-session", UserID: "batch-user", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)},
|
||||
&model.Asset{ID: "foreign", UserID: "other-user", PayloadJSON: `{}`},
|
||||
} {
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ids := make([]string, 100)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("batch-%03d", i)
|
||||
if err := db.Create(&model.Asset{ID: ids[i], UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router := gin.New()
|
||||
RegisterUserDataRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir()))
|
||||
call := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if authenticated {
|
||||
r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: "batch-session.test-token"})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
assertAssets := func(want int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(&model.Asset{}).Count(&count).Error; err != nil || count != want {
|
||||
t.Fatalf("asset count=%d want=%d err=%v", count, want, err)
|
||||
}
|
||||
}
|
||||
path := "/api/assets/batch-delete"
|
||||
if w := call(http.MethodPost, path, `["batch-000"]`, false); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, body := range []string{`[]`, `null`, `{ "ids": ["batch-000"] }`, `["batch-000", ""]`, `[1]`} {
|
||||
if w := call(http.MethodPost, path, body, true); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid body %s: %d %s", body, w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
}
|
||||
for _, badID := range []string{"foreign", "missing"} {
|
||||
w := call(http.MethodPost, path, `["batch-000","`+badID+`"]`, true)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("ownership/missing %s: %d %s", badID, w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
}
|
||||
body, _ := json.Marshal(ids)
|
||||
if err := db.Exec("CREATE TRIGGER fail_batch_delete BEFORE DELETE ON assets WHEN OLD.id = 'batch-099' BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := call(http.MethodPost, path, string(body), true); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("database failure: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
if err := db.Exec("DROP TRIGGER fail_batch_delete").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := call(http.MethodPost, path, string(body), true)
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
IDs []string `json:"ids"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != http.StatusOK || response.Code != 0 || len(response.Data.IDs) != 100 {
|
||||
t.Fatalf("batch response: %d %s err=%v", w.Code, w.Body.String(), err)
|
||||
}
|
||||
assertAssets(1)
|
||||
|
||||
// The existing DELETE endpoint uses the same purge policy for non-archived assets.
|
||||
resource := model.Resource{ID: "single-resource", UserID: "batch-user", Provider: "unsupported-test-provider", ObjectKey: "single.png"}
|
||||
payload := `{"url":"/api/resources/single-resource/file"}`
|
||||
for _, record := range []any{
|
||||
&resource,
|
||||
&model.Asset{ID: "single", UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload},
|
||||
&model.Task{ID: "running-task", UserID: "batch-user", Status: model.TaskStatusRunning, InputJSON: payload},
|
||||
} {
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusOK {
|
||||
t.Fatalf("single referenced delete: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(1)
|
||||
}
|
||||
@@ -1090,6 +1090,65 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
/assets/batch-delete:
|
||||
post:
|
||||
tags: [assets]
|
||||
summary: 批量彻底删除当前用户素材
|
||||
description: 顶层 JSON 数组,一次请求、一个数据库事务。所有 ID 必须属于当前用户,不受任务、画布或历史引用阻塞;共享文件保留,其余通过 Outbox 异步清理,失败重试。任一素材不存在、越权或数据库写入失败时整批不删除。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1000
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
example: ["asset-1", "asset-2"]
|
||||
responses:
|
||||
"200":
|
||||
description: 成功信封,data.ids 返回本次请求的素材 ID 数组;物理文件清理由后台执行。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
"500":
|
||||
$ref: "#/components/responses/Error"
|
||||
/assets/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
delete:
|
||||
tags: [assets]
|
||||
summary: 彻底删除当前用户素材
|
||||
description: 不依赖素材 archived 状态,也不受画布、任务、项目或历史快照引用阻塞;其他素材共享的物理对象仍保留。
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
components:
|
||||
parameters:
|
||||
AgentRunID:
|
||||
|
||||
@@ -18,6 +18,24 @@ import (
|
||||
)
|
||||
|
||||
func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/assets/batch-delete", 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, 256<<10)
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.PurgeUserAssets(user.ID, ids); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"ids": ids})
|
||||
})
|
||||
r.POST("/assets/batch", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -586,7 +604,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteUserAsset(user.ID, c.Param("id")); err != nil {
|
||||
if err := svc.PurgeUserAsset(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) {
|
||||
router := gin.New()
|
||||
RegisterUserDataRoutes(router.Group("/api"), &service.Service{})
|
||||
wanted := map[string]bool{
|
||||
"GET /api/canvas-projects": false,
|
||||
"GET /api/canvas-projects/:id": false,
|
||||
"POST /api/assets/batch": false,
|
||||
"GET /api/canvas-projects": false,
|
||||
"GET /api/canvas-projects/:id": false,
|
||||
"POST /api/assets/batch": false,
|
||||
"POST /api/assets/batch-delete": false,
|
||||
}
|
||||
for _, route := range router.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
|
||||
@@ -1156,7 +1156,7 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error {
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteAsset(userID string, id string) error {
|
||||
return r.DeleteAssetAndResources(userID, id, nil, nil)
|
||||
return r.DeleteAssetAndResources(userID, id, nil, nil, false)
|
||||
}
|
||||
|
||||
func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ResourceReferenceDocument 是资源删除校验使用的只读业务文档快照。
|
||||
@@ -36,8 +37,12 @@ type ResourceReferenceSnapshot struct {
|
||||
}
|
||||
|
||||
func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, []model.AssetRepresentation, error) {
|
||||
return r.AssetsResourceRecords([]string{assetID})
|
||||
}
|
||||
|
||||
func (r *Repository) AssetsResourceRecords(assetIDs []string) ([]model.AssetVersion, []model.AssetRepresentation, error) {
|
||||
var versions []model.AssetVersion
|
||||
if err := r.db.Where("asset_id = ?", assetID).Find(&versions).Error; err != nil {
|
||||
if err := r.db.Where("asset_id IN ?", assetIDs).Find(&versions).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
@@ -54,6 +59,35 @@ func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion,
|
||||
return versions, representations, nil
|
||||
}
|
||||
|
||||
// OtherAssetResourceReferences 只查询整批删除范围之外的素材,不扫描任务和画布。
|
||||
func (r *Repository) OtherAssetResourceReferences(userID string, excludedAssetIDs []string) (ResourceReferenceSnapshot, error) {
|
||||
snapshot := ResourceReferenceSnapshot{}
|
||||
var assets []model.Asset
|
||||
if err := r.db.Where("user_id = ? AND id NOT IN ?", userID, excludedAssetIDs).Find(&assets).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
ids := make([]string, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
ids = append(ids, asset.ID)
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: asset.ID, PrimaryJSON: asset.PayloadJSON})
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
versions, representations, err := r.AssetsResourceRecords(ids)
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, version := range versions {
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: version.ID, PrimaryJSON: version.DefinitionJSON})
|
||||
}
|
||||
for _, representation := range representations {
|
||||
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, ResourceID: representation.ResourceID})
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: representation.ID, PrimaryJSON: representation.MetadataJSON})
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ResourcesForUserIDs(userID string, resourceIDs []string) ([]model.Resource, error) {
|
||||
if len(resourceIDs) == 0 {
|
||||
return []model.Resource{}, nil
|
||||
@@ -352,12 +386,42 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob) error {
|
||||
func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error {
|
||||
return r.DeleteAssetsAndResources(userID, []string{assetID}, resourceIDs, deletionJobs, deleteReferencedResources)
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteAssetsAndResources(userID string, assetIDs []string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
var ownedAssets []model.Asset
|
||||
query := tx.Where("user_id = ? AND id IN ?", userID, assetIDs).Order("id")
|
||||
if r.Dialect() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&ownedAssets).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID)
|
||||
if len(assetIDs) == 0 || len(ownedAssets) != len(assetIDs) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if deleteReferencedResources {
|
||||
if len(resourceIDs) > 0 {
|
||||
// 与历史快照写入串行化,避免清除索引后又插入外键引用。
|
||||
if r.Dialect() == "postgres" {
|
||||
var resources []model.Resource
|
||||
if err := tx.Select("id").Where("id IN ?", resourceIDs).Order("id").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&resources).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.CanvasSnapshotResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id IN ?", assetIDs)
|
||||
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -367,16 +431,16 @@ func (r *Repository) DeleteAssetAndResources(userID string, assetID string, reso
|
||||
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("asset_id = ?", assetID).Delete(&model.ProjectAssetLink{}).Error; err != nil {
|
||||
if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.ProjectAssetLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("resolved_asset_id = ?", assetID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
|
||||
if err := tx.Where("resolved_asset_id IN ?", assetIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil {
|
||||
if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.AssetVersion{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", assetID, userID).Error; err != nil {
|
||||
if err := tx.Delete(&model.Asset{}, "id IN ? AND user_id = ?", assetIDs, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(deletionJobs) > 0 {
|
||||
|
||||
@@ -11,6 +11,22 @@ description: 信封、错误码与 OpenAPI
|
||||
|
||||
出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。
|
||||
|
||||
## 素材彻底删除
|
||||
|
||||
`DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。
|
||||
|
||||
素材页及素材选择器的批量彻底删除、清空回收站统一调用 `POST /assets/batch-delete`,请求体直接为 JSON 数组(不是 `{ ids: [...] }`):
|
||||
|
||||
```json
|
||||
["asset-1", "asset-2"]
|
||||
```
|
||||
|
||||
- 每次 1–1000 项,单个请求体最多 256 KiB;ID 去除首尾空白并去重,空 ID / 非数组返回 400,任一 ID 不存在或不属于当前用户返回 404,整批不删除。
|
||||
- 成功信封 `data.ids` 为请求传入的数组。前端等待服务端成功后一次更新本地集合并冲刷持久化;失败不回退为逐条 DELETE,不先移除本地记录。
|
||||
- 整批素材、版本、表现、业务关联、待删资源及对应历史资源索引,与物理删除 Outbox 在同一数据库事务中提交,数据库失败整批回滚。共享检查排除整批被删素材,避免两条互相共享的选中素材残留文件。
|
||||
- 物理空间由后台删除队列异步释放,失败保留任务重试;接口成功不代表对象存储已经清理完成。未选中素材使用的资源,以及其他 Resource 行(包括其他用户)复用的物理对象继续保留。
|
||||
- 旧画布 / 任务 / 历史快照记录本身保留,但被删媒体链接将失效。此操作不可恢复;没有数据库结构迁移。
|
||||
|
||||
## 站点皮肤主按钮
|
||||
|
||||
### 画布配置与 Live2D(外观合同版本 9)
|
||||
|
||||
@@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 回收站彻底删除与画布工具引用
|
||||
|
||||
- [ ] 素材卡片选择框恢复左上角,徽章为选择框和更多菜单让位;批量操作条取消吸顶覆盖,并与卡片网格保留间距。需在真实登录页面检查多选边框、长列表滚动、窄屏换行和明暗主题;自动浏览器访问被登录页拦截,样式约束测试不代替视觉验收。
|
||||
- [x] 新增 `POST /api/assets/batch-delete`,顶层 JSON 数组传入素材 ID;素材页及选择器的批量删除、清空回收站只发一次请求,后端批量事务处理,不按 ID 逐次提交。整批归属检查、事务失败回滚、同批共享资源回收与未选共享资源保留有专项覆盖。
|
||||
- [x] `DELETE /api/assets/:id` 明确表示彻底删除,不再依赖服务端素材状态判断是否位于回收站;即使本地已归档但服务端仍暂存为 `confirmed`,也不会受画布、任务或历史快照引用阻塞。事务会移除对应历史资源索引并通过删除 Outbox 释放未被其他素材复用的物理文件;内部普通删除仍保留引用保护。
|
||||
- [x] 画布提示词中的工具引用按“工具类型 + 工具 ID”区分,并使用最新提示词原子追加;相同数字 ID 的不同工具不会互相覆盖或把前一个标签回退成编码令牌。后端专项测试、前端工具集成测试和前端类型检查通过。
|
||||
- [x] 删除完成后退出用户数据同步队列,再异步刷新素材库与素材选择器分页缓存,避免删除与列表查询互相等待而无法关闭确认弹窗。去掉选择器删除后的重复刷新;刷新失败保留查询错误并记录告警,不误报删除失败。8 项批量删除与工具栏专项测试通过,包含真实 QueryObserver、分页加载与同步队列链路,以及刷新延迟、刷新失败场景(仅模拟 HTTP 响应)。
|
||||
- [ ] 在真实素材页及素材选择器中分别确认单条删除、批量删除和清空回收站:删除成功后确认弹窗关闭,列表刷新;限速或模拟列表请求失败时确认弹窗不被卡住,素材选择器外层窗口保持打开。
|
||||
- [ ] 在真实素材页分别验证单个彻底删除、选择 100 条后批量删除、清空回收站及过期自动清理;网络面板应显示单个批量 POST,body 为数组。确认后台清理完成后空间释放、原画布或任务中的旧媒体引用按预期失效、其他素材复用的文件仍可访问;失败时不出现部分删除。
|
||||
- [ ] 在真实画布快速连续选择两个不同工具,并覆盖相同数字 ID、中文标签、同类型替换和多个运镜工具,确认两个标签均正常显示、保存刷新后不乱码。
|
||||
|
||||
## 画布 Agent 配置与 Live2D
|
||||
|
||||
- [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
@@ -291,14 +291,13 @@ export function AssetLibraryPickerModal({
|
||||
if (!archivedSelectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id);
|
||||
await deleteAssetsWithRemoteSync(archivedSelectedIds);
|
||||
setSelected(new Set());
|
||||
message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`);
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (remoteEnabled) void remoteQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,7 +306,7 @@ export function AssetLibraryPickerModal({
|
||||
if (!toDelete.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const item of toDelete) await deleteAssetWithRemoteSync(item.id);
|
||||
await deleteAssetsWithRemoteSync(toDelete.map((item) => item.id));
|
||||
setSelected(new Set());
|
||||
message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`);
|
||||
setCategory("all");
|
||||
@@ -315,7 +314,6 @@ export function AssetLibraryPickerModal({
|
||||
message.error(err instanceof Error ? err.message : "清空回收站失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (remoteEnabled) void remoteQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,7 +530,7 @@ export function AssetLibraryPickerModal({
|
||||
<div className="asset-picker-actions">
|
||||
{isRecycleBin ? (
|
||||
<>
|
||||
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;仍被引用的素材由服务端拒绝删除。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;关联文件会直接释放,原画布或任务中的旧引用可能失效。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !archivedCount}>
|
||||
{remoteEnabled ? "删除当前页" : "清空回收站"}
|
||||
</Button>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
const [prompt, setPrompt] = useState(savedPrompt);
|
||||
const promptRef = useRef(savedPrompt);
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
|
||||
const [nineGridOpen, setNineGridOpen] = useState(false);
|
||||
@@ -213,6 +214,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const canAutoMention = autoMentionedPrompt !== prompt;
|
||||
|
||||
useEffect(() => {
|
||||
promptRef.current = normalizedSavedPrompt;
|
||||
setPrompt(normalizedSavedPrompt);
|
||||
if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt);
|
||||
}, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]);
|
||||
@@ -258,6 +260,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
|
||||
|
||||
const updatePrompt = (value: string) => {
|
||||
promptRef.current = value;
|
||||
setPrompt(value);
|
||||
onPromptChange(node.id, value);
|
||||
if (showPromptTemplates && /(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
|
||||
@@ -266,6 +269,10 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
}
|
||||
};
|
||||
|
||||
const updatePromptFromCurrent = (updater: (currentPrompt: string) => string) => {
|
||||
updatePrompt(updater(promptRef.current));
|
||||
};
|
||||
|
||||
const applyPreset = (preset: CanvasPromptPreset) => {
|
||||
const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd();
|
||||
updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt);
|
||||
@@ -285,7 +292,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
updatePrompt(trimmedBase ? `${trimmedBase} ${insertText}` : insertText);
|
||||
};
|
||||
|
||||
const removeMotionToolMention = () => updatePrompt(removeToolMentions(prompt, "motion"));
|
||||
const removeMotionToolMention = () => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt, "motion"));
|
||||
|
||||
const submit = () => {
|
||||
const text = prompt.trim();
|
||||
@@ -330,11 +337,11 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
{!simpleMode && (mode === "image" || mode === "video") ? <div className="canvas-node-tool-controls canvas-node-tool-controls-inline flex items-center gap-1" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={e => e.stopPropagation()}>
|
||||
{mode === "image" ? <>
|
||||
<CanvasChooseImageStylePicker open={expanded ? expandedStyleToolOpen : styleToolOpen} onOpenChange={expanded ? setExpandedStyleToolOpen : setStyleToolOpen} activeToolId={activeStyleTool?.toolId} activeLabel={activeStyleTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} />
|
||||
<CanvasNineGridPicker open={expanded ? expandedNineGridOpen : nineGridOpen} onOpenChange={expanded ? setExpandedNineGridOpen : setNineGridOpen} icon={activeNineGridIcon} onSelect={(id,label,icon) => updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} />
|
||||
<CanvasChooseImageStylePicker open={expanded ? expandedStyleToolOpen : styleToolOpen} onOpenChange={expanded ? setExpandedStyleToolOpen : setStyleToolOpen} activeToolId={activeStyleTool?.toolId} activeLabel={activeStyleTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"style"))} />
|
||||
<CanvasNineGridPicker open={expanded ? expandedNineGridOpen : nineGridOpen} onOpenChange={expanded ? setExpandedNineGridOpen : setNineGridOpen} icon={activeNineGridIcon} onSelect={(id,label,icon) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"nine_grid"},icon))} />
|
||||
</> : <>
|
||||
<CanvasChooseEffectPicker open={expanded ? expandedEffectToolOpen : effectToolOpen} onOpenChange={expanded ? setExpandedEffectToolOpen : setEffectToolOpen} activeToolId={activeEffectTool?.toolId} activeLabel={activeEffectTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} />
|
||||
<CanvasChooseMotionPicker open={expanded ? expandedMotionToolOpen : motionToolOpen} onOpenChange={expanded ? setExpandedMotionToolOpen : setMotionToolOpen} activeToolIds={activeMotionTools.map(t => t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
|
||||
<CanvasChooseEffectPicker open={expanded ? expandedEffectToolOpen : effectToolOpen} onOpenChange={expanded ? setExpandedEffectToolOpen : setEffectToolOpen} activeToolId={activeEffectTool?.toolId} activeLabel={activeEffectTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"effect"))} />
|
||||
<CanvasChooseMotionPicker open={expanded ? expandedMotionToolOpen : motionToolOpen} onOpenChange={expanded ? setExpandedMotionToolOpen : setMotionToolOpen} activeToolIds={activeMotionTools.map(t => t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
|
||||
</>}
|
||||
</div> : null}
|
||||
{showPromptTemplates ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense appearance="quiet" /> : null}
|
||||
|
||||
@@ -52,11 +52,12 @@ const TOOL_REF_PATTERN = /@\[tool:(\w+):(\d+):([^:\]]+):([^\]]+)\]/g;
|
||||
/** 从文本中解析所有 @[tool:type:ID:label:icon] 令牌,返回去重的工具引用。 */
|
||||
export function parseToolMentionTokens(text: string): { type: string; toolId: number; label: string; icon: string }[] {
|
||||
const results: { type: string; toolId: number; label: string; icon: string }[] = [];
|
||||
const seen = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
for (const match of text.matchAll(TOOL_REF_PATTERN)) {
|
||||
const toolId = Number(match[2]);
|
||||
if (seen.has(toolId)) continue;
|
||||
seen.add(toolId);
|
||||
const identity = `${match[1]}:${toolId}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
let label = match[3];
|
||||
try { label = decodeURIComponent(label); } catch { /* Keep readable text for malformed imported labels. */ }
|
||||
results.push({ type: match[1], toolId, label, icon: match[4] });
|
||||
@@ -585,8 +586,8 @@ function skillResourceText(node: CanvasNodeData) {
|
||||
|
||||
export function buildToolMentionReference(toolId: number, label: string, type: string, icon: string): CanvasResourceReference {
|
||||
return {
|
||||
id: `tool:${toolId}`,
|
||||
nodeId: `tool:${toolId}`,
|
||||
id: `tool:${type}:${toolId}`,
|
||||
nodeId: `tool:${type}:${toolId}`,
|
||||
kind: "tool",
|
||||
label,
|
||||
title: label,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetWithRemoteSync, deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
||||
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
||||
@@ -487,9 +487,7 @@ export default function AssetsPage() {
|
||||
const count = trashAssets.length;
|
||||
if (!count) return;
|
||||
try {
|
||||
for (const asset of trashAssets) {
|
||||
await deleteAssetWithRemoteSync(asset.id);
|
||||
}
|
||||
await deleteAssetsWithRemoteSync(trashAssets.map((asset) => asset.id));
|
||||
setSelectedIds([]);
|
||||
message.success(`已彻底清空回收站 ${count} 个素材`);
|
||||
} catch (error) {
|
||||
@@ -516,7 +514,7 @@ export default function AssetsPage() {
|
||||
const confirmBatchDelete = async () => {
|
||||
if (!selectedAssets.length) return;
|
||||
try {
|
||||
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
|
||||
await deleteAssetsWithRemoteSync(selectedAssets.map((asset) => asset.id));
|
||||
message.success(`已彻底删除 ${selectedAssets.length} 个素材`);
|
||||
setSelectedIds([]);
|
||||
setBatchDeleteOpen(false);
|
||||
@@ -982,7 +980,7 @@ export default function AssetsPage() {
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
|
||||
确定彻底删除「{deletingAsset?.title}」吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。
|
||||
</Modal>
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
@@ -994,7 +992,7 @@ export default function AssetsPage() {
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
|
||||
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -359,11 +359,12 @@ export function useCanvasRenderModel({
|
||||
const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
const tokens = parseToolMentionTokens(text);
|
||||
if (!tokens.length) continue;
|
||||
const seen = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
const refs: ReturnType<typeof buildToolMentionReference>[] = [];
|
||||
for (const { type, toolId, label, icon } of tokens) {
|
||||
if (seen.has(toolId)) continue;
|
||||
seen.add(toolId);
|
||||
const identity = `${type}:${toolId}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
refs.push(buildToolMentionReference(toolId, label, type, icon));
|
||||
}
|
||||
if (refs.length) map.set(node.id, refs);
|
||||
|
||||
@@ -109,6 +109,10 @@ export function deleteRemoteAsset(id: string) {
|
||||
return http.delete<{ id: string }>(`/assets/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function deleteRemoteAssets(ids: string[]) {
|
||||
return http.post<{ ids: string[] }>("/assets/batch-delete", ids);
|
||||
}
|
||||
|
||||
export function listRemoteCanvasProjects() {
|
||||
return http.get<{ projects: RemoteUserDataSummary[] }>("/canvas-projects");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { deleteRemoteAssets, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -438,19 +438,34 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
|
||||
}
|
||||
|
||||
export async function deleteAssetWithRemoteSync(id: string) {
|
||||
return deleteAssetsWithRemoteSync([id]);
|
||||
}
|
||||
|
||||
export async function deleteAssetsWithRemoteSync(ids: string[]) {
|
||||
const epoch = sessionEpoch;
|
||||
const assetId = id.trim();
|
||||
if (!assetId) throw new Error("素材 ID 不能为空");
|
||||
if (!ids.length || ids.length > 1000) throw new Error("每次请选择 1–1000 个素材删除");
|
||||
const assetIds = [...new Set(ids.map((id) => id.trim()))];
|
||||
if (assetIds.some((id) => !id)) throw new Error("素材 ID 不能为空");
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的素材");
|
||||
if (activeRemoteUserId) {
|
||||
requireRemoteUserDataBaseline();
|
||||
await deleteRemoteAsset(assetId);
|
||||
acknowledgedAssets.delete(assetId);
|
||||
await deleteRemoteAssets(assetIds);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果");
|
||||
for (const id of assetIds) acknowledgedAssets.delete(id);
|
||||
}
|
||||
await useAssetStore.getState().removeAsset(assetId);
|
||||
await useAssetStore.getState().removeAssets(assetIds);
|
||||
await flushAssetStorePersistence();
|
||||
});
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果");
|
||||
// 列表查询也会进入同步队列,必须在退出删除队列后触发,不能在队列内等待刷新。
|
||||
// 刷新慢或失败不应阻塞确认弹窗关闭,也不能把已完成的删除报告为失败。
|
||||
void Promise.all([
|
||||
appQueryClient.invalidateQueries({ queryKey: ["asset-library"] }, { throwOnError: true }),
|
||||
appQueryClient.invalidateQueries({ queryKey: ["asset-picker"] }, { throwOnError: true }),
|
||||
]).catch((error) => {
|
||||
console.warn("素材删除后列表刷新失败", error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
|
||||
@@ -59,6 +59,7 @@ type AssetStore = {
|
||||
addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise<string>;
|
||||
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
|
||||
removeAsset: (id: string) => Promise<void>;
|
||||
removeAssets: (ids: string[]) => Promise<void>;
|
||||
replaceAssets: (assets: Asset[]) => void;
|
||||
cleanupImages: (extra?: unknown) => Promise<void>;
|
||||
};
|
||||
@@ -393,18 +394,23 @@ export const useAssetStore = create<AssetStore>()(
|
||||
set((state) => ({
|
||||
assets: state.assets.map((asset) => (asset.id === id ? parseAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() }) : asset)),
|
||||
})),
|
||||
removeAsset: async (id) => {
|
||||
removeAsset: async (id) => get().removeAssets([id]),
|
||||
removeAssets: async (ids) => {
|
||||
const removedIds = new Set(ids);
|
||||
let remainingAssets: Asset[] = [];
|
||||
let removedAsset: Asset | undefined;
|
||||
let hasLocalMedia = false;
|
||||
set((state) => {
|
||||
removedAsset = state.assets.find((asset) => asset.id === id);
|
||||
const assets = state.assets.filter((asset) => asset.id !== id);
|
||||
const assets = state.assets.filter((asset) => {
|
||||
if (!removedIds.has(asset.id)) return true;
|
||||
hasLocalMedia ||= !!collectImageStorageKeys(asset).size || !!collectMediaStorageKeys(asset).size;
|
||||
return false;
|
||||
});
|
||||
remainingAssets = assets;
|
||||
return { assets };
|
||||
});
|
||||
// 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描,
|
||||
// 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。
|
||||
if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) return;
|
||||
if (!hasLocalMedia) return;
|
||||
await get().cleanupImages({ assets: remainingAssets });
|
||||
},
|
||||
replaceAssets: (assets) => set({ assets: assets.map(parseAssetRecord) }),
|
||||
|
||||
@@ -14049,9 +14049,10 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-media { width: 100%; height: 100%; object-fit: contain; background: color-mix(in srgb, var(--foreground) 5%, transparent); }
|
||||
.assets-cover-fallback { display: grid; width: 100%; height: 100%; place-items: center; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
|
||||
.assets-cover-vignette { display: none; }
|
||||
.assets-cover-badges { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 5px; pointer-events: none; }
|
||||
.assets-cover-badges { position: absolute; top: 8px; left: 38px; right: 44px; z-index: 2; display: flex; align-items: center; gap: 5px; overflow: hidden; white-space: nowrap; pointer-events: none; }
|
||||
.assets-cover-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -14088,11 +14089,11 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-play svg { position: relative; width: 17px; height: 17px; margin-left: 2px; filter: drop-shadow(0 1px 4px rgba(0, 0, 0, .45)); }
|
||||
.assets-cover-link:hover .assets-cover-play, .assets-cover-link:focus-visible .assets-cover-play { opacity: 1; }
|
||||
|
||||
/* 选中态:accent 勾选框(右下)+ accent 描边由 is-selected 提供 */
|
||||
/* 选择入口固定在左上角,徽章预留其空间;选中描边由 is-selected 提供。 */
|
||||
.assets-select-check {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
z-index: 3;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -14167,17 +14168,19 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-model > svg { width: 34px; height: 34px; stroke-width: 1.1; }
|
||||
.assets-cover-model > span { max-width: 76%; overflow: hidden; font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 批量操作条:浮现动画,黑白表面 */
|
||||
/* 批量操作条随列表正常排版,留出选中描边和 hover 位移的空间。 */
|
||||
.assets-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 2px 0 0;
|
||||
margin: 2px 0 var(--space-4);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--elevation-hairline);
|
||||
animation: assets-batch-in 240ms var(--motion-ease-out) both;
|
||||
}
|
||||
@@ -14185,7 +14188,7 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
@media (prefers-reduced-motion: reduce) { .assets-batch-bar { animation: none; } }
|
||||
.assets-batch-count { display: inline-flex; align-items: baseline; gap: 6px; margin-right: auto; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-label); }
|
||||
.assets-batch-count strong { color: var(--foreground); font-size: var(--fs-body); font-weight: 620; font-variant-numeric: tabular-nums; }
|
||||
.assets-batch-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.assets-batch-actions { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
.assets-batch-actions .ant-btn { border: 0; border-radius: var(--r-md); background: transparent; color: color-mix(in srgb, var(--foreground) 56%, transparent); box-shadow: none; }
|
||||
.assets-batch-actions .ant-btn:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
|
||||
|
||||
@@ -14284,16 +14287,6 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
/* 1. 欢迎卡与 banner 同列宽,边缘对齐 */
|
||||
.assets-empty-cards { width: min(720px, calc(100% - 24px)); }
|
||||
|
||||
/* 2. 批量操作条:sticky + 毛玻璃,滚动长列表时始终可见 */
|
||||
.assets-batch-bar {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 20;
|
||||
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* 素材密度由页面传入卡片最小宽度,网格布局统一在 workspace-product.css。 */
|
||||
.assets-library-page .library-create-title { margin-top: 12px; font-size: var(--fs-body); }
|
||||
.assets-library-page .library-create-meta { margin-top: 4px; font-size: var(--fs-label); }
|
||||
|
||||
165
web/test/asset-batch-delete.test.ts
Normal file
165
web/test/asset-batch-delete.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { apiClient } from "@/services/api/request";
|
||||
import { deleteAssetsWithRemoteSync, initializeRemoteUserDataSession, loadAssetLibraryPage, resetRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
|
||||
const originalAdapter = apiClient.defaults.adapter;
|
||||
async function within<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000);
|
||||
})]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
const asset = (id: string): Asset => ({
|
||||
id, kind: "text", title: id, coverUrl: "", tags: [],
|
||||
createdAt: "2026-09-21T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z",
|
||||
data: { content: "测试素材" },
|
||||
} as Asset);
|
||||
|
||||
beforeEach(async () => {
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
await initializeRemoteUserDataSession("batch-test-user");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await appQueryClient.cancelQueries();
|
||||
appQueryClient.clear();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
});
|
||||
|
||||
describe("素材批量删除", () => {
|
||||
test("100 个素材只发一个 POST 数组请求,成功后只更新一次本地素材集合", async () => {
|
||||
const ids = Array.from({ length: 100 }, (_, i) => `asset-${i}`);
|
||||
useAssetStore.setState({ assets: [...ids.map(asset), asset("keep")] });
|
||||
const requests: unknown[] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push({ method: config.method, url: config.url, body: JSON.parse(config.data) });
|
||||
expect(useAssetStore.getState().assets).toHaveLength(101);
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } };
|
||||
};
|
||||
let updates = 0;
|
||||
const stop = useAssetStore.subscribe((state, previous) => { if (state.assets !== previous.assets) updates++; });
|
||||
try {
|
||||
await deleteAssetsWithRemoteSync(ids);
|
||||
expect(requests).toEqual([{ method: "post", url: "/assets/batch-delete", body: ids }]);
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
expect(updates).toBe(1);
|
||||
} finally { stop(); }
|
||||
});
|
||||
|
||||
test.each([false, true])("真实分页查询刷新不阻塞删除完成(刷新失败:%s)", async (failRefresh) => {
|
||||
const initialPage = {
|
||||
assets: [asset("first"), asset("keep")], total: 2, page: 1, pageSize: 20,
|
||||
kindCounts: {}, categoryCounts: {}, folderCounts: {}, hasMore: false,
|
||||
};
|
||||
useAssetStore.setState({ assets: initialPage.assets });
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshGate = new Promise<void>((resolve) => { releaseRefresh = resolve; });
|
||||
const requests: string[] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push(`${config.method} ${config.url}`);
|
||||
if (config.method === "post") {
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["first"] }, msg: "ok" } };
|
||||
}
|
||||
await refreshGate;
|
||||
if (failRefresh) throw new Error("列表暂时不可用");
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: {
|
||||
code: 0, data: { ...initialPage, assets: [asset("keep")], total: 1 }, msg: "ok",
|
||||
} };
|
||||
};
|
||||
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
||||
const observers = ["asset-library", "asset-picker"].map((key) => new QueryObserver(appQueryClient, {
|
||||
queryKey: [key, "delete-regression"],
|
||||
queryFn: () => loadAssetLibraryPage({ page: 1, pageSize: 20 }),
|
||||
initialData: initialPage,
|
||||
}));
|
||||
const stops: (() => void)[] = [];
|
||||
const refreshed = observers.map((observer) => new Promise<void>((resolve) => {
|
||||
stops.push(observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve();
|
||||
}));
|
||||
}));
|
||||
try {
|
||||
// GET 尚未返回,删除必须已返回,调用方才能关闭 Modal / Popconfirm。
|
||||
await within(deleteAssetsWithRemoteSync(["first"]));
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
expect(requests).toEqual(["post /assets/batch-delete", "get /assets", "get /assets"]);
|
||||
for (const observer of observers) expect(observer.getCurrentResult().fetchStatus).toBe("fetching");
|
||||
|
||||
releaseRefresh();
|
||||
// 真实 loadAssetLibraryPage 要再次进入同步队列;不能与删除互相等待。
|
||||
await within(Promise.all(refreshed));
|
||||
for (const observer of observers) {
|
||||
const result = observer.getCurrentResult();
|
||||
if (failRefresh) expect(result.error?.message).toBe("列表暂时不可用");
|
||||
else expect(result.data?.assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (failRefresh) expect(warning).toHaveBeenCalledWith("素材删除后列表刷新失败", expect.any(Error));
|
||||
expect(requests.filter((request) => request.startsWith("post"))).toHaveLength(1);
|
||||
} finally {
|
||||
releaseRefresh();
|
||||
await appQueryClient.cancelQueries();
|
||||
stops.forEach((stop) => stop());
|
||||
observers.forEach((observer) => observer.destroy());
|
||||
warning.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("业务失败不会先删本地素材或回退到逐条请求", async () => {
|
||||
useAssetStore.setState({ assets: [asset("first"), asset("second")] });
|
||||
let requests = 0;
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests++;
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 400, data: null, msg: "拒绝删除", reason: "invalid_argument" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["first", "second"])).rejects.toThrow("拒绝删除");
|
||||
expect(requests).toBe(1);
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
test("删除响应返回前切换账号,不删除新账号本地素材", async () => {
|
||||
useAssetStore.setState({ assets: [asset("same-id")] });
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [asset("same-id")] });
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["same-id"] }, msg: "ok" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["same-id"])).rejects.toThrow("账号已切换");
|
||||
expect(useAssetStore.getState().assets).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("空 ID 不发请求,重复 ID 合并到一个请求", async () => {
|
||||
const bodies: string[][] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
const ids = JSON.parse(config.data);
|
||||
bodies.push(ids);
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["good", " "])).rejects.toThrow("素材 ID 不能为空");
|
||||
expect(bodies).toHaveLength(0);
|
||||
await deleteAssetsWithRemoteSync([" first ", "first", "second"]);
|
||||
expect(bodies).toEqual([["first", "second"]]);
|
||||
});
|
||||
|
||||
test("素材页和选择器的批量及清空入口使用数组同步方法", () => {
|
||||
const page = readFileSync(new URL("../src/pages/assets/index.tsx", import.meta.url), "utf8");
|
||||
const picker = readFileSync(new URL("../src/components/assets/asset-library-picker-modal.tsx", import.meta.url), "utf8");
|
||||
expect(page).toContain("deleteAssetsWithRemoteSync(trashAssets.map");
|
||||
expect(page).toContain("deleteAssetsWithRemoteSync(selectedAssets.map");
|
||||
expect(picker).toContain("deleteAssetsWithRemoteSync(archivedSelectedIds)");
|
||||
expect(picker).toContain("deleteAssetsWithRemoteSync(toDelete.map");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,27 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("assets page batch toolbar", () => {
|
||||
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
test("keeps card selection in the top left with badge clearance", () => {
|
||||
const checkbox = styles.match(/\.assets-select-check\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
const badges = styles.match(/\.assets-cover-badges\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
expect(checkbox).toContain("left: 8px;");
|
||||
expect(checkbox).toContain("top: 8px;");
|
||||
expect(checkbox).not.toMatch(/(?:right|bottom):/);
|
||||
expect(badges).toContain("left: 38px;");
|
||||
expect(badges).toContain("right: 44px;");
|
||||
});
|
||||
|
||||
test("keeps batch actions in document flow with space above selected cards", () => {
|
||||
const toolbarRules = [...styles.matchAll(/\.assets-batch-bar\s*\{([^}]+)\}/g)].map((match) => match[1]).join("\n");
|
||||
expect(toolbarRules).not.toMatch(/position:\s*(?:sticky|fixed|absolute)/);
|
||||
expect(toolbarRules).not.toContain("z-index:");
|
||||
expect(toolbarRules).toContain("margin: 2px 0 var(--space-4);");
|
||||
const actions = styles.match(/\.assets-batch-actions\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
expect(actions).toContain("flex-wrap: wrap;");
|
||||
});
|
||||
|
||||
test("places select all before cancel selection", () => {
|
||||
const source = readFileSync(resolve(import.meta.dir, "../src/pages/assets/index.tsx"), "utf8");
|
||||
const selectAllIndex = source.search(/>\s*全选\s*<\/Button>/);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { applyToolMention, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { applyToolMention, buildToolMentionReference, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { createNineGridNode } from "../src/lib/canvas/canvas-image-source";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
import type { CanvasNodeData } from "../src/types/canvas";
|
||||
@@ -55,6 +55,17 @@ test("tool labels round trip; style replaces, motion deduplicates; clear preserv
|
||||
expect(parseToolMentionTokens(combined)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("tool references with the same numeric id remain distinct across types", () => {
|
||||
let prompt = applyToolMention("", { id: 7, type: "style", label: "水墨" }, "Palette");
|
||||
prompt = applyToolMention(prompt, { id: 7, type: "motion", label: "推镜" }, "Camera");
|
||||
expect(parseToolMentionTokens(prompt).map(({ type, toolId, label }) => ({ type, toolId, label }))).toEqual([
|
||||
{ type: "style", toolId: 7, label: "水墨" },
|
||||
{ type: "motion", toolId: 7, label: "推镜" },
|
||||
]);
|
||||
expect(buildToolMentionReference(7, "水墨", "style", "Palette").id)
|
||||
.not.toBe(buildToolMentionReference(7, "推镜", "motion", "Camera").id);
|
||||
});
|
||||
|
||||
test("nine grid creates idle child using stable source token, not an auto generation", () => {
|
||||
const child = createNineGridNode({ id: "source", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "/image.png" } }, "child", 79, "九宫格", "nine_grid", "Grid3x3");
|
||||
expect(child.metadata?.prompt).toContain("@[node:source]");
|
||||
|
||||
Reference in New Issue
Block a user