From bdd85fe481f4c0fbd472ba64707c58f3b2ef45b3 Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Thu, 24 Sep 2026 12:40:29 +0800 Subject: [PATCH 1/3] feat(cache): add file-tree and download-link caches (DB by default) Implement the two-level cache from the OpenList.ts reference: - File-tree cache (`ft`): directory listings are served from cache, keyed by storage + virtual path. Empty directories are cached too, so repeated browsing no longer hits the remote drive every time. - Download-link cache (`ln`): the raw_url returned by the driver is reused instead of being re-signed/re-exchanged on every download, which is usually the most expensive and most rate-limited step. Caching is DB-only by default. `CACHE_DRIVER=db` (the default) reuses the backend resolved by `getStorageBackend()`, i.e. the same one as `DB_DRIVER`, so zero configuration means "cache in the database". Dedicated KV / Blob / cfkv / do / memory backends must be opted into explicitly via `CACHE_DRIVER` (e.g. `db,kv`, `kv`, `blob`) and are never auto-detected or auto-substituted, matching the existing DB_DRIVER rule that an explicitly configured driver never falls back. Safety boundary: only driver-layer results are cached (raw FileItem lists and raw links). Permissions, meta passwords, hide rules and signatures are still computed per request in server/fs.ts and server/raw.ts, so a cache hit cannot leak privileges. Any cache error degrades to a no-op and never breaks the request. Invalidation: writes (mkdir/rename/remove/move/copy/put) invalidate the affected path plus its parent directory for the file tree, and the path itself for links. Storage create/update/enable/disable/delete clears that storage's cache entirely. New environment variables (all optional): CACHE_ENABLED, CACHE_DRIVER, CACHE_FILE_TREE, CACHE_DOWNLOAD_LINK, CACHE_TTL, CACHE_LINK_TTL, CACHE_EXCLUDE_DRIVERS, CACHE_PREFIX. New admin endpoints: GET /cache/status, POST /cache/clear, POST /storage/refresh, POST /storage/refresh_one. --- .dev.vars.example | 22 ++ .env.example | 22 ++ README.md | 68 ++++ docs/pr-cache.md | 130 ++++++++ package.json | 27 +- src/backend/internal/cache/cache.test.ts | 381 ++++++++++++++++++++++ src/backend/internal/cache/config.ts | 242 ++++++++++++++ src/backend/internal/cache/filetree.ts | 176 ++++++++++ src/backend/internal/cache/index.ts | 125 +++++++ src/backend/internal/cache/link.ts | 138 ++++++++ src/backend/internal/cache/store.ts | 396 +++++++++++++++++++++++ src/backend/internal/op/storage.ts | 101 ++++-- src/backend/server/admin.ts | 127 ++++++++ src/backend/server/public.ts | 25 ++ src/backend/server/raw.ts | 49 ++- wrangler.jsonc | 37 +++ 16 files changed, 2028 insertions(+), 38 deletions(-) create mode 100644 docs/pr-cache.md create mode 100644 src/backend/internal/cache/cache.test.ts create mode 100644 src/backend/internal/cache/config.ts create mode 100644 src/backend/internal/cache/filetree.ts create mode 100644 src/backend/internal/cache/index.ts create mode 100644 src/backend/internal/cache/link.ts create mode 100644 src/backend/internal/cache/store.ts diff --git a/.dev.vars.example b/.dev.vars.example index 276f2cbe..d1faedac 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -48,3 +48,25 @@ CF_ACCOUNT= CF_KV_UUID= # 具有 KV 读写权限的 API Token CF_API_KEY= + +# ── 缓存(文件树缓存 / 下载链接缓存)──────────────────────────────────────── +# 默认【只向数据库启用】:缓存与业务数据同后端(DB_DRIVER 解析结果)。 +# 想要改用 / 额外启用 KV、Blob 等专用后端,必须显式设置 CACHE_DRIVER +# (不会自动探测、不会自动回退)。详见 README「缓存配置」。 +# +# 总开关,默认 true +# CACHE_ENABLED=true +# 缓存后端,默认 db;可选 db / kv / blob / cfkv / do / memory / none, +# 支持逗号分隔多后端(读按顺序命中、写全部铺开),如 db,kv +# CACHE_DRIVER=db +# 分级开关,默认 true +# CACHE_FILE_TREE=true +# CACHE_DOWNLOAD_LINK=true +# 文件树缓存时长(分钟),0 = 跟随存储级 cache_expiration(默认 30) +# CACHE_TTL=0 +# 下载链接缓存时长(分钟),直链自带有效期,取值需保守(默认 5) +# CACHE_LINK_TTL=5 +# 不参与缓存的驱动(纯本地计算型) +# CACHE_EXCLUDE_DRIVERS=virtual,alias,url_tree,strm,chunk +# 缓存键前缀(与业务数据键隔离) +# CACHE_PREFIX=openlist_cache diff --git a/.env.example b/.env.example index 54ab7470..e00cf2c7 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,25 @@ CF_ACCOUNT= CF_KV_UUID= # 具有 KV 读写权限的 API Token CF_API_KEY= + +# ── 缓存(文件树缓存 / 下载链接缓存)──────────────────────────────────────── +# 默认【只向数据库启用】:缓存与业务数据同后端(DB_DRIVER 解析结果)。 +# 想要改用 / 额外启用 KV、Blob 等专用后端,必须显式设置 CACHE_DRIVER +# (不会自动探测、不会自动回退)。详见 README「缓存配置」。 +# +# 总开关,默认 true +# CACHE_ENABLED=true +# 缓存后端,默认 db;可选 db / kv / blob / cfkv / do / memory / none, +# 支持逗号分隔多后端(读按顺序命中、写全部铺开),如 db,kv +# CACHE_DRIVER=db +# 分级开关,默认 true +# CACHE_FILE_TREE=true +# CACHE_DOWNLOAD_LINK=true +# 文件树缓存时长(分钟),0 = 跟随存储级 cache_expiration(默认 30) +# CACHE_TTL=0 +# 下载链接缓存时长(分钟),直链自带有效期,取值需保守(默认 5) +# CACHE_LINK_TTL=5 +# 不参与缓存的驱动(纯本地计算型) +# CACHE_EXCLUDE_DRIVERS=virtual,alias,url_tree,strm,chunk +# 缓存键前缀(与业务数据键隔离) +# CACHE_PREFIX=openlist_cache diff --git a/README.md b/README.md index 83fdca45..606e829e 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,74 @@ CF_API_KEY=your_api_token 前缀固定为 `x_`(与 Go 后端默认值一致)。要与 Go 后端共享同一物理数据库,无需额外配置。 +#### 缓存配置 + +本项目实现了两级持久缓存(参考 [OpenList.ts](https://github.com/Wudarensheng/OpenList.ts)): + +- **文件树缓存**:`/api/fs/list`、`/api/fs/get`、`/api/fs/dirs` 优先从缓存读取目录树, + 只有缓存未命中(或已过期)才会去请求网盘 / 对象存储; +- **下载链接缓存**:`/d`、`/p` 等下载链路复用驱动换取的直链, + 重复下载 / 预览时不再重新签名或重新换链(网盘换链通常最贵、最易被限流)。 + +**默认只向数据库启用**:缓存与业务数据走同一条存储链路(即 `DB_DRIVER` 指向的 +后端),因此「什么都不配」就等于「用数据库做缓存」。想要改用 / 额外启用 KV、 +Blob 等专用后端,**必须显式添加环境变量**(不会自动探测、不会自动回退): + +**CACHE_ENABLED**(总开关) +- `true`(默认):启用两级缓存 +- `false`:完全关闭(等同于 `CACHE_DRIVER=none`) + +**CACHE_DRIVER**(缓存后端) +- `db`(默认):与业务数据同一个后端(`DB_DRIVER` 解析结果,如 D1 / KV / Blob / MySQL) +- `kv`:Cloudflare KV 或 EdgeOne KV(binding 名固定为 `KV`) +- `blob`:EdgeOne Blob SDK / ESA Blob +- `cfkv`:Cloudflare KV REST API(需 `CF_ACCOUNT`、`CF_KV_UUID`、`CF_API_KEY`) +- `do`:Cloudflare Durable Objects +- `memory`:进程内存(仅本地调试,重启即失) +- `none`:关闭缓存 +- 支持逗号分隔的多后端(**读按顺序命中,写全部铺开**),例如: + - `db,kv` —— 数据库 + KV 双写,读优先命中 KV + - `kv,blob` —— 只用两个专用后端 +- 显式配置但该后端不可用时:**跳过并告警,不会悄悄换成别的后端**; + 若最终一个都不剩,缓存自动降级为「不缓存」(请求照常走真实存储)。 + +**CACHE_FILE_TREE** / **CACHE_DOWNLOAD_LINK**(分级开关) +- 均为 `true`(默认);设为 `false` 可单独关闭文件树缓存或下载链接缓存 + +**CACHE_TTL**(文件树缓存时长,分钟) +- `0`(默认):跟随存储级 `cache_expiration`(默认 30 分钟, + 可被该存储的 `custom_cache_policies` 按路径覆盖) +- `>0`:全局覆盖所有存储的文件树缓存时长 +- 存储级 `cache_expiration=0` 或路径级策略命中 `0` 时,该目录**永不缓存** + +**CACHE_LINK_TTL**(下载链接缓存时长,分钟) +- 默认 `5`。直链通常自带有效期,取值需保守:缓存过久会把已失效的链接 + 交给浏览器(表现为 403/404)。设为 `0` 可关闭链接缓存。 + +**CACHE_EXCLUDE_DRIVERS**(不参与缓存的驱动) +- 默认 `virtual,alias,url_tree,strm,chunk` —— 这些驱动不产生远程 IO, + 缓存没有收益且只会让结果变陈旧 +- 传空字符串表示不排除任何驱动 + +**CACHE_PREFIX**(缓存键前缀) +- 默认 `openlist_cache`,用于与业务数据键(`openlist_config`、`users_1` 等)隔离 + +**失效与一致性:** +- 写操作(mkdir / rename / remove / move / copy / put)会**立即失效**受影响的 + 路径与其父目录缓存; +- 存储配置变更(更新 / 启用 / 禁用 / 删除)会清空该存储的全部缓存; +- 缓存只存「驱动层结果」,权限、meta 密码、隐藏规则、下载签名都在请求时 + 实时计算,因此**缓存不会造成越权**。 + +**管理接口:** +- `GET /api/admin/cache/status` —— 生效配置、实际后端、各级缓存条目数 +- `POST /api/admin/cache/clear` —— body `{ type?: "file_tree" | "download_link", storage_id?: number }` +- `POST /api/admin/storage/refresh` —— 刷新(清空)全部存储的文件树缓存 +- `POST /api/admin/storage/refresh_one?id=` —— 刷新单个存储的文件树缓存 + +> 缓存状态也会回显在 `/api/public/env_check` 的 `cache` 字段中, +> 便于确认「缓存实际落在哪个后端」。 + #### 安全配置 - `JWT_SECRET`:JWT 令牌签名密钥(必填),**同时用作可选的字段加密密钥**与定时任务鉴权 diff --git a/docs/pr-cache.md b/docs/pr-cache.md new file mode 100644 index 00000000..05803484 --- /dev/null +++ b/docs/pr-cache.md @@ -0,0 +1,130 @@ + + +`feat(cache): implement file-tree cache and download-link cache (DB by default, KV/Blob via env)` + +## Summary / 摘要 + +参考 [Wudarensheng/OpenList.ts](https://github.com/Wudarensheng/OpenList.ts) 的两级持久缓存, +为本项目实现**文件树缓存**与**下载链接缓存**。 + +**默认只向数据库启用**:缓存与业务数据走同一条存储链路(`getStorageBackend()` 解析出的 +driver,即 `DB_DRIVER` 指向的 D1 / MySQL / KV / cfkv / Blob / DO),因此「什么都不配」 +就等于「用数据库做缓存」。想要改用 / 额外启用 KV、Blob 等专用后端,**必须显式添加 +环境变量**(`CACHE_DRIVER`),不会自动探测、也不会自动回退——与 `DB_DRIVER` +「显式配置不回退」的既有哲学一致。 + +### 用户可感知的行为变化 + +- `/api/fs/list`、`/api/fs/get`、`/api/fs/dirs` 浏览目录时优先命中缓存, + 不再每次都请求网盘 / 对象存储; +- `/d`、`/p` 等下载链路复用驱动换取的直链,重复下载 / 预览不再重新换链; +- 写操作(mkdir / rename / remove / move / copy / put)与存储配置变更后缓存立即失效; +- 默认配置下**行为与之前一致**,只是更快、对上游更友好(缓存落在同一个数据库后端)。 + +### 重要实现变化 + +- 新增 `src/backend/internal/cache/`:`config.ts`(环境变量)/ `store.ts`(后端解析与键编码)/ + `filetree.ts`(文件树缓存)/ `link.ts`(下载链接缓存)/ `index.ts`(统一入口与失效); +- 缓存写入**直接调用 driver 的 `get/put/delete/list`**,不经过 `saveDb()`: + 不触发整配置对象序列化 / 写前守卫 / 字段加密,也不会污染业务数据 + (键名统一带 `openlist_cache` 前缀,与 `openlist_config`、`users_1` 等隔离); +- 缓存只保存**驱动层结果**(原始 FileItem 列表、直链),权限 / meta 密码 / + 隐藏规则 / 下载签名仍在 `server/fs.ts`、`server/raw.ts` 按请求实时计算, + 因此**缓存不会造成越权**; +- 缓存失败(后端不可用、序列化失败等)一律降级为「不缓存」,绝不影响业务请求。 + +### 配置 / 存储 / API 变化 + +新增环境变量(默认值即「只向数据库启用」): + +| 变量 | 默认值 | 说明 | +| :-- | :-- | :-- | +| `CACHE_ENABLED` | `true` | 总开关 | +| `CACHE_DRIVER` | `db` | 后端列表,逗号分隔:`db` / `kv` / `blob` / `cfkv` / `do` / `memory` / `none` | +| `CACHE_FILE_TREE` | `true` | 文件树缓存开关 | +| `CACHE_DOWNLOAD_LINK` | `true` | 下载链接缓存开关 | +| `CACHE_TTL` | `0` | 文件树缓存时长(分钟);`0` = 跟随存储级 `cache_expiration` | +| `CACHE_LINK_TTL` | `5` | 下载链接缓存时长(分钟) | +| `CACHE_EXCLUDE_DRIVERS` | `virtual,alias,url_tree,strm,chunk` | 不参与缓存的驱动 | +| `CACHE_PREFIX` | `openlist_cache` | 缓存键前缀 | + +新增管理接口: + +- `GET /api/admin/cache/status` —— 生效配置、实际后端、各级条目数 +- `POST /api/admin/cache/clear` —— body `{ type?: "file_tree" | "download_link", storage_id?: number }` +- `POST /api/admin/storage/refresh` —— 刷新全部文件树缓存(对齐 OpenList.ts) +- `POST /api/admin/storage/refresh_one?id=` —— 刷新单个存储(对齐 OpenList.ts) + +`/api/public/env_check` 新增 `cache` 字段,回显配置与实际生效后端。 + +## Testing / 测试 + +```bash +npm run test:cache # 新增:缓存模块回归测试(配置 / 键编码 / 两级缓存 / 清理) +npm run lint # tsc --noEmit +``` + +`src/backend/internal/cache/cache.test.ts` 通过 `__setCacheDriversForTest()` 注入内存假驱动, +不依赖任何真实存储后端,锁定: + +1. 默认只向数据库启用(`CACHE_DRIVER` 缺省 → `db`),KV/Blob 必须显式配置; +2. 各开关语义(`CACHE_ENABLED` / `CACHE_FILE_TREE` / `CACHE_DOWNLOAD_LINK`); +3. 键名只含 `[A-Za-z0-9_]`(EdgeOne KV 约束),超长路径被确定性压缩; +4. 缓存命中返回副本,避免调用方就地修改污染缓存; +5. 过期条目视为未命中并被惰性清理; +6. 下载链接只缓存「确实拿到直链的文件」(目录 / 空直链不缓存); +7. `clearCache` 按存储 id 过滤,不误伤相邻 id。 + +## Checklist / 检查清单 + +- [x] 遵循 Conventional Commits 的 PR 标题 +- [x] 新增/修改代码通过 `npm run lint`(tsc --noEmit) +- [x] 新增单元测试并通过 +- [x] 更新 README 环境变量文档与 `package.json` 的 Cloudflare bindings 说明 +- [x] 不引入破坏性变更(默认配置行为不变) + +## Implementation Notes / 实现说明 + +### 为什么默认是 `db` + +`db` 后端复用 `getStorageBackend()` 的解析结果,因此缓存与业务数据**同后端**: +- 用户不需要额外配置任何东西; +- 不需要额外的绑定,也就不存在「部署了但缓存后端不可用」的隐性故障; +- 单一后端不会出现「业务数据在 D1、缓存在 KV」这类难以排查的分裂。 + +### 多后端语义 + +`CACHE_DRIVER=db,kv` 时:**读按顺序命中,写全部铺开**。任一后端不可用时 +跳过并告警一次(`warnOnce`),绝不静默替换成别的后端;若最终一个都不剩, +缓存整体降级为 no-op。 + +### TTL 与「不缓存」 + +- 文件树 TTL 优先级:`CACHE_TTL`(>0 时覆盖)→ 存储级 `cache_expiration` + (可被 `custom_cache_policies` 按路径覆盖)→ 默认 30 分钟; +- 存储级 `cache_expiration=0` 或路径级策略命中 `0` 时,该目录**永不缓存** + (与 Go 的语义一致); +- 下载链接 TTL 独立(默认 5 分钟):直链通常自带有效期,缓存过久会把 + 「已失效的链接」交给浏览器。 + +### 失效策略 + +- 写操作:失效「被改动路径」+「其父目录」的文件树缓存,以及被改动路径的链接缓存; +- 存储更新 / 启用 / 禁用 / 删除:清空该存储的全部缓存(避免 id 复用时读到旧内容); +- 过期条目在读路径上惰性清理,避免缓存无限膨胀。 + +### 已知限制 + +- 链接缓存的 TTL 是**静态**的,不解析直链里 `Expires` 参数:TTL 设置过大时 + 可能把已失效的链接交给浏览器(表现为 403/404)。保守默认 5 分钟, + 必要时可设 `CACHE_LINK_TTL=0` 关闭链接缓存; +- 删除目录时只失效该目录自身的缓存,其下文件的链接缓存随 TTL 自然过期 + (链接 TTL 很短,不做递归清理以避免大量删除操作); +- 纯本地计算型驱动(`virtual` / `alias` / `url_tree` / `strm` / `chunk`) + 默认不缓存:没有远程 IO,缓存只会带来陈旧。 diff --git a/package.json b/package.json index 8662a0cd..9994cb79 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,30 @@ }, "DB_CIPHER": { "description": "At-rest cipher for sensitive fields (drive credentials, 2FA secrets, password hashes): `none` (default, no encryption), `aes-256-gcm` (HKDF-derived AES-256-GCM key — the `enc:v2:` envelope written by existing encrypted deployments, recommended), `aes-256-gcm-pbkdf2` (legacy `enc:v1:` envelope, PBKDF2 per field, slow), `aes-256-cbc-hmac` (`enc:v3:`), `chacha20-poly1305` (`enc:v4:`, RFC 8439, pure-JS implementation), `des-cbc-hmac` / `3des-cbc-hmac` (`enc:v5:`/`enc:v6:`, compatibility only — single DES is 56-bit and 3DES is deprecated, do not use for real data). Ciphertexts carry an `enc:vN:` prefix and are decrypted by prefix on read, so switching back to `none` (or changing algorithms) never makes existing data unreadable — it only changes how newly written data is stored (legacy ciphertext is migrated to plaintext on the next save). Key derivation is cached per isolate, and unchanged fields are not re-encrypted on save. This setting does NOT affect the shared secret: with `none`, a secret is still generated and persisted during setup when JWT_SECRET is not provided, because JWT signing needs it." + }, + "CACHE_ENABLED": { + "description": "Optional. Master switch for the file-tree cache and the download-link cache. Defaults to `true`. Set to `false` to disable both." + }, + "CACHE_DRIVER": { + "description": "Optional. Where the caches are stored. Defaults to `db`, meaning the same backend as DB_DRIVER (D1/KV/Blob/MySQL) — i.e. only the database is used unless you opt in explicitly. Comma-separated list to add dedicated backends: `kv`, `blob`, `cfkv`, `do`, `memory`, or `none` to disable. Example: `db,kv` (read prefers KV) or `kv` (KV only). Dedicated backends are never auto-detected or auto-substituted." + }, + "CACHE_FILE_TREE": { + "description": "Optional. Toggle the file-tree cache (directory listings). Defaults to `true`. Listings are cached per storage+path; only admin writes invalidate them." + }, + "CACHE_DOWNLOAD_LINK": { + "description": "Optional. Toggle the download-link cache (presigned/raw download URLs returned by drivers). Defaults to `true`. Reuses the link instead of re-signing on every download." + }, + "CACHE_TTL": { + "description": "Optional. File-tree cache lifetime in minutes. Defaults to `0`, which means 'follow the per-storage cache_expiration' (30 minutes, overridable per path by custom_cache_policies). Set to 0 to never cache a storage whose cache_expiration is 0." + }, + "CACHE_LINK_TTL": { + "description": "Optional. Download-link cache lifetime in minutes. Defaults to `5`. Keep it conservative: raw URLs usually carry their own expiry, and a stale link is served to the browser as 403/404." + }, + "CACHE_EXCLUDE_DRIVERS": { + "description": "Optional. Comma-separated drivers excluded from caching. Defaults to `virtual,alias,url_tree,strm,chunk` (locally computed drivers where caching only adds staleness)." + }, + "CACHE_PREFIX": { + "description": "Optional. Key prefix used to isolate cache entries from business data. Defaults to `openlist_cache`." } } }, @@ -55,10 +79,11 @@ "test:server": "tsx --test \"src/backend/server/*.test.ts\"", "test:store": "tsx --test src/backend/internal/model/store/store.test.ts", "test:model": "tsx --test \"src/backend/internal/model/*.test.ts\"", + "test:cache": "tsx --test \"src/backend/internal/cache/*.test.ts\"", "test:regress": "tsx scripts/_regress.mjs", "test:deploy": "node scripts/test-deploy.js", "env:check": "tsx scripts/env-check.mjs", - "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:regress", + "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:cache && npm run test:regress", "sls:deploy": "serverless deploy", "sls:package": "serverless package", "sls:info": "serverless info", diff --git a/src/backend/internal/cache/cache.test.ts b/src/backend/internal/cache/cache.test.ts new file mode 100644 index 00000000..919b7457 --- /dev/null +++ b/src/backend/internal/cache/cache.test.ts @@ -0,0 +1,381 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +/** + * 缓存模块回归测试(文件树缓存 / 下载链接缓存)。 + * + * 锁定以下契约: + * 1. 默认只向数据库启用(CACHE_DRIVER 缺省 → `db`);KV/Blob 等专用后端 + * 必须显式配置,未配置时绝不启用; + * 2. 开关语义:CACHE_ENABLED / CACHE_FILE_TREE / CACHE_DOWNLOAD_LINK; + * 3. 键名对 KV 合法(EdgeOne 只接受 [A-Za-z0-9_]),且超长路径被确定性压缩; + * 4. 文件树缓存命中时返回副本(避免调用方就地修改污染缓存); + * 5. 过期条目视为未命中; + * 6. 下载链接缓存只缓存「确实拿到直链的文件」,目录 / 空直链不缓存; + * 7. clearCache 能按存储 id 过滤,避免 storages_1_ 误伤 storages_10_。 + * + * 测试通过 __setCacheDriversForTest 注入内存假驱动,完全不依赖真实存储后端。 + */ + +const mod = await import("./index") +const { + buildCacheKey, + cacheGetEnvelope, + cacheSetEnvelope, + clearCache, + encodeCachePathSegment, + expandToInvalidationPaths, + getCacheConfig, + getCachedFileTree, + getCachedLink, + parseCacheBackends, + setCachedFileTree, + setCachedLink, + storageIdFromCacheKey, + __resetCacheConfigForTest, + __resetCacheStoreForTest, + __setCacheDriversForTest, +} = mod as any + +/** 内存假驱动(满足 Driver 接口的键值部分)。 */ +function createFakeDriver(name = "fake") { + const store = new Map() + return { + name, + __store: store, + async isAvailable() { + return true + }, + async init() {}, + async get(key: string) { + return store.get(key) ?? null + }, + async put(key: string, value: string) { + store.set(key, value) + }, + async delete(key: string) { + store.delete(key) + }, + async list(prefix: string) { + return [...store.keys()].filter((k) => k.startsWith(prefix)) + }, + async health() { + return { connected: true } + }, + } +} + +function setup(driver: any = createFakeDriver()) { + __resetCacheConfigForTest() + __resetCacheStoreForTest() + __setCacheDriversForTest(async () => [driver]) + return driver +} + +const SAMPLE_ITEMS = [ + { name: "a.txt", size: 1, is_dir: false, modified: "2026-01-01", sign: "", type: 0 }, + { name: "sub", size: 0, is_dir: true, modified: "2026-01-01", sign: "", type: 1 }, +] + +// ───────────────────────── 配置 ───────────────────────── + +test("配置:默认只向数据库启用", () => { + __resetCacheConfigForTest() + const cfg = getCacheConfig({}) + assert.equal(cfg.enabled, true) + assert.deepEqual(cfg.backends, ["db"]) + assert.equal(cfg.fileTree, true) + assert.equal(cfg.downloadLink, true) + // 默认 TTL:文件树跟随存储级 cache_expiration(0 = 不覆盖) + assert.equal(cfg.ttlMinutes, 0) + assert.equal(cfg.linkTtlMinutes, 5) +}) + +test("配置:CACHE_DRIVER 解析(db / kv / 多后端 / none / 未知值)", () => { + assert.deepEqual(parseCacheBackends(""), ["db"]) + assert.deepEqual(parseCacheBackends("kv"), ["kv"]) + assert.deepEqual(parseCacheBackends("db,kv"), ["db", "kv"]) + assert.deepEqual(parseCacheBackends("KV , BLOB"), ["kv", "blob"]) + assert.deepEqual(parseCacheBackends("none"), []) + // 未知值被忽略,且不会把列表清空(回退 db) + assert.deepEqual(parseCacheBackends("nope"), ["db"]) + assert.deepEqual(parseCacheBackends("nope,kv"), ["kv"]) +}) + +test("配置:开关与 TTL 读取", () => { + __resetCacheConfigForTest() + const cfg = getCacheConfig({ + CACHE_ENABLED: "false", + CACHE_FILE_TREE: "0", + CACHE_DOWNLOAD_LINK: "off", + CACHE_TTL: "12", + CACHE_LINK_TTL: "1", + CACHE_DRIVER: "db,kv", + }) + assert.equal(cfg.enabled, false) + assert.equal(cfg.fileTree, false) + assert.equal(cfg.downloadLink, false) + assert.equal(cfg.ttlMinutes, 12) + assert.equal(cfg.linkTtlMinutes, 1) + assert.deepEqual(cfg.backends, ["db", "kv"]) +}) + +test("配置:CACHE_DRIVER=none 时整体关闭", () => { + __resetCacheConfigForTest() + const cfg = getCacheConfig({ CACHE_DRIVER: "none" }) + assert.equal(cfg.enabled, false) + assert.deepEqual(cfg.backends, []) +}) + +test("配置:默认排除纯本地计算型驱动", () => { + __resetCacheConfigForTest() + const cfg = getCacheConfig({}) + assert.ok(cfg.excludeDrivers.has("virtual")) + assert.ok(cfg.excludeDrivers.has("alias")) + assert.ok(cfg.excludeDrivers.has("urltree")) +}) + +// ───────────────────────── 键名编码 ───────────────────────── + +test("键名:KV 合法字符集 + 存储 id 可解析", () => { + const key = buildCacheKey("ft", 1, "/a b/c%d.txt", "openlist_cache") + assert.match(key, /^[A-Za-z0-9_]+$/, "键名必须只含 [A-Za-z0-9_]") + assert.equal(storageIdFromCacheKey(key, "ft", "openlist_cache"), "1") + // 同输入必得同键(失效时才能算出一致的键) + assert.equal(key, buildCacheKey("ft", 1, "/a b/c%d.txt", "openlist_cache")) +}) + +test("键名:storageId 解析不受前缀内下划线影响", () => { + const key = buildCacheKey("ln", 42, "/x", "my_cache_prefix") + assert.equal(storageIdFromCacheKey(key, "ln", "my_cache_prefix"), "42") + // kind 不同不会串味 + assert.equal(storageIdFromCacheKey(key, "ft", "my_cache_prefix"), null) +}) + +test("键名:超长路径被确定性压缩且仍在合法字符集内", () => { + const long = "/" + Array.from({ length: 60 }, (_, i) => `dir${i}`).join("/") + const a = encodeCachePathSegment(long) + const b = encodeCachePathSegment(long) + assert.equal(a, b) + assert.ok(a.length <= 180, "长路径应被压缩") + assert.match(a, /^[A-Za-z0-9_]+$/) + // 不同长路径不应碰撞 + assert.notEqual(a, encodeCachePathSegment(long + "x")) +}) + +test("失效路径展开:包含父目录", () => { + assert.deepEqual(expandToInvalidationPaths("/a/b/c.txt").sort(), ["/a/b", "/a/b/c.txt"]) + assert.deepEqual(expandToInvalidationPaths("/x").sort(), ["/", "/x"]) +}) + +// ───────────────────────── 存储层 ───────────────────────── + +test("存储层:写入后可读出,过期条目视为未命中并被清理", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db" } + + await cacheSetEnvelope("k1", { hello: "world" }, 60_000, env) + const hit = await cacheGetEnvelope("k1", env) + assert.deepEqual(hit?.v, { hello: "world" }) + + // 手工写入一条已过期条目 + await driver.put("k2", JSON.stringify({ v: 1, ts: 1, exp: Date.now() - 1000 })) + assert.equal(await cacheGetEnvelope("k2", env), null) + // 惰性清理是 fire-and-forget,等一个 tick 再断言 + await new Promise((r) => setTimeout(r, 0)) + assert.equal(await driver.get("k2"), null, "过期条目应被惰性清理") +}) + +test("存储层:ttl<=0 时不写入", async () => { + const driver = setup() + await cacheSetEnvelope("k", 1, 0, {}) + assert.equal(await driver.get("k"), null) +}) + +// ───────────────────────── 文件树缓存 ───────────────────────── + +test("文件树缓存:命中时返回深拷贝副本", async () => { + setup() + const env = { CACHE_DRIVER: "db", CACHE_TTL: "30" } + const target = { storage: { id: 1, driver: "fake" }, cleanPath: "/a" } + + await setCachedFileTree(target, SAMPLE_ITEMS as any, env) + const hit = await getCachedFileTree(target, env) + assert.ok(hit) + assert.equal(hit!.length, 2) + assert.equal(hit![0].name, "a.txt") + + // 必须是副本:就地修改不能回写缓存 + assert.notEqual(hit, SAMPLE_ITEMS) + assert.notEqual(hit![0], SAMPLE_ITEMS[0]) + hit![0].name = "MUTATED" + const again = await getCachedFileTree(target, env) + assert.equal(again![0].name, "a.txt") +}) + +test("文件树缓存:空目录也会被缓存(避免反复击穿存储)", async () => { + setup() + const env = { CACHE_DRIVER: "db", CACHE_TTL: "30" } + const target = { storage: { id: 7, driver: "fake" }, cleanPath: "/empty" } + await setCachedFileTree(target, [], env) + const hit = await getCachedFileTree(target, env) + assert.ok(Array.isArray(hit)) + assert.equal(hit!.length, 0) +}) + +test("文件树缓存:cache_expiration=0(不缓存)时读写都被跳过", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db" } + const target = { + storage: { id: 2, driver: "fake", cache_expiration: 0 }, + cleanPath: "/nocache", + } + await setCachedFileTree(target, SAMPLE_ITEMS as any, env) + assert.equal(await getCachedFileTree(target, env), null) + assert.equal(driver.__store.size, 0, "不应产生任何缓存键") +}) + +test("文件树缓存:排除名单中的驱动不缓存", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db", CACHE_TTL: "30" } + const target = { storage: { id: 3, driver: "virtual" }, cleanPath: "/v" } + await setCachedFileTree(target, SAMPLE_ITEMS as any, env) + assert.equal(await getCachedFileTree(target, env), null) + assert.equal(driver.__store.size, 0) +}) + +test("文件树缓存:CACHE_FILE_TREE=false 时关闭", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db", CACHE_FILE_TREE: "false", CACHE_TTL: "30" } + const target = { storage: { id: 4, driver: "fake" }, cleanPath: "/a" } + await setCachedFileTree(target, SAMPLE_ITEMS as any, env) + assert.equal(await getCachedFileTree(target, env), null) + assert.equal(driver.__store.size, 0) +}) + +test("文件树缓存:custom_cache_policies 命中 0 分钟即不缓存", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db" } + const target = { + storage: { + id: 5, + driver: "fake", + cache_expiration: 30, + custom_cache_policies: JSON.stringify([ + { path: "/private/*", cache_expiration: 0 }, + ]), + }, + cleanPath: "/private/x", + } + await setCachedFileTree(target, SAMPLE_ITEMS as any, env) + assert.equal(await getCachedFileTree(target, env), null) + assert.equal(driver.__store.size, 0) +}) + +// ───────────────────────── 下载链接缓存 ───────────────────────── + +const FILE_ITEM = { + name: "movie.mp4", + size: 100, + is_dir: false, + modified: "2026-01-01", + sign: "", + type: 2, + raw_url: "https://cdn.example.com/movie.mp4?sig=abc", +} + +test("下载链接缓存:缓存拿到直链的文件,并返回副本", async () => { + setup() + const env = { CACHE_DRIVER: "db" } + const target = { storage: { id: 10, driver: "fake" }, cleanPath: "/movie.mp4" } + + await setCachedLink(target, FILE_ITEM as any, env) + const hit = await getCachedLink(target, env) + assert.ok(hit) + assert.equal(hit!.raw_url, FILE_ITEM.raw_url) + assert.notEqual(hit, FILE_ITEM) + + hit!.raw_url = "MUTATED" + const again = await getCachedLink(target, env) + assert.equal(again!.raw_url, FILE_ITEM.raw_url) +}) + +test("下载链接缓存:目录 / 空直链不缓存", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db" } + const dirTarget = { storage: { id: 11, driver: "fake" }, cleanPath: "/dir" } + const emptyTarget = { storage: { id: 11, driver: "fake" }, cleanPath: "/empty" } + + await setCachedLink(dirTarget, { ...FILE_ITEM, is_dir: true } as any, env) + await setCachedLink(emptyTarget, { ...FILE_ITEM, raw_url: "" } as any, env) + + assert.equal(await getCachedLink(dirTarget, env), null) + assert.equal(await getCachedLink(emptyTarget, env), null) + assert.equal(driver.__store.size, 0) +}) + +test("下载链接缓存:CACHE_LINK_TTL=0 时关闭", async () => { + const driver = setup() + const env = { CACHE_DRIVER: "db", CACHE_LINK_TTL: "0" } + const target = { storage: { id: 12, driver: "fake" }, cleanPath: "/movie.mp4" } + await setCachedLink(target, FILE_ITEM as any, env) + assert.equal(await getCachedLink(target, env), null) + assert.equal(driver.__store.size, 0) +}) + +// ───────────────────────── 清理 ───────────────────────── + +test("clearCache:按存储 id 过滤,不误伤相邻 id", async () => { + setup() + const env = { CACHE_DRIVER: "db", CACHE_TTL: "30" } + await setCachedFileTree( + { storage: { id: 1, driver: "fake" }, cleanPath: "/a" }, + SAMPLE_ITEMS as any, + env, + ) + await setCachedFileTree( + { storage: { id: 10, driver: "fake" }, cleanPath: "/a" }, + SAMPLE_ITEMS as any, + env, + ) + await setCachedLink( + { storage: { id: 1, driver: "fake" }, cleanPath: "/movie.mp4" }, + FILE_ITEM as any, + env, + ) + + const removed = await clearCache(env, { storageId: 1 }) + assert.equal(removed, 2, "只应清掉 storage 1 的两条缓存") + + // storage 10 的文件树缓存仍在 + const survivor = await getCachedFileTree( + { storage: { id: 10, driver: "fake" }, cleanPath: "/a" }, + env, + ) + assert.ok(survivor) + + const rest = await clearCache(env, {}) + assert.equal(rest, 1) +}) + +test("clearCache:按 kind 只清一类", async () => { + setup() + const env = { CACHE_DRIVER: "db", CACHE_TTL: "30" } + await setCachedFileTree( + { storage: { id: 1, driver: "fake" }, cleanPath: "/a" }, + SAMPLE_ITEMS as any, + env, + ) + await setCachedLink( + { storage: { id: 1, driver: "fake" }, cleanPath: "/movie.mp4" }, + FILE_ITEM as any, + env, + ) + assert.equal(await clearCache(env, { kind: "ft" }), 1) + assert.ok( + await getCachedLink( + { storage: { id: 1, driver: "fake" }, cleanPath: "/movie.mp4" }, + env, + ), + ) +}) diff --git a/src/backend/internal/cache/config.ts b/src/backend/internal/cache/config.ts new file mode 100644 index 00000000..afebf8c8 --- /dev/null +++ b/src/backend/internal/cache/config.ts @@ -0,0 +1,242 @@ +/** + * 缓存配置(文件树缓存 / 下载链接缓存)—— 完全由环境变量驱动。 + * + * 设计目标(对齐参考实现 OpenList.ts 的两级缓存): + * 1. **文件树缓存**:`/fs/list`、`/fs/get`、`/fs/dirs` 优先命中缓存, + * 避免每次浏览都去请求网盘 / 对象存储; + * 2. **下载链接缓存**:`/d`、`/p` 等下载链路把驱动返回的直链缓存起来, + * 避免重复签名 / 重复换取临时链接。 + * + * 默认行为(**只向数据库启用**): + * 缓存内容与业务数据走同一条存储链路(`getStorageBackend()` 解析出的 + * driver,即 DB_DRIVER 指向的 d1 / mysql / kv / cfkv / blob / do), + * 因此「什么都不配」就等于「用数据库做缓存」。 + * + * 想要改用 / 额外启用 KV、Blob 等专用后端时,**必须显式添加环境变量** + * CACHE_DRIVER=kv # 只用 KV 缓存 + * CACHE_DRIVER=db,kv # 数据库 + KV 双写(读优先命中 KV) + * CACHE_DRIVER=blob,cfkv # 多个专用后端 + * 未显式配置的专用后端绝不会被自动启用,避免「以为缓存在 KV、实际写进了 + * 数据库」这类不可见行为(与 DB_DRIVER 的「显式配置不回退」哲学一致)。 + * + * 支持的环境变量一览: + * + * | 变量 | 默认值 | 说明 | + * | :-- | :-- | :-- | + * | `CACHE_ENABLED` | `true` | 总开关,`false` 时两级缓存全部关闭 | + * | `CACHE_DRIVER` | `db` | 缓存后端列表,逗号分隔:`db` / `kv` / `blob` / `cfkv` / `do` / `memory` / `none` | + * | `CACHE_FILE_TREE` | `true` | 文件树缓存开关 | + * | `CACHE_DOWNLOAD_LINK` | `true` | 下载链接缓存开关 | + * | `CACHE_TTL` | `0` | 文件树缓存时长(分钟);`0` = 跟随存储级 `cache_expiration`(默认 30) | + * | `CACHE_LINK_TTL` | `5` | 下载链接缓存时长(分钟);直链通常带有效期,不宜过长 | + * | `CACHE_EXCLUDE_DRIVERS` | `virtual,alias,url_tree,strm,chunk` | 不参与缓存的驱动(本地计算型,缓存只会带来陈旧) | + * | `CACHE_PREFIX` | `openlist_cache` | 缓存键前缀,用于与业务数据键隔离 | + */ + +/** 可用的缓存后端名。`db` = 与业务数据同一个后端(默认)。 */ +export type CacheBackendName = + | "db" + | "kv" + | "blob" + | "cfkv" + | "do" + | "memory" + | "none" + +/** 合法后端名(`none` 用于显式关闭) */ +export const VALID_CACHE_BACKENDS: readonly CacheBackendName[] = [ + "db", + "kv", + "blob", + "cfkv", + "do", + "memory", + "none", +] as const + +/** + * 默认排除的驱动:这些驱动不产生远程 IO(纯本地计算/别名映射), + * 缓存不但没有收益,还会因为它们的输入(如 url_structure)变更而变陈旧。 + * 可通过 `CACHE_EXCLUDE_DRIVERS` 覆盖(传空字符串表示不排除任何驱动)。 + */ +const DEFAULT_EXCLUDE_DRIVERS = ["virtual", "alias", "url_tree", "strm", "chunk"] + +/** 存储级 cache_expiration 缺省值(与 driver/storageopts.ts 保持一致) */ +export const DEFAULT_FILE_TREE_TTL_MINUTES = 30 + +/** 下载直链的默认缓存时长(分钟):直链通常带签名有效期,取值需保守 */ +export const DEFAULT_LINK_TTL_MINUTES = 5 + +/** 已解析的缓存配置 */ +export interface CacheConfig { + /** 总开关 */ + enabled: boolean + /** 缓存后端列表(已去掉 `none`;为空表示关闭) */ + backends: CacheBackendName[] + /** 文件树缓存开关 */ + fileTree: boolean + /** 下载链接缓存开关 */ + downloadLink: boolean + /** 文件树缓存时长(分钟);`0` 表示跟随存储级配置 */ + ttlMinutes: number + /** 下载链接缓存时长(分钟) */ + linkTtlMinutes: number + /** 不参与缓存的驱动(小写、去非字母数字) */ + excludeDrivers: Set + /** 缓存键前缀 */ + prefix: string +} + +/** 读取环境变量(同时兼容注入的 env 对象与 process.env)。 */ +function readRaw(key: string, env?: any): string { + const e = env && typeof env === "object" ? env : {} + const fromEnv = e[key] + if (fromEnv !== undefined && fromEnv !== null && String(fromEnv) !== "") { + return String(fromEnv) + } + if (typeof process !== "undefined" && process.env) { + const fromProcess = (process.env as any)[key] + if (fromProcess !== undefined && fromProcess !== null) { + return String(fromProcess) + } + } + return "" +} + +/** 解析布尔型环境变量(无法识别时回退默认值)。 */ +function readBool(key: string, fallback: boolean, env?: any): boolean { + const raw = readRaw(key, env).trim().toLowerCase() + if (!raw) return fallback + if (["1", "true", "yes", "on", "enable", "enabled"].includes(raw)) return true + if (["0", "false", "no", "off", "disable", "disabled"].includes(raw)) return false + return fallback +} + +/** 解析非负整数型环境变量(无法识别时回退默认值)。 */ +function readInt(key: string, fallback: number, env?: any): number { + const raw = readRaw(key, env).trim() + if (!raw) return fallback + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) return fallback + return n +} + +/** 驱动名归一化(与 storage.ts 的 normDriver 规则一致)。 */ +export function normalizeDriverName(name: string): string { + return String(name || "") + .toLowerCase() + .replace(/[^a-z0-9]/g, "") +} + +/** + * 解析 `CACHE_DRIVER`。 + * + * 规则: + * - 缺省 / 空 → `["db"]`(默认只向数据库启用); + * - 出现 `none` → 返回空数组(显式关闭); + * - 无法识别的取值会被忽略并告警一次(不静默改写成别的后端)。 + */ +export function parseCacheBackends(raw: string): CacheBackendName[] { + const tokens = String(raw || "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + if (tokens.length === 0) return ["db"] + if (tokens.includes("none")) return [] + + const out: CacheBackendName[] = [] + const unknown: string[] = [] + for (const token of tokens) { + const norm = normalizeDriverName(token) + const match = VALID_CACHE_BACKENDS.find( + (b) => b !== "none" && normalizeDriverName(b) === norm, + ) + if (match) { + if (!out.includes(match)) out.push(match) + } else { + unknown.push(token) + } + } + if (unknown.length > 0) { + console.warn( + `[Cache] Unknown CACHE_DRIVER value(s): ${unknown.join(", ")}. ` + + `Valid values: ${VALID_CACHE_BACKENDS.join(", ")}. They were ignored.`, + ) + } + return out.length > 0 ? out : ["db"] +} + +/** 解析 `CACHE_EXCLUDE_DRIVERS`(默认排除纯本地计算型驱动)。 */ +export function parseExcludeDrivers(raw: string): Set { + const source = String(raw || "").trim() + const tokens = (source === "" ? DEFAULT_EXCLUDE_DRIVERS : source.split(",")) + .map((s) => normalizeDriverName(s)) + .filter(Boolean) + return new Set(tokens) +} + +/** 计算用于「配置缓存」的指纹(配置一变即失效)。 */ +function configSignature(env?: any): string { + return [ + readRaw("CACHE_ENABLED", env), + readRaw("CACHE_DRIVER", env), + readRaw("CACHE_FILE_TREE", env), + readRaw("CACHE_DOWNLOAD_LINK", env), + readRaw("CACHE_TTL", env), + readRaw("CACHE_LINK_TTL", env), + readRaw("CACHE_EXCLUDE_DRIVERS", env), + readRaw("CACHE_PREFIX", env), + ].join("\u0001") +} + +let cachedConfig: { sig: string; config: CacheConfig } | null = null + +/** + * 读取缓存配置(按环境变量指纹做进程内记忆化)。 + * + * 纯同步、无 IO:缓存配置只来自环境变量,不依赖数据库, + * 因此可以在请求热路径上安全调用。 + */ +export function getCacheConfig(env?: any): CacheConfig { + const sig = configSignature(env) + if (cachedConfig && cachedConfig.sig === sig) return cachedConfig.config + + const backends = parseCacheBackends(readRaw("CACHE_DRIVER", env)) + const enabledRaw = readBool("CACHE_ENABLED", true, env) + const prefix = readRaw("CACHE_PREFIX", env).trim() || "openlist_cache" + + const config: CacheConfig = { + enabled: enabledRaw && backends.length > 0, + backends, + fileTree: readBool("CACHE_FILE_TREE", true, env), + downloadLink: readBool("CACHE_DOWNLOAD_LINK", true, env), + ttlMinutes: readInt("CACHE_TTL", 0, env), + linkTtlMinutes: readInt("CACHE_LINK_TTL", DEFAULT_LINK_TTL_MINUTES, env), + excludeDrivers: parseExcludeDrivers(readRaw("CACHE_EXCLUDE_DRIVERS", env)), + prefix, + } + + cachedConfig = { sig, config } + return config +} + +/** 仅供测试:清除配置记忆化,避免用例间串味。 */ +export function __resetCacheConfigForTest(): void { + cachedConfig = null +} + +/** 供管理接口回显当前生效配置(脱敏,不含任何密钥)。 */ +export function describeCacheConfig(env?: any): Record { + const cfg = getCacheConfig(env) + return { + enabled: cfg.enabled, + driver: cfg.backends.join(","), + backends: cfg.backends, + file_tree: cfg.fileTree, + download_link: cfg.downloadLink, + ttl_minutes: cfg.ttlMinutes, + link_ttl_minutes: cfg.linkTtlMinutes, + exclude_drivers: [...cfg.excludeDrivers], + prefix: cfg.prefix, + } +} diff --git a/src/backend/internal/cache/filetree.ts b/src/backend/internal/cache/filetree.ts new file mode 100644 index 00000000..3cb68c1b --- /dev/null +++ b/src/backend/internal/cache/filetree.ts @@ -0,0 +1,176 @@ +/** + * 文件树缓存(File Tree Cache)。 + * + * 对齐参考实现 OpenList.ts 的 `files` / `file_cache` 表:浏览目录时优先从 + * 缓存读取,只有缓存未命中(或已过期)才去请求真实存储。 + * + * 缓存粒度与安全边界(重要): + * - 只缓存**驱动层**的 `driver.list()` 结果(原始 FileItem 列表), + * 不缓存任何与请求者身份相关的东西(权限、meta 密码、签名、hide 过滤 + * 都在 server/fs.ts 里按请求实时计算)。因此「管理员看过一次、普通用户 + * 随后访问」不会因为缓存而越权。 + * - 空目录同样会被缓存(值为 `[]`),避免反复击穿到存储。 + * - 写操作(mkdir/rename/remove/move/copy/put)后由调用方主动失效, + * 见 cache/index.ts 的 invalidatePaths()。 + */ +import type { FileItem } from "../driver/base" +import { resolveCacheExpiration } from "../driver/storageopts" +import { resolvePath } from "../model/db" +import { + DEFAULT_FILE_TREE_TTL_MINUTES, + getCacheConfig, + normalizeDriverName, +} from "./config" +import { + buildCacheKey, + cacheDelete, + cacheGetEnvelope, + cacheSetEnvelope, +} from "./store" + +/** 缓存目标:`resolvePath()` 的返回值中本模块关心的字段。 */ +export interface CacheTarget { + storage: any + cleanPath: string + physical?: string | null + isVirtual?: boolean +} + +/** + * 该目标是否可参与文件树缓存。 + * + * 排除条件: + * - 总开关 / 文件树开关关闭; + * - 虚拟路径(没有对应存储); + * - 驱动在 CACHE_EXCLUDE_DRIVERS 中(纯本地计算型,缓存只会变陈旧); + * - 计算出的 TTL <= 0(存储级 `cache_expiration=0` 或自定义策略命中 0, + * 语义即「不缓存」)。 + */ +export function isFileTreeCacheable( + target: CacheTarget | null | undefined, + env?: any, +): boolean { + const cfg = getCacheConfig(env) + if (!cfg.enabled || !cfg.fileTree) return false + if (!target?.storage || target.isVirtual) return false + const driver = normalizeDriverName(target.storage.driver) + if (cfg.excludeDrivers.has(driver)) return false + return resolveFileTreeTtlMinutes(target, env) > 0 +} + +/** + * 文件树缓存的 TTL(分钟)。 + * + * 优先级: + * 1. `CACHE_TTL`(显式全局覆盖,>0 时生效) + * 2. 存储级 `cache_expiration`(可被 `custom_cache_policies` 按路径覆盖) + * 3. 默认 30 分钟 + */ +export function resolveFileTreeTtlMinutes( + target: CacheTarget, + env?: any, +): number { + const cfg = getCacheConfig(env) + if (cfg.ttlMinutes > 0) return cfg.ttlMinutes + try { + const minutes = resolveCacheExpiration(target.storage, target.cleanPath) + return Number.isFinite(minutes) ? minutes : DEFAULT_FILE_TREE_TTL_MINUTES + } catch { + return DEFAULT_FILE_TREE_TTL_MINUTES + } +} + +function keyFor(target: CacheTarget, env?: any): string { + const cfg = getCacheConfig(env) + return buildCacheKey("ft", target.storage.id, target.cleanPath, cfg.prefix) +} + +/** + * 读取文件树缓存。未命中 / 已过期 / 任何异常都返回 null(由调用方回源)。 + */ +export async function getCachedFileTree( + target: CacheTarget | null | undefined, + env?: any, +): Promise { + if (!target || !isFileTreeCacheable(target, env)) return null + try { + const hit = await cacheGetEnvelope(keyFor(target, env), env) + if (!hit) return null + const items = hit.v + if (!Array.isArray(items)) return null + // 防御性拷贝:缓存对象可能被上层归一化逻辑就地修改(如补 type 字段), + // 直接返回同一引用会把修改带回缓存(多实例/多请求下表现为数据串味)。 + return items.map((item) => ({ ...item })) + } catch { + return null + } +} + +/** 写入文件树缓存(TTL <= 0 时跳过)。 */ +export async function setCachedFileTree( + target: CacheTarget | null | undefined, + items: FileItem[], + env?: any, +): Promise { + if (!target || !isFileTreeCacheable(target, env)) return + const ttlMinutes = resolveFileTreeTtlMinutes(target, env) + if (ttlMinutes <= 0) return + try { + await cacheSetEnvelope(keyFor(target, env), items, ttlMinutes * 60_000, env) + } catch { + // 缓存写入失败绝不影响业务 + } +} + +/** 删除单个路径的文件树缓存(不做路径解析,调用方已知 storage)。 */ +export async function deleteFileTreeCacheEntry( + storageId: any, + cleanPath: string, + env?: any, +): Promise { + const cfg = getCacheConfig(env) + await cacheDelete(buildCacheKey("ft", storageId, cleanPath, cfg.prefix), env) +} + +/** 把某个虚拟路径与其父目录一并加入待失效集合(父目录的列表必然变化)。 */ +export function expandToInvalidationPaths(virtualPath: string): string[] { + const norm = + "/" + + String(virtualPath || "") + .split("/") + .filter(Boolean) + .join("/") + const idx = norm.lastIndexOf("/") + const parent = idx <= 0 ? "/" : norm.slice(0, idx) + return parent === norm ? [norm] : [norm, parent] +} + +/** + * 失效若干虚拟路径对应的文件树缓存。 + * + * 每个路径会连同其父目录一起失效:例如 `/a/b/c.txt` 的增删改会让 `/a/b` + * 的目录列表变陈旧;若该路径本身是目录,它自己的列表也会失效。 + */ +export async function invalidateFileTree( + virtualPaths: string[], + env?: any, +): Promise { + const cfg = getCacheConfig(env) + if (!cfg.enabled || !cfg.fileTree) return + + const keys = new Set() + for (const p of virtualPaths) { + for (const candidate of expandToInvalidationPaths(p)) { + try { + const resolved = await resolvePath(candidate, env) + if (resolved.isVirtual || !resolved.storage) continue + keys.add( + buildCacheKey("ft", resolved.storage.id, resolved.cleanPath, cfg.prefix), + ) + } catch { + // 路径无法解析(存储被删除等):忽略,缓存会随 TTL 自然过期 + } + } + } + for (const key of keys) await cacheDelete(key, env) +} diff --git a/src/backend/internal/cache/index.ts b/src/backend/internal/cache/index.ts new file mode 100644 index 00000000..5977eee8 --- /dev/null +++ b/src/backend/internal/cache/index.ts @@ -0,0 +1,125 @@ +/** + * 缓存模块统一入口。 + * + * 两级缓存(对齐参考实现 OpenList.ts): + * - 文件树缓存:`filetree.ts`,浏览目录时命中缓存,避免每次请求网盘; + * - 下载链接缓存:`link.ts`,复用驱动换取的直链,避免重复签名/换链。 + * + * 后端选择:`store.ts`,默认只向数据库启用(`CACHE_DRIVER=db`), + * 显式配置 `CACHE_DRIVER=db,kv` / `kv` / `blob` 等才会启用专用后端。 + * + * 失效:写操作(mkdir/rename/remove/move/copy/put)后调用 `invalidatePaths()`, + * 同时失效这两级缓存。 + */ +import { getCacheConfig, describeCacheConfig } from "./config" +import { clearCache, getCacheDrivers, isCacheActive } from "./store" +import { invalidateFileTree } from "./filetree" +import { invalidateLinks } from "./link" + +export { + getCacheConfig, + describeCacheConfig, + parseCacheBackends, + parseExcludeDrivers, + normalizeDriverName, + __resetCacheConfigForTest, + DEFAULT_FILE_TREE_TTL_MINUTES, + DEFAULT_LINK_TTL_MINUTES, + VALID_CACHE_BACKENDS, +} from "./config" +export type { CacheBackendName, CacheConfig } from "./config" + +export { + buildCacheKey, + cacheKindPrefix, + cacheDelete, + cacheGetEnvelope, + cacheListKeys, + cacheSetEnvelope, + clearCache, + encodeCachePathSegment, + getCacheDrivers, + isCacheActive, + storageIdFromCacheKey, + __resetCacheStoreForTest, + __setCacheDriversForTest, +} from "./store" +export type { CacheEnvelope, CacheKind } from "./store" + +export { + getCachedFileTree, + setCachedFileTree, + invalidateFileTree, + isFileTreeCacheable, + resolveFileTreeTtlMinutes, + deleteFileTreeCacheEntry, + expandToInvalidationPaths, +} from "./filetree" +export type { CacheTarget } from "./filetree" + +export { + getCachedLink, + setCachedLink, + invalidateLinks, + isLinkCacheable, + resolveLinkTtlMinutes, + deleteLinkCacheEntry, +} from "./link" + +/** + * 失效若干虚拟路径对应的**全部**缓存(文件树 + 下载链接)。 + * + * 这是写操作后统一调用的入口:调用方只需给出「被改动的路径」, + * 父目录的列表失效由 `invalidateFileTree()` 内部处理。 + */ +export async function invalidatePaths( + virtualPaths: string[], + env?: any, +): Promise { + const paths = (virtualPaths || []).filter(Boolean) + if (paths.length === 0) return + const cfg = getCacheConfig(env) + if (!cfg.enabled) return + try { + await Promise.all([ + invalidateFileTree(paths, env), + invalidateLinks(paths, env), + ]) + } catch (err: any) { + // 失效失败不影响写操作本身:缓存会随 TTL 自然过期 + console.warn(`[Cache] invalidate failed: ${err?.message || err}`) + } +} + +/** 清空某个存储的全部缓存(存储配置变更 / 删除 / 启停时调用)。 */ +export async function clearStorageCache( + storageId: any, + env?: any, +): Promise { + try { + return await clearCache(env, { storageId }) + } catch (err: any) { + console.warn(`[Cache] clearStorageCache failed: ${err?.message || err}`) + return 0 + } +} + +/** + * 缓存运行时状态(供管理接口 / 诊断展示)。 + */ +export async function getCacheRuntimeStatus(env?: any): Promise<{ + config: Record + active: boolean + drivers: string[] +}> { + const config = describeCacheConfig(env) + let drivers: string[] = [] + let active = false + try { + active = await isCacheActive(env) + drivers = (await getCacheDrivers(env)).map((d) => d.name) + } catch { + active = false + } + return { config, active, drivers } +} diff --git a/src/backend/internal/cache/link.ts b/src/backend/internal/cache/link.ts new file mode 100644 index 00000000..f12efa7d --- /dev/null +++ b/src/backend/internal/cache/link.ts @@ -0,0 +1,138 @@ +/** + * 下载链接缓存(Download Link Cache)。 + * + * 对齐参考实现 OpenList.ts 的 `file_links` 表:把驱动 `get()` 返回的直链 + * (`raw_url` + 必需的 `raw_url_headers`)缓存起来,重复下载 / 预览时无需 + * 再次向网盘换取临时链接(这一步通常最贵,且容易被限流)。 + * + * 安全边界: + * - 只缓存**驱动层**结果,签名(`?sign=`)与访问控制在 server 层按请求 + * 实时签发/校验,因此缓存不会绕过权限; + * - 只缓存「确实拿到了直链」的成功结果(`raw_url` 非空、非目录), + * 失败结果不做负缓存,避免把瞬时故障固化; + * - TTL 默认仅 5 分钟(`CACHE_LINK_TTL`)。直链通常自带有效期, + * 缓存过久会把「已失效的链接」交给浏览器(表现为 403/404)。 + */ +import type { FileItem } from "../driver/base" +import { resolvePath } from "../model/db" +import { + DEFAULT_LINK_TTL_MINUTES, + getCacheConfig, + normalizeDriverName, +} from "./config" +import { + buildCacheKey, + cacheDelete, + cacheGetEnvelope, + cacheSetEnvelope, +} from "./store" +import type { CacheTarget } from "./filetree" + +/** 下载链接缓存的 TTL(分钟)。 */ +export function resolveLinkTtlMinutes(env?: any): number { + return getCacheConfig(env).linkTtlMinutes +} + +/** + * 该目标是否可参与下载链接缓存。 + * + * 与文件树缓存共享「驱动排除名单」:纯本地计算型驱动(virtual/alias/…) + * 的 get() 本来就没有网络开销,缓存没有意义。 + */ +export function isLinkCacheable( + target: CacheTarget | null | undefined, + env?: any, +): boolean { + const cfg = getCacheConfig(env) + if (!cfg.enabled || !cfg.downloadLink) return false + if (!target?.storage || target.isVirtual) return false + const driver = normalizeDriverName(target.storage.driver) + if (cfg.excludeDrivers.has(driver)) return false + return cfg.linkTtlMinutes > 0 +} + +function keyFor(target: CacheTarget, env?: any): string { + const cfg = getCacheConfig(env) + return buildCacheKey("ln", target.storage.id, target.cleanPath, cfg.prefix) +} + +/** + * 读取下载链接缓存。未命中 / 已过期 / 异常均返回 null。 + * + * 返回的是 FileItem 的浅拷贝,避免调用方就地修改污染缓存内容。 + */ +export async function getCachedLink( + target: CacheTarget, + env?: any, +): Promise { + if (!isLinkCacheable(target, env)) return null + try { + const hit = await cacheGetEnvelope(keyFor(target, env), env) + if (!hit || !hit.v || typeof hit.v !== "object") return null + return { ...hit.v } + } catch { + return null + } +} + +/** + * 写入下载链接缓存。 + * + * 只在「拿到了可用直链」时写入:目录、`raw_url` 为空、或带有 + * `raw_url_error` 的结果都会被跳过。 + */ +export async function setCachedLink( + target: CacheTarget | null | undefined, + item: FileItem | null | undefined, + env?: any, +): Promise { + if (!target || !isLinkCacheable(target, env)) return + if (!item || item.is_dir) return + if (!item.raw_url) return + const ttlMinutes = resolveLinkTtlMinutes(env) + if (ttlMinutes <= 0) return + try { + await cacheSetEnvelope(keyFor(target, env), item, ttlMinutes * 60_000, env) + } catch { + // 缓存写入失败绝不影响业务 + } +} + +/** 删除单个路径的下载链接缓存(调用方已知 storage)。 */ +export async function deleteLinkCacheEntry( + storageId: any, + cleanPath: string, + env?: any, +): Promise { + const cfg = getCacheConfig(env) + await cacheDelete(buildCacheKey("ln", storageId, cleanPath, cfg.prefix), env) +} + +/** + * 失效若干虚拟路径对应的下载链接缓存。 + * + * 仅失效路径自身(下载链接按文件粒度缓存,不存在「父目录链接」)。 + * 路径可能是目录(如删除整个目录),此时其下所有文件的链接会随 TTL + * 自然过期——直链缓存 TTL 很短(默认 5 分钟),无需递归清理。 + */ +export async function invalidateLinks( + virtualPaths: string[], + env?: any, +): Promise { + const cfg = getCacheConfig(env) + if (!cfg.enabled || !cfg.downloadLink) return + + const keys = new Set() + for (const p of virtualPaths) { + try { + const resolved = await resolvePath(p, env) + if (resolved.isVirtual || !resolved.storage) continue + keys.add( + buildCacheKey("ln", resolved.storage.id, resolved.cleanPath, cfg.prefix), + ) + } catch { + // 路径无法解析:忽略 + } + } + for (const key of keys) await cacheDelete(key, env) +} diff --git a/src/backend/internal/cache/store.ts b/src/backend/internal/cache/store.ts new file mode 100644 index 00000000..b9d51b58 --- /dev/null +++ b/src/backend/internal/cache/store.ts @@ -0,0 +1,396 @@ +/** + * 缓存存储层:把「缓存键值」落到某个 Driver 上。 + * + * 与业务数据的关系: + * - `db` 后端复用 `getStorageBackend()` 解析出的 driver,也就是与业务数据 + * **同一个** d1 / mysql / kv / cfkv / blob / do 后端(默认行为); + * - `kv` / `blob` / `cfkv` / `do` / `memory` 是**专用**后端,只有用户在 + * `CACHE_DRIVER` 里显式写出才会启用(不会自动探测、不会自动回退); + * - 缓存写入**直接走 driver 的 get/put/delete**,不经过 `saveDb()`, + * 因此不会触发「整配置对象序列化 / 写前守卫 / 字段加密」等开销, + * 也不会污染业务数据(键名统一带 `openlist_cache` 前缀)。 + * + * 失败策略:缓存永远不能让业务请求失败。任何读/写异常都按「未命中 / 未写入」 + * 处理,并只告警一次,避免把日志刷满。 + */ +import type { Driver } from "../model/store/types" +import { getStorageBackend } from "../model/store/backend" +import { kvDriver } from "../model/store/driver/kv" +import { cfkvDriver } from "../model/store/driver/cfkv" +import { blobDriver } from "../model/store/driver/blob" +import { doDriver } from "../model/store/driver/do" +import { memoryDriver } from "../model/store/driver/memory" +import { encodeKeyPart } from "../model/store/keycodec" +import { getCacheConfig, type CacheBackendName } from "./config" + +/** 专用后端名 → 驱动实现(`db` 单独处理,见 resolveDrivers) */ +const DEDICATED_DRIVERS: Record = { + kv: kvDriver, + cfkv: cfkvDriver, + blob: blobDriver, + do: doDriver, + memory: memoryDriver, +} + +/** 缓存条目信封:值 + 过期时间戳 */ +export interface CacheEnvelope { + /** 载荷 */ + v: T + /** 写入时间(ms) */ + ts: number + /** 过期时间(ms,绝对时间) */ + exp: number +} + +/** + * 驱动解析结果缓存。 + * + * 为什么需要:`blobDriver.isAvailable()` 每次都会动态 import EdgeOne SDK, + * `kvDriver.isAvailable()` 可能发一次 HTTP 探测;而缓存配置在实例生命周期内 + * 基本不变。按 env 对象身份记忆化即可(`1 env = 1 请求`,条目随 GC 回收)。 + */ +const resolvedByEnv = new WeakMap>() + +/** 仅供测试:注入缓存驱动解析结果。 */ +let driverLoaderOverride: ((env: any) => Promise) | null = null + +export function __setCacheDriversForTest( + loader: ((env: any) => Promise) | null, +): void { + driverLoaderOverride = loader +} + +/** 仅供测试:重置模块级状态。 */ +export function __resetCacheStoreForTest(): void { + driverLoaderOverride = null + warnedDrivers.clear() +} + +/** 每个驱动只告警一次(键为 `driverName:reason`) */ +const warnedDrivers = new Map() + +function warnOnce(key: string, message: string): void { + if (warnedDrivers.has(key)) return + warnedDrivers.set(key, true) + console.warn(message) +} + +/** + * 解析实际可用的缓存驱动列表(顺序即读取优先级)。 + * + * 语义约定(与 DB_DRIVER 一致): + * - 用户显式配置了某个专用后端但它不可用 → **跳过并告警**,绝不悄悄换成 + * 别的后端;若最终一个都不剩,则缓存整体失效(请求照常走真实存储)。 + */ +async function resolveDrivers(env: any): Promise { + if (driverLoaderOverride) return driverLoaderOverride(env) + + const cfg = getCacheConfig(env) + if (!cfg.enabled) return [] + + const drivers: Driver[] = [] + for (const name of cfg.backends) { + if (name === "db") { + try { + const { driver } = await getStorageBackend(env) + drivers.push(driver) + } catch (err: any) { + warnOnce( + "db:resolve", + `[Cache] CACHE_DRIVER includes "db" but the storage backend could not be ` + + `resolved (${err?.message || err}); the cache falls back to no-op.`, + ) + } + continue + } + + const driver = DEDICATED_DRIVERS[name] + if (!driver) continue + try { + if (await driver.isAvailable(env)) { + drivers.push(driver) + } else { + warnOnce( + `${name}:unavailable`, + `[Cache] CACHE_DRIVER="${name}" is not available in this runtime ` + + `(missing binding/credentials). This cache backend is skipped; ` + + `no other backend is used in its place.`, + ) + } + } catch (err: any) { + warnOnce( + `${name}:error`, + `[Cache] CACHE_DRIVER="${name}" availability probe failed: ` + + `${err?.message || err}. This cache backend is skipped.`, + ) + } + } + return drivers +} + +/** 获取(并记忆化)当前 env 的缓存驱动列表。 */ +export async function getCacheDrivers(env?: any): Promise { + if (driverLoaderOverride) return driverLoaderOverride(env) + if (env && typeof env === "object") { + let pending = resolvedByEnv.get(env) + if (!pending) { + pending = resolveDrivers(env) + resolvedByEnv.set(env, pending) + } + return pending + } + return resolveDrivers(env) +} + +/** 缓存是否已实际启用(用于诊断接口与管理页展示)。 */ +export async function isCacheActive(env?: any): Promise { + const drivers = await getCacheDrivers(env) + return drivers.length > 0 +} + +/** 把 driver.get 的返回值(可能是 Response 形态)统一成字符串。 */ +async function coerceText(value: any): Promise { + if (value === null || value === undefined) return null + if (typeof value === "string") return value + if (typeof value?.text === "function") { + try { + return String(await value.text()) + } catch { + return null + } + } + return String(value) +} + +/** + * 读取缓存条目(自动跳过已过期条目)。 + * + * 多个后端时按配置顺序逐个尝试:任一命中即返回(先写 KV 后写 DB 的部署, + * 即使 DB 侧被清理过也仍能命中 KV)。 + */ +export async function cacheGetEnvelope( + key: string, + env?: any, +): Promise | null> { + let drivers: Driver[] + try { + drivers = await getCacheDrivers(env) + } catch { + return null + } + if (drivers.length === 0) return null + + for (const driver of drivers) { + try { + const raw = await coerceText(await driver.get(key, env)) + if (!raw) continue + const parsed = JSON.parse(raw) as CacheEnvelope + if (!parsed || typeof parsed !== "object") continue + if (typeof parsed.exp === "number" && parsed.exp <= Date.now()) { + // 惰性清理过期条目,避免缓存无限膨胀 + void cacheDelete(key, env) + continue + } + return parsed + } catch (err: any) { + warnOnce( + `${driver.name}:get`, + `[Cache] read failed on backend "${driver.name}": ${err?.message || err}`, + ) + } + } + return null +} + +/** 写入缓存条目(TTL 单位毫秒;ttl<=0 时不写入)。 */ +export async function cacheSetEnvelope( + key: string, + value: T, + ttlMs: number, + env?: any, +): Promise { + if (!Number.isFinite(ttlMs) || ttlMs <= 0) return + + let drivers: Driver[] + try { + drivers = await getCacheDrivers(env) + } catch { + return + } + if (drivers.length === 0) return + + const now = Date.now() + const envelope: CacheEnvelope = { v: value, ts: now, exp: now + ttlMs } + let raw: string + try { + raw = JSON.stringify(envelope) + } catch { + return + } + + // 双写所有后端:读路径按顺序命中,写路径尽量铺满(各自失败互不影响) + await Promise.all( + drivers.map(async (driver) => { + try { + await driver.put(key, raw, env) + } catch (err: any) { + warnOnce( + `${driver.name}:put`, + `[Cache] write failed on backend "${driver.name}": ${err?.message || err}`, + ) + } + }), + ) +} + +/** 删除缓存键(多后端全删)。 */ +export async function cacheDelete(key: string, env?: any): Promise { + let drivers: Driver[] + try { + drivers = await getCacheDrivers(env) + } catch { + return + } + if (drivers.length === 0) return + + await Promise.all( + drivers.map(async (driver) => { + try { + await driver.delete(key, env) + } catch (err: any) { + warnOnce( + `${driver.name}:delete`, + `[Cache] delete failed on backend "${driver.name}": ${err?.message || err}`, + ) + } + }), + ) +} + +/** + * 列出以 `prefix` 开头的缓存键(多后端取并集)。 + * + * 仅用于管理接口(清空 / 统计),不在请求热路径上。 + */ +export async function cacheListKeys( + prefix: string, + env?: any, +): Promise { + let drivers: Driver[] + try { + drivers = await getCacheDrivers(env) + } catch { + return [] + } + if (drivers.length === 0) return [] + + const out = new Set() + for (const driver of drivers) { + try { + for (const key of await driver.list(prefix, env)) out.add(key) + } catch (err: any) { + warnOnce( + `${driver.name}:list`, + `[Cache] list failed on backend "${driver.name}": ${err?.message || err}`, + ) + } + } + return [...out] +} + +/** 缓存类型标识:`ft` = 文件树(file tree),`ln` = 下载链接(link) */ +export type CacheKind = "ft" | "ln" + +/** 32 位 FNV-1a 哈希(同步,用于把超长路径压成短键)。 */ +function fnv1a32(input: string, seed: number): number { + let hash = seed >>> 0 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash >>> 0 +} + +/** + * 路径片段编码为 KV 合法键片段。 + * + * EdgeOne KV 只接受 `[A-Za-z0-9_]`,因此复用业务数据的 `encodeKeyPart()`。 + * 超长路径(深层目录)会被压成双 32 位哈希,避免超过 KV 键长上限 + * (Cloudflare KV 512 字节);编码是确定性的,因此失效时仍能算出同一个键。 + */ +export function encodeCachePathSegment(virtualPath: string): string { + const norm = + "/" + + String(virtualPath || "") + .split("/") + .filter(Boolean) + .join("/") + const encoded = encodeKeyPart(norm) + if (encoded.length <= 180) return encoded + const a = fnv1a32(norm, 0x811c9dc5).toString(16).padStart(8, "0") + const b = fnv1a32(norm, 0x9e3779b9).toString(16).padStart(8, "0") + return `h${a}${b}` +} + +/** + * 构造缓存键:`___`。 + * + * 键名分四段,第 4 段(索引 3)是存储 id,便于「按存储清空」时按段过滤, + * 避免 `storages_1_` 前缀误伤 `storages_10_` 这类包含关系。 + */ +export function buildCacheKey( + kind: CacheKind, + storageId: any, + virtualPath: string, + prefix: string, +): string { + return `${prefix}_${kind}_${encodeKeyPart(String(storageId ?? ""))}_${encodeCachePathSegment( + virtualPath, + )}` +} + +/** 某个 kind 在全部存储下的键前缀。 */ +export function cacheKindPrefix(kind: CacheKind, prefix: string): string { + return `${prefix}_${kind}_` +} + +/** + * 从缓存键中解析出所属存储 id(用于按存储过滤)。 + * + * 段数由前缀决定(前缀本身可能含 `_`),因此按前缀的段数动态定位, + * 不做硬编码索引。 + */ +export function storageIdFromCacheKey( + key: string, + kind: CacheKind, + prefix: string, +): string | null { + const head = `${prefix}_${kind}_` + if (!key.startsWith(head)) return null + const rest = key.slice(head.length) + const sep = rest.indexOf("_") + return sep === -1 ? rest : rest.slice(0, sep) +} + +/** 清空全部缓存(或仅某一 kind / 某一存储),返回删除的键数量。 */ +export async function clearCache( + env: any, + opts: { kind?: CacheKind; storageId?: any } = {}, +): Promise { + const cfg = getCacheConfig(env) + const kinds: CacheKind[] = opts.kind ? [opts.kind] : ["ft", "ln"] + let removed = 0 + + for (const kind of kinds) { + const keys = await cacheListKeys(cacheKindPrefix(kind, cfg.prefix), env) + for (const key of keys) { + if (opts.storageId !== undefined && opts.storageId !== null) { + const id = storageIdFromCacheKey(key, kind, cfg.prefix) + if (id !== String(opts.storageId)) continue + } + await cacheDelete(key, env) + removed++ + } + } + return removed +} diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index d2900a96..0d2af931 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -2,6 +2,14 @@ import { resolvePath, getDb, getSettings, saveDb } from "../model/db" import { encodeDownloadPath } from "../../pkg/path" import { canUseProxyEndpoint, normalizeExtList } from "../driver/proxy" import { FileItem, StorageDriver, calcFileType } from "../driver/base" +import { + getCachedFileTree, + setCachedFileTree, + getCachedLink, + setCachedLink, + invalidatePaths, + type CacheTarget, +} from "../cache" import { Onedrive } from "../../drivers/onedrive/driver" import { OnedriveAPP } from "../../drivers/onedrive_app/driver" import { AliyundriveOpen } from "../../drivers/aliyundrive_open/driver" @@ -1330,6 +1338,20 @@ export async function flushPendingDriverState( await scheduleStoragePersistence(requestContext?.waitUntil, persistence) } +/** + * 由 `resolvePath()` 结果构造缓存目标;虚拟路径(无存储)返回 null, + * 表示不参与缓存。 + */ +function toCacheTarget(resolved: any): CacheTarget | null { + if (!resolved?.storage) return null + return { + storage: resolved.storage, + cleanPath: resolved.cleanPath, + physical: resolved.physical, + isVirtual: resolved.isVirtual, + } +} + export async function listItems( virtualPath: string, requestContext?: StorageRequestContext, @@ -1340,17 +1362,32 @@ export async function listItems( if (resolved.storage) { driverName = resolved.storage.driver + const cacheTarget = toCacheTarget(resolved) try { - const driver = await getDriver(driverName, resolved.storage) - // Get raw items from driver - try { - items = await driver.list(virtualPath, resolved.physical!) - } finally { - await flushPendingDriverState( - driverName, - resolved.storage, - driver, - requestContext, + // 文件树缓存:命中则完全跳过驱动(对齐 OpenList.ts「浏览只读缓存」)。 + // 注意缓存的是驱动层原始结果,权限/meta/签名仍在 server 层按请求计算。 + const cached = await getCachedFileTree(cacheTarget!, requestContext?.env) + if (cached) { + items = cached + } else { + const driver = await getDriver(driverName, resolved.storage) + // Get raw items from driver + try { + items = await driver.list(virtualPath, resolved.physical!) + } finally { + await flushPendingDriverState( + driverName, + resolved.storage, + driver, + requestContext, + ) + } + // 回源成功后写入缓存(空目录同样缓存,避免反复击穿存储)。 + // 传副本:后续 mount 合并 / type 计算会就地修改 items。 + await setCachedFileTree( + cacheTarget!, + items.map((item) => ({ ...item })), + requestContext?.env, ) } if (resolved.storage.status !== "work") { @@ -1504,17 +1541,25 @@ export async function getItem( } const driverName = resolved.storage ? resolved.storage.driver : "Local" - const driver = await getDriver(driverName, resolved.storage) - let item: FileItem - try { - item = await driver.get(virtualPath, resolved.physical!) - } finally { - await flushPendingDriverState( - driverName, - resolved.storage, - driver, - requestContext, - ) + const cacheTarget = toCacheTarget(resolved) + // 下载链接缓存:命中则直接复用上次换取到的直链与元数据, + // 跳过驱动 get()(网盘换链通常是最贵、最易被限流的一步)。 + let item: FileItem | null = cacheTarget + ? await getCachedLink(cacheTarget, requestContext?.env) + : null + if (!item) { + const driver = await getDriver(driverName, resolved.storage) + try { + item = await driver.get(virtualPath, resolved.physical!) + } finally { + await flushPendingDriverState( + driverName, + resolved.storage, + driver, + requestContext, + ) + } + await setCachedLink(cacheTarget, item, requestContext?.env) } if (!item.type) { item.type = calcFileType(item.name, item.is_dir) @@ -1549,6 +1594,8 @@ export async function makeDirectory( requestContext, ) } + // 新建目录:父目录列表已变化(目录自身也为空列表) + await invalidatePaths([virtualPath], requestContext?.env) } export async function renameItem( @@ -1571,6 +1618,10 @@ export async function renameItem( requestContext, ) } + // 重命名:旧路径与新路径都要失效(父目录列表同样变化) + const parent = virtualPath.slice(0, virtualPath.lastIndexOf("/")) || "/" + const newVirtualPath = `${parent === "/" ? "" : parent}/${newName}` + await invalidatePaths([virtualPath, newVirtualPath], requestContext?.env) } export async function removeItems( @@ -1595,6 +1646,8 @@ export async function removeItems( requestContext, ) } + // 删除:被删路径与其父目录缓存失效 + await invalidatePaths([itemVirtual], requestContext?.env) } } @@ -1633,6 +1686,8 @@ export async function moveItems( requestContext, ) } + // 移动:源路径与目标路径(含各自父目录)缓存全部失效 + await invalidatePaths([srcVirtual, dstVirtual], requestContext?.env) } } @@ -1671,6 +1726,8 @@ export async function copyItems( requestContext, ) } + // 复制:目标路径(含父目录)缓存失效 + await invalidatePaths([dstVirtual], requestContext?.env) } } @@ -1694,4 +1751,6 @@ export async function putItem( requestContext, ) } + // 上传/覆盖:该文件的旧直链与父目录列表缓存失效 + await invalidatePaths([virtualPath], requestContext?.env) } diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index a23583a5..18f48bf5 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -17,6 +17,14 @@ import { driverPreferProxy, normalizeDriverName, } from "../internal/driver/proxy" +import { + cacheKindPrefix, + cacheListKeys, + clearCache, + clearStorageCache, + getCacheRuntimeStatus, + type CacheKind, +} from "../internal/cache" export const adminRouter = new Hono() @@ -428,6 +436,9 @@ adminRouter.post("/storage/update", async (c) => { } db.storages[idx] = updatedStorage await saveDb(db, c.env) + // 存储配置已变更(挂载路径 / root_folder_path / addition / 缓存策略等), + // 既有缓存全部失效,避免继续返回旧挂载点或旧凭据下的结果。 + await clearStorageCache(updatedStorage.id, c.env) } return c.json({ code: 200, message: "success", data: null }) }) @@ -437,6 +448,9 @@ adminRouter.post("/storage/delete", async (c) => { const db = await getDb(c.env) db.storages = db.storages.filter((s: any) => s.id !== id) await saveDb(db, c.env) + // 存储已删除:其文件树 / 下载链接缓存必须一并清理,否则 id 复用时 + // 新存储会读到旧存储的缓存内容。 + await clearStorageCache(id, c.env) return c.json({ code: 200, message: "success", data: null }) }) @@ -448,6 +462,7 @@ adminRouter.post("/storage/enable", async (c) => { s.disabled = false s.modified = new Date().toISOString() await saveDb(db, c.env) + await clearStorageCache(id, c.env) // 异步初始化驱动(不阻塞响应):启用多个云盘时立即返回, // 初始化完成后更新状态;失败时把错误写入 status,下次访问或 // 重新加载时会再次尝试。 @@ -481,6 +496,7 @@ adminRouter.post("/storage/disable", async (c) => { if (s) { s.disabled = true await saveDb(db, c.env) + await clearStorageCache(id, c.env) } return c.json({ code: 200, message: "success", data: null }) }) @@ -4643,6 +4659,117 @@ adminRouter.get("/kv/status", async (c) => { }) }) +// --- 缓存管理(文件树缓存 / 下载链接缓存)--- +// +// 默认只向数据库启用(CACHE_DRIVER=db);想要改用 / 额外启用 KV、Blob 等 +// 专用后端,需显式配置 CACHE_DRIVER(如 `kv`、`db,kv`、`blob`)。 +// 这里的接口只做「查看状态 / 清空」,不改变后端选择(后端只认环境变量)。 + +/** 缓存状态:生效配置 + 实际后端 + 当前条目数。 */ +adminRouter.get("/cache/status", async (c) => { + const runtime = await getCacheRuntimeStatus(c.env) + let fileTree = 0 + let downloadLink = 0 + try { + const [ftKeys, lnKeys] = await Promise.all([ + cacheListKeys(cacheKindPrefix("ft", runtime.config.prefix), c.env), + cacheListKeys(cacheKindPrefix("ln", runtime.config.prefix), c.env), + ]) + fileTree = ftKeys.length + downloadLink = lnKeys.length + } catch (err: any) { + return c.json({ + code: 200, + message: "success", + data: { + ...runtime, + entries: null, + entries_error: safeErrorMessage(err), + }, + }) + } + return c.json({ + code: 200, + message: "success", + data: { + ...runtime, + entries: { + file_tree: fileTree, + download_link: downloadLink, + total: fileTree + downloadLink, + }, + }, + }) +}) + +/** + * 清空缓存。 + * body: { type?: "file_tree" | "download_link" | "all", storage_id?: number } + * 省略 type 表示两级缓存全清;省略 storage_id 表示所有存储。 + */ +adminRouter.post("/cache/clear", async (c) => { + const body = await c.req.json().catch(() => ({})) + const rawType = String(body.type ?? c.req.query("type") ?? "") + .trim() + .toLowerCase() + const kind: CacheKind | undefined = + rawType === "file_tree" || rawType === "ft" || rawType === "tree" + ? "ft" + : rawType === "download_link" || rawType === "link" || rawType === "ln" + ? "ln" + : undefined + + const idRaw = body.storage_id ?? c.req.query("storage_id") + const hasId = idRaw !== undefined && idRaw !== null && String(idRaw) !== "" + + const removed = await clearCache(c.env, { + ...(kind ? { kind } : {}), + ...(hasId ? { storageId: idRaw } : {}), + }) + return c.json({ + code: 200, + message: "success", + data: { removed, type: kind ?? "all", storage_id: hasId ? idRaw : null }, + }) +}) + +/** + * 刷新文件树缓存(对齐参考实现 OpenList.ts 的 POST /api/admin/storage/refresh)。 + * body/query 可选 `id`:只刷新该存储。 + */ +adminRouter.post("/storage/refresh", async (c) => { + const body = await c.req.json().catch(() => ({})) + const idRaw = body.id ?? c.req.query("id") + const hasId = idRaw !== undefined && idRaw !== null && String(idRaw) !== "" + const removed = await clearCache(c.env, { + kind: "ft", + ...(hasId ? { storageId: idRaw } : {}), + }) + return c.json({ + code: 200, + message: "success", + data: { removed, storage_id: hasId ? idRaw : null }, + }) +}) + +/** + * 刷新单个存储的文件树缓存 + * (对齐参考实现 OpenList.ts 的 POST /api/admin/storage/refresh_one?id=)。 + */ +adminRouter.post("/storage/refresh_one", async (c) => { + const body = await c.req.json().catch(() => ({})) + const idRaw = c.req.query("id") ?? body.id + if (idRaw === undefined || idRaw === null || String(idRaw) === "") { + return c.json({ code: 400, message: "id is required", data: null }, 400) + } + const removed = await clearCache(c.env, { kind: "ft", storageId: idRaw }) + return c.json({ + code: 200, + message: "success", + data: { removed, storage_id: idRaw }, + }) +}) + // --- Search Index / Scan --- // Serverless 环境无常驻后台任务,索引构建为「请求内同步遍历 + KV 缓存」。 // 对大型存储可能耗时较长(受 Worker CPU 时限约束),但中小型部署可用; diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 9016c823..0f3b9545 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -17,6 +17,11 @@ import { readFormat, } from "../internal/model/store/backend" import { setUserPassword } from "../pkg/password" +import { + describeCacheConfig, + getCacheDrivers, + isCacheActive, +} from "../internal/cache" // 脱敏 / 截断 / 摘要 / 建议组装:与全局 503 拦截(index.ts)共用同一套规则 import { reasonLines, @@ -279,6 +284,19 @@ publicRouter.get("/env_check", async (c) => { // 但不再因此阻断向导。 const ready = storageAvailable + // ── 缓存状态(文件树 / 下载链接)── + // 默认只向数据库启用(CACHE_DRIVER=db);配置了 KV/Blob 等专用后端时 + // 这里会如实回显实际生效的后端列表,便于排查「以为缓存在 KV」的错觉。 + const cacheConfig = describeCacheConfig(env) + let cacheActive = false + let cacheDrivers: string[] = [] + try { + cacheActive = await isCacheActive(env) + cacheDrivers = (await getCacheDrivers(env)).map((d) => d.name) + } catch { + cacheActive = false + } + return c.json({ code: 200, message: "success", @@ -287,6 +305,13 @@ publicRouter.get("/env_check", async (c) => { serverless, platform: storage?.platform ?? null, }, + cache: { + ...cacheConfig, + /** 是否已有可用缓存后端(false 时缓存自动降级为不缓存) */ + active: cacheActive, + /** 实际解析出的后端驱动名(`db` 会展开成 d1/kv/blob/... ) */ + resolved_backends: cacheDrivers, + }, config: { // 配置值(用户显式设置,或默认值) db_format: formatCfg, diff --git a/src/backend/server/raw.ts b/src/backend/server/raw.ts index f1894ffa..3fe7796c 100644 --- a/src/backend/server/raw.ts +++ b/src/backend/server/raw.ts @@ -3,6 +3,11 @@ import { getSettings, resolvePath } from "../internal/model/db" import { parseRangeHeader } from "../internal/stream/stream" import { flushPendingDriverState, getDriver } from "../internal/op/storage" import { resolveShare } from "../internal/op/share" +import { + getCachedLink, + setCachedLink, + type CacheTarget, +} from "../internal/cache" import { needDownloadSign, verifyDownloadSign, @@ -444,7 +449,7 @@ rawRouter.get("/*", async (c) => { } } - const resolved = await resolvePath(reqPath) + const resolved = await resolvePath(reqPath, c.env) if (resolved.isVirtual || !resolved.physical) { return c.text("Cannot download virtual directory path", 400) @@ -496,20 +501,31 @@ rawRouter.get("/*", async (c) => { // 管理员配置的受信存储 endpoint host(可能是内网自建 S3/WebDAV/MinIO), // 加上全局环境变量 SSRF_ALLOWED_HOSTS,合并为 SSRF 白名单,避免被误拦截。 const trustedHosts = getTrustedHosts(resolved.storage.addition, c.env) - const driver = await getDriver( - resolved.storage.driver, - resolved.storage, - ) - let fileItem - try { - fileItem = await driver.get(reqPath, resolved.physical) - } finally { - await flushPendingDriverState( - resolved.storage.driver, - resolved.storage, - driver, - getStorageRequestContext(c), - ) + // 下载链接缓存:与 /fs/get 共用同一份缓存(键 = storage + 虚拟路径)。 + // 命中时直接复用直链,跳过驱动换链(网盘侧通常最贵、最易被限流)。 + const cacheTarget: CacheTarget = { + storage: resolved.storage, + cleanPath: resolved.cleanPath, + physical: resolved.physical, + isVirtual: resolved.isVirtual, + } + let fileItem = await getCachedLink(cacheTarget, c.env) + // 仅在缓存未命中时才会实例化驱动(命中时 driver 保持 null, + // 见下方 createReadStream 分支的空值保护)。 + let driver: any = null + if (!fileItem) { + driver = await getDriver(resolved.storage.driver, resolved.storage) + try { + fileItem = await driver.get(reqPath, resolved.physical) + } finally { + await flushPendingDriverState( + resolved.storage.driver, + resolved.storage, + driver, + getStorageRequestContext(c), + ) + } + await setCachedLink(cacheTarget, fileItem, c.env) } if (fileItem && fileItem.raw_url) { @@ -579,7 +595,8 @@ rawRouter.get("/*", async (c) => { ) return c.redirect(fileItem.raw_url, 302) } else if ( - typeof (driver as any).createReadStream === "function" && + driver && + typeof driver.createReadStream === "function" && fileItem && !fileItem.is_dir ) { diff --git a/wrangler.jsonc b/wrangler.jsonc index a7a08f3a..d94ee768 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -75,6 +75,43 @@ // 自动生成并持久化共享密钥(JWT 签名需要)。 "DB_CIPHER": "none", + // ── 缓存(文件树缓存 / 下载链接缓存)───────────────────────────────── + // 参考 OpenList.ts 的两级持久缓存: + // 文件树缓存 - /api/fs/list|get|dirs 优先读缓存,未命中才请求网盘 + // 下载链接缓存 - /d、/p 复用驱动换取的直链,避免重复换链/签名 + // + // 默认【只向数据库启用】:缓存与业务数据走同一条存储链路(DB_DRIVER + // 解析出的 D1/KV/Blob/MySQL/DO)。想要改用或额外启用 KV、Blob 等专用 + // 后端,必须显式配置 CACHE_DRIVER(不会自动探测、不会自动回退)。 + // + // 总开关(默认 true) + "CACHE_ENABLED": "true", + // + // 缓存后端(默认 db = 与业务数据同后端): + // db - 数据库(DB_DRIVER 解析结果)★默认 + // kv - KV binding(名固定为 KV) + // blob - EdgeOne Blob SDK / ESA Blob + // cfkv - Cloudflare KV REST API(需 CF_* 三项) + // do - Durable Objects + // memory - 进程内存(仅本地调试) + // none - 关闭缓存 + // 支持逗号分隔多后端(读按顺序命中、写全部铺开),如 "db,kv" + "CACHE_DRIVER": "db", + // + // 分级开关(默认 true) + "CACHE_FILE_TREE": "true", + "CACHE_DOWNLOAD_LINK": "true", + // + // 文件树缓存时长(分钟)。0 = 跟随存储级 cache_expiration(默认 30 分钟, + // 可被该存储的 custom_cache_policies 按路径覆盖;命中 0 表示不缓存) + "CACHE_TTL": "0", + // + // 下载链接缓存时长(分钟)。直链通常自带有效期,取值需保守(默认 5) + "CACHE_LINK_TTL": "5", + // + // 不参与缓存的驱动(纯本地计算型,缓存只会变陈旧) + "CACHE_EXCLUDE_DRIVERS": "virtual,alias,url_tree,strm,chunk", + // ── 安全鉴权(推荐以 Secret 类型配置,见 .dev.vars.example)────────── // // JWT 签名密钥(>=16 字符,兼作可选的字段加密密钥与定时任务鉴权) From 12db47dac3860e199d5c905af04a0acba62670b7 Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Thu, 24 Sep 2026 12:45:24 +0800 Subject: [PATCH 2/3] chore(git): ignore .workbuddy-ai/ local assistant data The local assistant keeps its project memory under .workbuddy-ai/, which must never be committed. Ignore it next to the existing .codebuddy entry. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d80645cd..f20f13d0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ public_data .idea .test .codebuddy +.workbuddy-ai .vscode .env .env.local From fecc76a3abe0e307822253f28c171eb8b3ba36bd Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Fri, 25 Sep 2026 15:40:54 +0800 Subject: [PATCH 3/3] Delete docs/pr-cache.md --- docs/pr-cache.md | 130 ----------------------------------------------- 1 file changed, 130 deletions(-) delete mode 100644 docs/pr-cache.md diff --git a/docs/pr-cache.md b/docs/pr-cache.md deleted file mode 100644 index 05803484..00000000 --- a/docs/pr-cache.md +++ /dev/null @@ -1,130 +0,0 @@ - - -`feat(cache): implement file-tree cache and download-link cache (DB by default, KV/Blob via env)` - -## Summary / 摘要 - -参考 [Wudarensheng/OpenList.ts](https://github.com/Wudarensheng/OpenList.ts) 的两级持久缓存, -为本项目实现**文件树缓存**与**下载链接缓存**。 - -**默认只向数据库启用**:缓存与业务数据走同一条存储链路(`getStorageBackend()` 解析出的 -driver,即 `DB_DRIVER` 指向的 D1 / MySQL / KV / cfkv / Blob / DO),因此「什么都不配」 -就等于「用数据库做缓存」。想要改用 / 额外启用 KV、Blob 等专用后端,**必须显式添加 -环境变量**(`CACHE_DRIVER`),不会自动探测、也不会自动回退——与 `DB_DRIVER` -「显式配置不回退」的既有哲学一致。 - -### 用户可感知的行为变化 - -- `/api/fs/list`、`/api/fs/get`、`/api/fs/dirs` 浏览目录时优先命中缓存, - 不再每次都请求网盘 / 对象存储; -- `/d`、`/p` 等下载链路复用驱动换取的直链,重复下载 / 预览不再重新换链; -- 写操作(mkdir / rename / remove / move / copy / put)与存储配置变更后缓存立即失效; -- 默认配置下**行为与之前一致**,只是更快、对上游更友好(缓存落在同一个数据库后端)。 - -### 重要实现变化 - -- 新增 `src/backend/internal/cache/`:`config.ts`(环境变量)/ `store.ts`(后端解析与键编码)/ - `filetree.ts`(文件树缓存)/ `link.ts`(下载链接缓存)/ `index.ts`(统一入口与失效); -- 缓存写入**直接调用 driver 的 `get/put/delete/list`**,不经过 `saveDb()`: - 不触发整配置对象序列化 / 写前守卫 / 字段加密,也不会污染业务数据 - (键名统一带 `openlist_cache` 前缀,与 `openlist_config`、`users_1` 等隔离); -- 缓存只保存**驱动层结果**(原始 FileItem 列表、直链),权限 / meta 密码 / - 隐藏规则 / 下载签名仍在 `server/fs.ts`、`server/raw.ts` 按请求实时计算, - 因此**缓存不会造成越权**; -- 缓存失败(后端不可用、序列化失败等)一律降级为「不缓存」,绝不影响业务请求。 - -### 配置 / 存储 / API 变化 - -新增环境变量(默认值即「只向数据库启用」): - -| 变量 | 默认值 | 说明 | -| :-- | :-- | :-- | -| `CACHE_ENABLED` | `true` | 总开关 | -| `CACHE_DRIVER` | `db` | 后端列表,逗号分隔:`db` / `kv` / `blob` / `cfkv` / `do` / `memory` / `none` | -| `CACHE_FILE_TREE` | `true` | 文件树缓存开关 | -| `CACHE_DOWNLOAD_LINK` | `true` | 下载链接缓存开关 | -| `CACHE_TTL` | `0` | 文件树缓存时长(分钟);`0` = 跟随存储级 `cache_expiration` | -| `CACHE_LINK_TTL` | `5` | 下载链接缓存时长(分钟) | -| `CACHE_EXCLUDE_DRIVERS` | `virtual,alias,url_tree,strm,chunk` | 不参与缓存的驱动 | -| `CACHE_PREFIX` | `openlist_cache` | 缓存键前缀 | - -新增管理接口: - -- `GET /api/admin/cache/status` —— 生效配置、实际后端、各级条目数 -- `POST /api/admin/cache/clear` —— body `{ type?: "file_tree" | "download_link", storage_id?: number }` -- `POST /api/admin/storage/refresh` —— 刷新全部文件树缓存(对齐 OpenList.ts) -- `POST /api/admin/storage/refresh_one?id=` —— 刷新单个存储(对齐 OpenList.ts) - -`/api/public/env_check` 新增 `cache` 字段,回显配置与实际生效后端。 - -## Testing / 测试 - -```bash -npm run test:cache # 新增:缓存模块回归测试(配置 / 键编码 / 两级缓存 / 清理) -npm run lint # tsc --noEmit -``` - -`src/backend/internal/cache/cache.test.ts` 通过 `__setCacheDriversForTest()` 注入内存假驱动, -不依赖任何真实存储后端,锁定: - -1. 默认只向数据库启用(`CACHE_DRIVER` 缺省 → `db`),KV/Blob 必须显式配置; -2. 各开关语义(`CACHE_ENABLED` / `CACHE_FILE_TREE` / `CACHE_DOWNLOAD_LINK`); -3. 键名只含 `[A-Za-z0-9_]`(EdgeOne KV 约束),超长路径被确定性压缩; -4. 缓存命中返回副本,避免调用方就地修改污染缓存; -5. 过期条目视为未命中并被惰性清理; -6. 下载链接只缓存「确实拿到直链的文件」(目录 / 空直链不缓存); -7. `clearCache` 按存储 id 过滤,不误伤相邻 id。 - -## Checklist / 检查清单 - -- [x] 遵循 Conventional Commits 的 PR 标题 -- [x] 新增/修改代码通过 `npm run lint`(tsc --noEmit) -- [x] 新增单元测试并通过 -- [x] 更新 README 环境变量文档与 `package.json` 的 Cloudflare bindings 说明 -- [x] 不引入破坏性变更(默认配置行为不变) - -## Implementation Notes / 实现说明 - -### 为什么默认是 `db` - -`db` 后端复用 `getStorageBackend()` 的解析结果,因此缓存与业务数据**同后端**: -- 用户不需要额外配置任何东西; -- 不需要额外的绑定,也就不存在「部署了但缓存后端不可用」的隐性故障; -- 单一后端不会出现「业务数据在 D1、缓存在 KV」这类难以排查的分裂。 - -### 多后端语义 - -`CACHE_DRIVER=db,kv` 时:**读按顺序命中,写全部铺开**。任一后端不可用时 -跳过并告警一次(`warnOnce`),绝不静默替换成别的后端;若最终一个都不剩, -缓存整体降级为 no-op。 - -### TTL 与「不缓存」 - -- 文件树 TTL 优先级:`CACHE_TTL`(>0 时覆盖)→ 存储级 `cache_expiration` - (可被 `custom_cache_policies` 按路径覆盖)→ 默认 30 分钟; -- 存储级 `cache_expiration=0` 或路径级策略命中 `0` 时,该目录**永不缓存** - (与 Go 的语义一致); -- 下载链接 TTL 独立(默认 5 分钟):直链通常自带有效期,缓存过久会把 - 「已失效的链接」交给浏览器。 - -### 失效策略 - -- 写操作:失效「被改动路径」+「其父目录」的文件树缓存,以及被改动路径的链接缓存; -- 存储更新 / 启用 / 禁用 / 删除:清空该存储的全部缓存(避免 id 复用时读到旧内容); -- 过期条目在读路径上惰性清理,避免缓存无限膨胀。 - -### 已知限制 - -- 链接缓存的 TTL 是**静态**的,不解析直链里 `Expires` 参数:TTL 设置过大时 - 可能把已失效的链接交给浏览器(表现为 403/404)。保守默认 5 分钟, - 必要时可设 `CACHE_LINK_TTL=0` 关闭链接缓存; -- 删除目录时只失效该目录自身的缓存,其下文件的链接缓存随 TTL 自然过期 - (链接 TTL 很短,不做递归清理以避免大量删除操作); -- 纯本地计算型驱动(`virtual` / `alias` / `url_tree` / `strm` / `chunk`) - 默认不缓存:没有远程 IO,缓存只会带来陈旧。