feat(trash): add bulk purge and automatic retention

This commit is contained in:
eezd committed 2026-10-25 11:56:28 +08:00
1 parent 2b8f18f0db
commit ac10de0466
16 files changed
+528 -26

No files matched your search

+1
View File
@@ -92,6 +92,7 @@ func (s *Server) routes() []route {
{"PUT /api/v1/assets/{path...}", authed, s.replaceImage},
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"DELETE /api/v1/trash", authed, s.trashClear},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events},
+66 -3
View File
@@ -349,14 +349,25 @@
"cookie": []
}
],
"description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。",
"description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。 trashRetentionDays 为非负安全整数(默认30,0为永不自动删除),null恢复默认。期限适用于已有和新回收站条目,服务启动及每小时按原删除时间永久清理。",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
"additionalProperties": true,
"properties": {
"trashRetentionDays": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 9007199254740991,
"default": 30
}
}
}
}
}
@@ -374,7 +385,7 @@
}
},
"400": {
"description": "不是 JSON 对象",
"description": "不是 JSON 对象,或自动删除期限无效",
"content": {
"application/json": {
"schema": {
@@ -1008,6 +1019,25 @@
}
}
}
},
"delete": {
"summary": "清空回收站全部条目",
"description": "永久删除,不可恢复;结果逐项报告,失败条目保留。只读令牌禁止。",
"tags": [
"回收站"
],
"responses": {
"200": {
"description": "包含成功及失败的逐项结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashPurgeResult"
}
}
}
}
}
}
},
"/trash/{id}/restore": {
@@ -2366,6 +2396,39 @@
"description": "Emoji 字符或内置线性图标名称,最多64字节"
}
}
},
"TrashPurgeResult": {
"type": "object",
"required": [
"deleted",
"failed"
],
"properties": {
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"failed": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"error"
],
"properties": {
"id": {
"type": "string"
},
"error": {
"type": "string"
}
}
}
}
}
}
}
}
+5 -1
View File
@@ -11,7 +11,7 @@ import (
"rawmark/internal/events"
)
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端只做合并与保存
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端合并与保存,并校验由服务端执行的清理期限
const settingsKey = "app.settings"
func (s *Server) readSettings() (map[string]json.RawMessage, error) {
@@ -60,6 +60,10 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
settings[k] = v
}
}
if _, err := trashRetentionDays(settings); err != nil {
writeError(w, 400, err.Error())
return
}
merged, err := json.Marshal(settings)
if err != nil {
internalError(w, r, err)
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。
const maxRetentionDays = 1<<53 - 1
func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
raw, ok := settings["trashRetentionDays"]
if !ok || bytes.Equal(raw, []byte("null")) {
return 30, nil
}
var days int64
if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays {
return 0, fmt.Errorf("回收站保留天数必须为非负安全整数,0 表示永不自动删除")
}
return days, nil
}
func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
result, err := s.vault.PurgeTrash(0, time.Now())
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) cleanupTrash(now time.Time) error {
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
settings, err := s.readSettings()
if err != nil {
return err
}
days, err := trashRetentionDays(settings)
if err != nil || days == 0 {
return err
}
result, err := s.vault.PurgeTrash(days, now)
if err != nil {
return err
}
if len(result.Failed) > 0 {
return fmt.Errorf("回收站清理部分失败: %v", result.Failed)
}
if len(result.Deleted) > 0 {
slog.Info("已清理过期回收站条目", "count", len(result.Deleted))
}
return nil
}
// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。
func (s *Server) RunTrashCleanup(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
if ctx.Err() != nil {
return
}
if err := s.cleanupTrash(time.Now()); err != nil {
slog.Error("自动清理回收站失败", "err", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"rawmark/internal/vault"
)
func TestTrashRetentionSettingsValidation(t *testing.T) {
f := newFixture(t)
for _, body := range []string{`{"trashRetentionDays":-1}`, `{"trashRetentionDays":0.5}`, `{"trashRetentionDays":"30"}`, `{"trashRetentionDays":true}`, `{"trashRetentionDays":9007199254740992}`} {
if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 {
t.Fatalf("应拒绝 %s: %d", body, code)
}
}
for _, value := range []string{"30", "0", "9007199254740991", "null"} {
if code := f.status(f.admin, "PATCH", "/api/v1/settings", `{"trashRetentionDays":`+value+`}`, nil); code != 200 {
t.Fatalf("应接受 %s: %d", value, code)
}
}
for _, raw := range []string{`{}`, `{"trashRetentionDays":null}`} {
settings := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
t.Fatal(err)
}
if days, err := trashRetentionDays(settings); err != nil || days != 30 {
t.Fatalf("缺省应30天: %d %v", days, err)
}
}
}
func TestTrashClearAuthorization(t *testing.T) {
f := newFixture(t)
f.write("a.md", "content")
decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil))
for _, scope := range []string{"read", "write"} {
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"test","scope":"`+scope+`"}`, nil)).Token
headers := map[string]string{"Authorization": "Bearer " + token}
if scope == "read" {
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
t.Fatalf("只读不能清空: %d", code)
}
} else {
result := decode[vault.TrashPurgeResult](t, f.do(&http.Client{}, "DELETE", "/api/v1/trash", "", headers))
if len(result.Deleted) != 1 || len(result.Failed) != 0 {
t.Fatalf("写令牌清空结果异常: %+v", result)
}
}
}
}
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {
f := newFixture(t)
v, err := vault.Open(f.root)
if err != nil {
t.Fatal(err)
}
f.write("old.md", "content")
item, err := v.Delete("old.md")
if err != nil {
t.Fatal(err)
}
metaPath := filepath.Join(f.root, ".rawmark/trash", item.ID, "meta.json")
item.DeletedAt = time.Now().Add(-40 * 24 * time.Hour).UnixMilli()
meta, _ := json.Marshal(item)
if err := os.WriteFile(metaPath, meta, 0600); err != nil {
t.Fatal(err)
}
s := New(v, nil, f.store, f.hub, nil)
for _, value := range []string{`{"trashRetentionDays":0}`, `{"trashRetentionDays":-1}`, `{"trashRetentionDays":"broken"}`} {
if err := f.store.SetSetting(settingsKey, value); err != nil {
t.Fatal(err)
}
err := s.cleanupTrash(time.Now())
if value != `{"trashRetentionDays":0}` && err == nil {
t.Fatal("无效配置不可静默降级为默认删除")
}
if _, err := os.Stat(metaPath); err != nil {
t.Fatalf("禁用或无效设置不应删除: %v", err)
}
}
if err := f.store.SetSetting(settingsKey, `{}`); err != nil {
t.Fatal(err)
}
if err := s.cleanupTrash(time.Now()); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
t.Fatalf("缺省30天应删除旧条目: %v", err)
}
}
+52
View File
@@ -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
}
+98
View File
@@ -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)
}
}
}
+9 -2
View File
@@ -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
}