feat(trash): add bulk purge and automatic retention
This commit is contained in:
1 parent
2b8f18f0db
commit
ac10de0466
16 files changed
+528
-26
No files matched your search
@@ -0,0 +1,52 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrashFailure struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type TrashPurgeResult struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Failed []TrashFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有;
|
||||
// 自动清理的“永不删除”由调用方处理,不调用本方法。
|
||||
func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}}
|
||||
if days < 0 {
|
||||
return result, fmt.Errorf("保留天数不能为负数")
|
||||
}
|
||||
dirs, err := os.ReadDir(v.trash)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
id := dir.Name()
|
||||
if !trashIDPattern.MatchString(id) {
|
||||
continue // 不删除非本程序管理的文件。
|
||||
}
|
||||
item, err := v.readTrashItem(id)
|
||||
if err == nil && days > 0 && (item.DeletedAt > now.UnixMilli() || (now.UnixMilli()-item.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
err = os.RemoveAll(filepath.Join(v.trash, id))
|
||||
}
|
||||
if err != nil {
|
||||
result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()})
|
||||
} else {
|
||||
result.Deleted = append(result.Deleted, id)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem {
|
||||
t.Helper()
|
||||
if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := v.Delete(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := v.readTrashMetadata(item.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.DeletedAt = when.UnixMilli()
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.trash, item.ID, "meta.json"), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return meta.TrashItem
|
||||
}
|
||||
|
||||
func TestTrashRetentionAndInvalidMetadata(t *testing.T) {
|
||||
v := newVault(t)
|
||||
now := time.Now()
|
||||
old := trashAt(t, v, "old.md", now.Add(-31*24*time.Hour))
|
||||
boundary := trashAt(t, v, "boundary.md", now.Add(-30*24*time.Hour))
|
||||
fresh := trashAt(t, v, "fresh.md", now.Add(-29*24*time.Hour))
|
||||
future := trashAt(t, v, "future.md", now.Add(time.Hour))
|
||||
broken := trashAt(t, v, "broken.md", now.Add(-40*24*time.Hour))
|
||||
if err := os.WriteFile(filepath.Join(v.trash, broken.ID, "meta.json"), []byte(`{"deletedAt":0}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := v.PurgeTrash(30, now)
|
||||
if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 || result.Failed[0].ID != broken.ID {
|
||||
t.Fatalf("结果异常: %+v, %v", result, err)
|
||||
}
|
||||
for _, item := range []TrashItem{old, boundary} {
|
||||
if _, err := os.Stat(filepath.Join(v.trash, item.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("过期条目未删除: %v", err)
|
||||
}
|
||||
}
|
||||
for _, item := range []TrashItem{fresh, future, broken} {
|
||||
if _, err := os.Stat(filepath.Join(v.trash, item.ID)); err != nil {
|
||||
t.Fatalf("不应删除未过期/无效条目: %v", err)
|
||||
}
|
||||
}
|
||||
// 极大天数不应溢出成过期期限。
|
||||
result, err = v.PurgeTrash(1<<53-1, now)
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 {
|
||||
t.Fatalf("极大期限: %+v, %v", result, err)
|
||||
}
|
||||
result, err = v.PurgeTrash(0, now)
|
||||
if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 {
|
||||
t.Fatalf("清空全部应独立报告损坏条目: %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashRestoreAndPurgeSerialize(t *testing.T) {
|
||||
for range 20 {
|
||||
v := newVault(t)
|
||||
item := trashAt(t, v, "note.md", time.Now().Add(-40*24*time.Hour))
|
||||
var restoreErr, purgeErr error
|
||||
var result TrashPurgeResult
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); _, restoreErr = v.Restore(item.ID) }()
|
||||
go func() { defer wg.Done(); result, purgeErr = v.PurgeTrash(30, time.Now()) }()
|
||||
wg.Wait()
|
||||
if purgeErr != nil || len(result.Failed) != 0 {
|
||||
t.Fatalf("并发清理失败: %+v %v", result, purgeErr)
|
||||
}
|
||||
if restoreErr == nil {
|
||||
if len(result.Deleted) != 0 {
|
||||
t.Fatal("已恢复的内容不可再次清理")
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(v.Root(), item.Path)); err != nil || string(body) != "content" {
|
||||
t.Fatalf("恢复内容丢失: %v", err)
|
||||
}
|
||||
} else if !errors.Is(restoreErr, ErrNotFound) || len(result.Deleted) != 1 {
|
||||
t.Fatalf("清理先完成时应明确不存在: %v %+v", restoreErr, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,6 +637,9 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
|
||||
}
|
||||
if _, err := CleanPath(metadata.Path); err != nil || metadata.ID != id || metadata.DeletedAt <= 0 || (metadata.Assets != "" && metadata.Assets != filepath.Base(AssetsDir(metadata.Path))) {
|
||||
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据无效", id)
|
||||
}
|
||||
for p, icon := range metadata.Icons {
|
||||
if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) {
|
||||
return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id)
|
||||
@@ -646,6 +649,8 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
|
||||
}
|
||||
|
||||
func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
dirs, err := os.ReadDir(v.trash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -666,6 +671,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
}
|
||||
|
||||
func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
metadata, err := v.readTrashMetadata(id)
|
||||
item = metadata.TrashItem
|
||||
if err != nil {
|
||||
@@ -675,8 +682,6 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
icons, err := v.readTreeIcons()
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
@@ -741,6 +746,8 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||
}
|
||||
|
||||
func (v *Vault) Purge(id string) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if _, err := v.readTrashItem(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user