feat(settings): show categorized storage usage on demand

This commit is contained in:
eezd committed 2026-10-25 11:56:38 +08:00
1 parent ac10de0466
commit 56145dba72
14 files changed
+440 -5

No files matched your search

+2
View File
@@ -38,6 +38,7 @@ type Server struct {
// 串行化设置的"读取-合并-写入"
settingsMu sync.Mutex
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
DataDir string
ThemesDir string
PluginsDir string
FontsDir string
@@ -93,6 +94,7 @@ func (s *Server) routes() []route {
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"DELETE /api/v1/trash", authed, s.trashClear},
{"GET /api/v1/storage", sessionOnly, s.getStorage},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events},
+1
View File
@@ -128,6 +128,7 @@ func newFixture(t *testing.T) *fixture {
t.Cleanup(func() { idx.Close() })
hub := events.NewHub()
server := New(v, am, st, hub, idx)
server.DataDir = dir
server.ThemesDir = filepath.Join(dir, "themes")
server.PluginsDir = filepath.Join(dir, "plugins")
server.FontsDir = filepath.Join(dir, "fonts")
+87
View File
@@ -2185,6 +2185,38 @@
}
}
}
},
"/storage": {
"get": {
"summary": "按需统计存储空间",
"description": "只接受登录会话。统计数据目录和实际笔记库的普通文件字节,嵌套去重,不跟随内部符号链接;附件/回收站是笔记库的子项,totalSize不重复累加。calculatedAt为统计完成时间(Unix毫秒)。扫描失败返回错误,不返回部分结果。",
"tags": [
"设置"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "统计结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StorageUsage"
}
}
}
},
"403": {
"description": "只接受登录会话"
},
"500": {
"description": "扫描失败"
}
}
}
}
},
"components": {
@@ -2429,6 +2461,61 @@
}
}
}
},
"StorageUsage": {
"type": "object",
"required": [
"totalSize",
"categories",
"assetsSize",
"trashSize",
"calculatedAt"
],
"properties": {
"totalSize": {
"type": "integer",
"minimum": 0
},
"assetsSize": {
"type": "integer",
"minimum": 0
},
"trashSize": {
"type": "integer",
"minimum": 0
},
"calculatedAt": {
"type": "integer",
"minimum": 0
},
"categories": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"size"
],
"properties": {
"id": {
"type": "string",
"enum": [
"vault",
"databases",
"fonts",
"themes",
"plugins",
"other"
]
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
}
}
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"path/filepath"
"strings"
"time"
)
type storageCategory struct {
ID string `json:"id"`
Size int64 `json:"size"`
}
type storageUsage struct {
TotalSize int64 `json:"totalSize"`
Categories []storageCategory `json:"categories"`
AssetsSize int64 `json:"assetsSize"`
TrashSize int64 `json:"trashSize"`
CalculatedAt int64 `json:"calculatedAt"`
}
func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
usage, err := scanStorage(r.Context(), s.DataDir, s.vault.Root())
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, http.StatusOK, usage)
}
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" {
return usage, fmt.Errorf("未配置应用数据目录")
}
dataRoot, err := filepath.EvalSymlinks(dataDir)
if err != nil {
return usage, err
}
dataRoot, err = filepath.Abs(dataRoot)
if err != nil {
return usage, err
}
vaultRoot, err := filepath.Abs(vaultDir)
if err != nil {
return usage, err
}
appCategory := func(rel string) int {
top := strings.Split(filepath.ToSlash(rel), "/")[0]
switch top {
case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm":
return 1
case "fonts":
return 2
case "themes":
return 3
case "plugins":
return 4
default:
return 5
}
}
walk := func(root, skip string, isVault bool) error {
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path != root && errors.Is(walkErr, fs.ErrNotExist) {
return nil // 文件可能在扫描期间被移动或删除。
}
return walkErr
}
if path != root && path == skip && entry.IsDir() {
return filepath.SkipDir
}
if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 {
return nil
}
info, err := entry.Info()
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
category := 0
if !isVault || dataRoot == vaultRoot {
category = appCategory(rel)
if isVault && category == 5 {
category = 0
}
}
size := info.Size()
usage.TotalSize += size
usage.Categories[category].Size += size
if category == 0 {
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) >= 3 && parts[0] == ".rawmark" && parts[1] == "trash" {
usage.TrashSize += size
} else {
for _, part := range parts[:len(parts)-1] {
if part == "assets" || strings.HasSuffix(part, ".assets") {
usage.AssetsSize += size
break
}
}
}
}
return nil
})
}
if dataRoot != vaultRoot {
if err := walk(dataRoot, vaultRoot, false); err != nil {
return storageUsage{}, err
}
}
if err := walk(vaultRoot, dataRoot, true); err != nil {
return storageUsage{}, err
}
usage.CalculatedAt = time.Now().UnixMilli()
return usage, nil
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestStorageCountsOwnedRootsOnce(t *testing.T) {
for _, layout := range []string{"nested-vault", "external-vault", "nested-data", "same-root"} {
t.Run(layout, func(t *testing.T) {
root := t.TempDir()
data, vault := filepath.Join(root, "data"), filepath.Join(root, "data", "vault")
switch layout {
case "external-vault":
vault = filepath.Join(root, "external")
case "nested-data":
vault, data = root, filepath.Join(root, "app")
case "same-root":
vault = data
}
write := func(base, rel string, size int) {
t.Helper()
path := filepath.Join(base, rel)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, make([]byte, size), 0600); err != nil {
t.Fatal(err)
}
}
write(vault, "note.md", 5)
write(vault, "dir/assets/a.png", 7)
write(vault, "old.assets/b.png", 11)
write(vault, ".rawmark/trash/id/meta.json", 13)
write(vault, ".rawmark/trash/id/assets/a.png", 17)
write(data, "rawmark.db", 19)
write(data, "index.db-wal", 23)
write(data, "fonts/a/file.woff2", 29)
write(data, "themes/a/theme.css", 31)
write(data, "plugins/a/main.js", 37)
write(data, "misc.bin", 41)
outside := t.TempDir()
write(outside, "outside.md", 1000)
if err := os.Symlink(outside, filepath.Join(vault, "link")); err != nil {
t.Fatal(err)
}
usage, err := scanStorage(context.Background(), data, vault)
if err != nil {
t.Fatal(err)
}
want := map[string]int64{"vault": 53, "databases": 42, "fonts": 29, "themes": 31, "plugins": 37, "other": 41}
if layout == "same-root" {
want["vault"] += 41
want["other"] = 0
}
for _, category := range usage.Categories {
if category.Size != want[category.ID] {
t.Errorf("%s = %d, want %d", category.ID, category.Size, want[category.ID])
}
}
if usage.TotalSize != 233 || usage.AssetsSize != 18 || usage.TrashSize != 30 || usage.CalculatedAt <= 0 {
t.Fatalf("总量或子项不符: %+v", usage)
}
})
}
}
func TestStorageDoesNotReturnPartialSuccess(t *testing.T) {
root := t.TempDir()
if _, err := scanStorage(context.Background(), root, filepath.Join(root, "missing")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("仓库缺失应报错: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := scanStorage(ctx, root, root); !errors.Is(err, context.Canceled) {
t.Fatalf("请求取消应停止扫描: %v", err)
}
}
+11 -1
View File
@@ -34,13 +34,16 @@ func TestTrashRetentionSettingsValidation(t *testing.T) {
}
}
func TestTrashClearAuthorization(t *testing.T) {
func TestStorageAndTrashClearAuthorization(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 code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", headers); code != 403 {
t.Fatalf("存储统计仅接受会话: %d", code)
}
if scope == "read" {
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
t.Fatalf("只读不能清空: %d", code)
@@ -52,6 +55,13 @@ func TestTrashClearAuthorization(t *testing.T) {
}
}
}
usage := decode[storageUsage](t, f.do(f.admin, "GET", "/api/v1/storage", "", nil))
if usage.TotalSize <= 0 || len(usage.Categories) != 6 {
t.Fatalf("会话统计: %+v", usage)
}
if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", nil); code != 401 {
t.Fatalf("匿名不能查看: %d", code)
}
}
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {