diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 13453e931..1fb7ab3c0 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1072,6 +1072,18 @@ func (r *Repository) DeleteAsset(userID string, id string) error { return r.DeleteAssetAndResources(userID, id, nil, nil) } +func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) { + var assets []model.Asset + if limit <= 0 { + limit = 100 + } + err := r.db.Where("status = ? AND updated_at <= ?", model.AssetVersionStatusArchived, cutoff). + Order("updated_at asc, id asc"). + Limit(limit). + Find(&assets).Error + return assets, err +} + func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Delete(&model.Asset{}, "user_id = ?", userID).Error; err != nil { diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go index 8e2b15538..502e0f8b8 100644 --- a/backend/internal/service/resource_delete_test.go +++ b/backend/internal/service/resource_delete_test.go @@ -257,6 +257,61 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { } } +func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbox(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + old := time.Now().Add(-45 * 24 * time.Hour) + resource := model.Resource{ + ID: "resource-expired-archive", UserID: "user-1", Provider: "unsupported-test-provider", + ObjectKey: "users/user-1/image/expired.png", Status: model.ResourceStatusReady, + } + asset := model.Asset{ + ID: "asset-expired-archive", UserID: "user-1", Title: "过期回收站素材", + Status: model.AssetVersionStatusArchived, PayloadJSON: `{"data":{"storageKey":"resource:resource-expired-archive"}}`, + CreatedAt: old, UpdatedAt: old, + } + canvas := model.CanvasProject{ + ID: "canvas-expired-archive", UserID: "user-1", Title: "仍引用回收站素材的画布", + PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-expired-archive"}}]}`, + } + for _, item := range []any{&resource, &asset, &canvas} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + svc.cleanupExpiredArchivedAssets() + var assetCount, resourceCount, jobCount int64 + 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 != 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) + } +} + func newResourceDeletionTestService(t *testing.T) (*Service, *gorm.DB, string) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go index 1266b12b5..72d9037a5 100644 --- a/backend/internal/service/resource_deletion_worker.go +++ b/backend/internal/service/resource_deletion_worker.go @@ -15,24 +15,56 @@ func (s *Service) startResourceDeletionWorker(ctx context.Context) { s.runWorkerLoop(func(ctx context.Context) { s.drainResourceDeletionJobs(32) s.cleanupStaleAnnouncementImageDrafts() + s.cleanupExpiredArchivedAssets() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() - lastDraftCleanup := time.Now() + lastPeriodicCleanup := time.Now() for { select { case <-ctx.Done(): return case <-ticker.C: s.drainResourceDeletionJobs(32) - if time.Since(lastDraftCleanup) >= time.Hour { + if time.Since(lastPeriodicCleanup) >= time.Hour { s.cleanupStaleAnnouncementImageDrafts() - lastDraftCleanup = time.Now() + s.cleanupExpiredArchivedAssets() + lastPeriodicCleanup = time.Now() } } } }) } +func (s *Service) cleanupExpiredArchivedAssets() { + policy, err := s.RuntimePolicy() + if err != nil { + return + } + retentionDays := policy.Resource.RecycleBinRetentionDays + if retentionDays <= 0 { + return + } + cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour) + expired, err := s.repo.FindExpiredArchivedAssets(cutoff, 100) + if err != nil { + log.Printf("expired archived assets query failed: %v", err) + return + } + deleted := 0 + for _, asset := range expired { + // 自动清理与用户手动删除走同一条强校验路径:先检查业务引用, + // 再以事务 + Outbox 删除资源记录和物理对象,不能从 repository 旁路。 + if err := s.DeleteUserAsset(asset.UserID, asset.ID); err != nil { + log.Printf("expired archived asset delete failed for %s: %v", asset.ID, err) + continue + } + deleted++ + } + if deleted > 0 { + log.Printf("recycle bin cleanup: deleted %d expired assets (retention: %d days)", deleted, retentionDays) + } +} + func (s *Service) drainResourceDeletionJobs(limit int) { owner := s.workerID if owner == "" { diff --git a/backend/internal/service/runtime_policy.go b/backend/internal/service/runtime_policy.go index 701978f31..40d312da6 100644 --- a/backend/internal/service/runtime_policy.go +++ b/backend/internal/service/runtime_policy.go @@ -25,18 +25,19 @@ const ( ) type RuntimeResourcePolicy struct { - ResourceUploadMB int64 `json:"resourceUploadMB"` - SessionUploadMB int64 `json:"sessionUploadMB"` - GeneratedFileMB int64 `json:"generatedFileMB"` - DailyUploadMB int64 `json:"dailyUploadMB"` - StoredFileGB int64 `json:"storedFileGB"` - StructuredDataMB int64 `json:"structuredDataMB"` - TaskDataGB int64 `json:"taskDataGB"` - AssetCount int64 `json:"assetCount"` - CanvasCount int64 `json:"canvasCount"` - SessionCount int64 `json:"sessionCount"` - TaskCount int64 `json:"taskCount"` - APICallLogCount int64 `json:"apiCallLogCount"` + ResourceUploadMB int64 `json:"resourceUploadMB"` + SessionUploadMB int64 `json:"sessionUploadMB"` + GeneratedFileMB int64 `json:"generatedFileMB"` + DailyUploadMB int64 `json:"dailyUploadMB"` + StoredFileGB int64 `json:"storedFileGB"` + StructuredDataMB int64 `json:"structuredDataMB"` + TaskDataGB int64 `json:"taskDataGB"` + AssetCount int64 `json:"assetCount"` + CanvasCount int64 `json:"canvasCount"` + SessionCount int64 `json:"sessionCount"` + TaskCount int64 `json:"taskCount"` + APICallLogCount int64 `json:"apiCallLogCount"` + RecycleBinRetentionDays int `json:"recycleBinRetentionDays"` } type RuntimeTaskPolicy struct { @@ -90,26 +91,28 @@ type PublicRuntimePolicySetting struct { } type PublicRuntimeLimits struct { - ActiveTaskLimit int `json:"activeTaskLimit"` - ResourceUploadMB int64 `json:"resourceUploadMB"` - SessionUploadMB int64 `json:"sessionUploadMB"` + ActiveTaskLimit int `json:"activeTaskLimit"` + ResourceUploadMB int64 `json:"resourceUploadMB"` + SessionUploadMB int64 `json:"sessionUploadMB"` + RecycleBinRetentionDays int `json:"recycleBinRetentionDays"` } func defaultRuntimePolicy() RuntimePolicySetting { return RuntimePolicySetting{ Resource: RuntimeResourcePolicy{ - ResourceUploadMB: 50, - SessionUploadMB: 32, - GeneratedFileMB: 64, - DailyUploadMB: 200, - StoredFileGB: 2, - StructuredDataMB: 256, - TaskDataGB: 1, - AssetCount: 2_000, - CanvasCount: 1_000, - SessionCount: 1_000, - TaskCount: 20_000, - APICallLogCount: 100_000, + ResourceUploadMB: 50, + SessionUploadMB: 32, + GeneratedFileMB: 64, + DailyUploadMB: 200, + StoredFileGB: 2, + StructuredDataMB: 256, + TaskDataGB: 1, + AssetCount: 2_000, + CanvasCount: 1_000, + SessionCount: 1_000, + TaskCount: 20_000, + APICallLogCount: 100_000, + RecycleBinRetentionDays: 30, }, Task: RuntimeTaskPolicy{ WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)), @@ -155,6 +158,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting { DailyUploadMB: maxRuntimeDataMB, StoredFileGB: maxRuntimeStorageGB, StructuredDataMB: maxRuntimeDataMB, TaskDataGB: maxRuntimeStorageGB, AssetCount: maxRuntimeCount, CanvasCount: maxRuntimeCount, SessionCount: maxRuntimeCount, TaskCount: maxRuntimeCount, APICallLogCount: maxRuntimeCount, + RecycleBinRetentionDays: 0, } value.Task = RuntimeTaskPolicy{ WorkerConcurrency: maxRuntimeConcurrency, ChannelConcurrency: maxRuntimeConcurrency, ActiveTaskLimit: maxRuntimeConcurrency, @@ -194,7 +198,8 @@ func (s *Service) PublicRuntimeLimits() (*PublicRuntimeLimits, error) { } return &PublicRuntimeLimits{ ActiveTaskLimit: policy.Task.ActiveTaskLimit, ResourceUploadMB: policy.Resource.ResourceUploadMB, - SessionUploadMB: policy.Resource.SessionUploadMB, + SessionUploadMB: policy.Resource.SessionUploadMB, + RecycleBinRetentionDays: policy.Resource.RecycleBinRetentionDays, }, nil } @@ -271,7 +276,7 @@ func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySettin if err != nil { return nil, RuntimePolicySetting{}, err } - value := RuntimePolicySetting{} + value := defaultRuntimePolicy() if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效") } @@ -312,6 +317,9 @@ func validateRuntimePolicy(value RuntimePolicySetting) error { return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeCount)) } } + if resource.RecycleBinRetentionDays < 0 || resource.RecycleBinRetentionDays > 365 { + return BadAuthRequest("回收站保留天数必须是 0-365 的整数 (0 表示不自动清理)") + } task := value.Task for label, item := range map[string]int{ "Worker 并发数": task.WorkerConcurrency, "全局渠道并发数": task.ChannelConcurrency, "活动任务上限": task.ActiveTaskLimit, diff --git a/backend/internal/service/runtime_policy_test.go b/backend/internal/service/runtime_policy_test.go index 8a85de63a..d0c8141a1 100644 --- a/backend/internal/service/runtime_policy_test.go +++ b/backend/internal/service/runtime_policy_test.go @@ -136,3 +136,39 @@ func TestRuntimePolicySaveAndResetTakeEffectImmediately(t *testing.T) { t.Fatalf("reset active task limit = %d, error = %v", effective.Task.ActiveTaskLimit, err) } } + +func TestRuntimePolicyBackfillsRecycleBinRetentionForLegacyJSON(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.SystemSetting{}); err != nil { + t.Fatal(err) + } + legacy := defaultRuntimePolicy() + encoded, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err := json.Unmarshal(encoded, &value); err != nil { + t.Fatal(err) + } + resource := value["resource"].(map[string]any) + delete(resource, "recycleBinRetentionDays") + encoded, err = json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: runtimePolicySettingKey, ValueJSON: string(encoded), UpdatedBy: "admin"}).Error; err != nil { + t.Fatal(err) + } + + policy, err := New(repository.New(db), t.TempDir()).RuntimePolicy() + if err != nil { + t.Fatal(err) + } + if policy.Resource.RecycleBinRetentionDays != 30 { + t.Fatalf("legacy recycle bin retention = %d, want 30", policy.Resource.RecycleBinRetentionDays) + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 6f92e552e..e0a3c4223 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -133,3 +133,5 @@ description: 影策后端核心持久化表与状态边界 | `resource_deletion_jobs` | 在业务记录删除后可靠清理物理对象 | 固化资源的存储位置与对象键;删除失败保留任务并重试,S3 404 按幂等成功处理 | `storage_locations.value_json` 中的 Secret Key 和 Session Token 使用后端设置密钥加密。启用 S3 前,关键位置字段和凭据摘要必须与最近一次 Put、Range Get、Delete 连接测试结果一致;历史资源继续按绑定的位置读取和删除,不随当前配置切换。 + +素材状态为 `archived` 时进入回收站。后台按“资源与请求策略”中的 `recycleBinRetentionDays` 定期扫描过期项;`0` 表示永久保留。自动清理与用户手动彻底删除复用同一 service 引用校验和 `resource_deletion_jobs` Outbox,仍被画布、项目、任务或其他业务记录引用的素材不会被删除。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 26b673b09..08fcff945 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布导入同步、素材回收站与历史 + +- [x] 画布压缩包导入会先校验媒体完整性,并统一通过远端同步入口上传资源;失败时保持脏状态和自动重试,不再把浏览器本地 storage key 误报为已同步。 +- [x] 素材回收站使用独立归档状态;彻底删除复用服务端引用校验与资源删除 Outbox,自动过期清理不会旁路业务引用。 +- [x] 已删除画布历史改为按用户 scope 存入 IndexedDB;删除失败不记录历史,共享素材不会因删除单个画布被错误归档。 +- [ ] 使用两个账号分别删除画布并切换登录,确认历史记录和回收站素材不串账号;刷新后记录仍在,清理历史只影响当前账号。 +- [ ] 删除仍被其他画布或项目引用的回收站素材,确认服务端拒绝并显示引用来源;解除引用后彻底删除,确认资源记录进入 Outbox 并最终清理物理对象。 +- [ ] 导入包含图片、视频、音频的真实画布压缩包,分别模拟上传中断和服务端保存失败,确认进度遮罩最终关闭、页面可离开且恢复网络后自动重试。 + ## 邮箱找回密码 - [ ] 后端专项测试验证验证码用途隔离、错误和过期验证码拒绝、单次消费、bcrypt 密码更新,以及重置事务撤销全部旧登录会话。 diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 727f6775a..73bfbc1a0 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -1,6 +1,6 @@ -import { Button, Dropdown, Modal } from "antd"; +import { App, Button, Dropdown, Modal, Popconfirm } from "antd"; import type { MenuProps } from "antd"; -import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, Search, Upload, UserRound, Video } from "lucide-react"; +import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AssetMediaPreview } from "@/components/asset-media-preview"; @@ -9,12 +9,14 @@ import { CachedResourceImage } from "@/components/cached-resource-image"; import { PaginationBar } from "@/components/layout/workspace-page"; import { cn } from "@/lib/utils"; import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; -import type { Asset } from "@/stores/use-asset-store"; +import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; export type AssetLibraryPickerItem = { id: string; title: string; category: string; + archived?: boolean; kindLabel: string; asset?: Asset; imageUrl?: string; @@ -91,6 +93,7 @@ export function AssetLibraryPickerModal({ onConfirm, onFolderAction, }: Props) { + const { message } = App.useApp(); const [category, setCategory] = useState(initialCategory); const [folderId, setFolderId] = useState(initialFolderId); const [source, setSource] = useState<"local" | "plugin">("local"); @@ -112,26 +115,37 @@ export function AssetLibraryPickerModal({ itemsRef.current = allItems; const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]); const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]); - const hasPluginSource = useMemo( - () => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), - [categoryLabels, pluginItems], - ); + const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]); const sourceItems = source === "plugin" ? pluginItems : localItems; + const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]); + const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]); const sourceFolders = source === "plugin" ? folders : []; const showCategories = source === "local" || !sourceFolders.length; - const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]); + const normalCategories = useMemo(() => ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems]); + const archivedCount = archivedItems.length; + const isRecycleBin = category === "archived"; + const visibleItems = useMemo(() => { const query = keyword.trim().toLowerCase(); return sourceItems.filter((item) => { - if (category !== "all" && item.category !== category) return false; + if (category === "archived") { + if (!item.archived) return false; + } else if (item.archived || (category !== "all" && item.category !== category)) { + return false; + } if (folderId !== "all" && (item.folderId || "") !== folderId) return false; return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); }); }, [category, folderId, keyword, sourceItems]); - const selectedIds = useMemo(() => Array.from(selected).filter((id) => { - const item = allItems.find((entry) => entry.id === id); - return !item?.disabledReason; - }), [allItems, selected]); + const selectedIds = useMemo( + () => + Array.from(selected).filter((id) => { + const item = allItems.find((entry) => entry.id === id); + return !item?.disabledReason; + }), + [allItems, selected], + ); + const archivedSelectedIds = useMemo(() => selectedIds.filter((id) => allItems.find((item) => item.id === id)?.archived), [allItems, selectedIds]); useEffect(() => { if (!open) return; @@ -149,9 +163,9 @@ export function AssetLibraryPickerModal({ }, [initialCategory, initialFolderId, open]); useEffect(() => { - if (category === "all" || categories.includes(category)) return; + if (category === "all" || category === "archived" || normalCategories.includes(category)) return; setCategory("all"); - }, [categories, category]); + }, [normalCategories, category]); useEffect(() => { if (hasPluginSource || source === "local") return; @@ -191,6 +205,55 @@ export function AssetLibraryPickerModal({ } }; + const handleRestoreSelected = async () => { + if (!archivedSelectedIds.length) return; + setWorking(true); + try { + for (const id of archivedSelectedIds) { + useAssetStore.getState().updateAsset(id, { status: "confirmed" }); + } + await flushAssetStorePersistence(); + await saveRemoteUserDataNow(); + setSelected(new Set()); + message.success(`已还原 ${archivedSelectedIds.length} 个素材至素材库`); + setCategory("all"); + } catch { + message.warning("已在本地还原,稍后自动同步至云端"); + } finally { + setWorking(false); + } + }; + + const handleDeleteSelected = async () => { + if (!archivedSelectedIds.length) return; + setWorking(true); + try { + for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id); + setSelected(new Set()); + message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`); + } catch (err) { + message.error(err instanceof Error ? err.message : "删除失败"); + } finally { + setWorking(false); + } + }; + + const handleEmptyRecycleBin = async () => { + const toDelete = archivedItems; + if (!toDelete.length) return; + setWorking(true); + try { + for (const item of toDelete) await deleteAssetWithRemoteSync(item.id); + setSelected(new Set()); + message.success(`已清空回收站 ${toDelete.length} 个素材`); + setCategory("all"); + } catch (err) { + message.error(err instanceof Error ? err.message : "清空回收站失败"); + } finally { + setWorking(false); + } + }; + const handleUpload = async (files: FileList | null) => { if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return; setWorking(true); @@ -228,13 +291,35 @@ export function AssetLibraryPickerModal({ } }; - const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length; + const countFor = (value: string) => (value === "all" ? activeSourceItems.length : activeSourceItems.filter((item) => item.category === value).length); const sourceLabel = source === "plugin" ? "插件来源" : "本地素材"; const sourceMenuItems: MenuProps["items"] = [ - { key: "local", icon: