---
url: /docs/agentbuff-stack/files-storage.md
---
# 私有文件存储

**当前状态：文件 API 与定时清理已通过本地真实 Workers 验收，工作台已接入主预览。** 两个干净生成站已有文件与隔离验收；本轮补齐 320 / 390 像素、浅深主题、键盘预览 / 取消 / 重试以及永久删除的实际浏览器流程。云端 R2 绑定、定时触发与生产验收尚未完成。

## 配置与数据

`packages/core/website.ts` 的 `storage` 控制产品策略，`apps/api/wrangler.jsonc` 的 `STORAGE` 绑定提供私有 R2 桶。它们分别表示“允许怎样使用”和“实际存在哪里”。这里没有公开桶地址，也不接受客户端传入对象路径或用户 ID。

| 配置               | 默认值 | 作用                                        |
| ------------------ | ------ | ------------------------------------------- |
| `enabled`          | `true` | 关闭后文件 API 拒绝访问；不自动删除现有数据 |
| `maxFileBytes`     | 5 MiB  | 单文件上限；可配置 1 字节至 16 MiB          |
| `maxFiles`         | 25     | 每账号保留文件数量；可配置 1 至 1000        |
| `maxTotalBytes`    | 50 MiB | 每账号累计字节上限；不能小于单文件上限      |
| `retentionDays`    | 7      | 上传时确定有效期；可配置 1 至 365 天        |
| `uploadsPerMinute` | 10     | 上传与删除共用的每账号每分钟写入次数        |
| `readsPerMinute`   | 120    | 列表、详情与下载共用的每账号每分钟读取次数  |

这些是模板的产品限制，不是 Cloudflare 免费额度。降低上限不会删除已有文件；修改保留天数只影响新上传文件。过期后立即拒绝读取，物理删除由后续清理批次完成。

增量迁移 `0002_private_files.sql` 增加三张表：`user_file` 保存所有权、摘要、状态与有效期；`file_quota` 保存预留数量和字节；`operation_limit` 保存账号操作窗口。既有迁移保持不变。普通预览需要先核实自己的本地数据库目标，再按[迁移教程](../database/migrations)应用迁移；代码更新不会自动迁移预览或共享数据库。

`0003_file_cleanup.sql` 增加 `file_maintenance` 扫描游标 / 租约和 `file_cleanup_run` 批次记录，文件模块运行需要两项增量迁移。

## 使用文件工作台

登录产品后打开 `/files`，主预览地址为 <http://localhost:4410/files>。侧栏的 Files 是个人文件入口，切换团队不会改变文件归属。文件能力关闭时隐藏导航，直接打开页面仍明确提示不可用；私有路由不会进入公开站点的搜索索引。

1. 点击 Choose a file 选择文件；页面显示名称和实际大小。空文件、超出配置上限的文件不能提交，服务端仍会重新验证内容和大小。
2. 点击 Upload file 上传。发送进度来自实际传输字节；100% 表示传输完成，页面继续等待存储确认，只有服务端返回 `ready` 才显示成功。
3. 在 Your library 查看有效期、状态与个人配额。View 打开会话授权预览，Download 下载原始字节；图片使用临时对象 URL，关闭预览后释放。文本和 JSON 按纯文本显示，最多预览 64 KiB，下载保留完整内容。
4. Delete 打开永久删除确认框，可用 Keep file 或 Escape 取消。删除失败显示原因并保留记录，只有服务端确认删除后才显示存储释放。
5. Refresh 更新当前状态；超过 20 条时使用 Load more files 翻页。处于保存或删除中的记录会自动轮询，失败和到期文件禁止查看 / 下载。

键盘可用回车打开预览或删除确认，Escape 关闭预览 / 取消未执行的删除，焦点回到原操作按钮。删除请求未结束时不能取消确认；失败后焦点回到重试按钮，成功后回到选择文件。列表刷新较慢时，等待状态结束后也恢复焦点。任务详情使用同一个私有预览组件，关闭后回到对应附件的预览按钮。

断网或超时可能发生在服务端已保存之后。工作台不会自动重传文件；先刷新列表确认，再决定是否重试，避免重复占用配额。登录过期交给已有认证恢复页面处理，不继续显示旧账号预览。配额包括尚未完成确认或删除的记录，页面也会解释这部分占用。

浏览器原件下载已核对 SHA-256，伪图片拒绝、超限提示、长文本限制与取消确认已实测。两个新生成站已有上传 / 查看 / 下载、实际 API 删除、定时清理和隔离验收；本轮另在隔离测试账号完成手机上传、长文件名、原件下载、实际图片解码、永久删除与任务占用拒绝。任务取消后在原失败弹窗用键盘重试成功；结束核对实际数据库、配额及 R2 对象全部归零。四种尺寸 / 主题组合均无横向溢出，确认弹窗在视口内；这是本地浏览器证据，不能替代真机、云端资源与生产验收。

## 上传与下载接口

| 接口 | 用途 |
| --- | --- |
| `POST /api/files` | 直接发送文件二进制；不是表单上传 |
| `GET /api/files/:id/content` | 会话授权读取；加 `?download=1` 强制下载 |
| tRPC `files.list` | 每页 20 条，使用返回的 `nextCursor` 翻页；同时返回配额与策略 |
| tRPC `files.get` | 当前账号的文件元数据 |
| tRPC `files.remove` | 删除当前账号文件并释放配额 |

上传请求使用当前账号 cookie，`Origin` 必须等于 `APP_ORIGIN`；`X-File-Name` 填写经过 `encodeURIComponent` 编码的文件名，`Content-Type` 填写真实类型。服务端按实际读入字节执行大小限制，再检查内容。当前支持 PNG、JPEG、UTF-8 文本与 JSON，不支持 PDF、SVG 或其他格式。

PNG / JPEG 检查签名与必要结构，不代表完整图片解码或恶意内容扫描。文本拒绝非法 UTF-8 与不允许的控制字符；声明为 JSON 的输入必须能解析。摘要基于原始字节计算，下载不会改变内容。名字会移除路径和控制字符，不作为对象路径使用。

每次请求都重新核实账号及文件所有权。匿名会话不可使用文件能力；切换组织不会把个人文件变成团队文件。其他账号访问文件返回不存在，列表只包含自己的记录。JSON 默认作为附件；下载响应使用私有且不可缓存的策略，以及禁止内容嗅探和限制执行的响应头。

## 状态与失败处理

上传先在数据库事务中预留数量和字节，再写入 R2，最后标记 `ready`。`uploading` 只表示正在写入；`deleting` 表示删除尚未完成；`failed` 保存故障代码。下载只接受未过期的 `ready` 文件。

数据库与 R2 无法共用一个事务。上传失败会尝试删除对象，只有确认对象删除后才释放配额；补偿失败保留记录与占用，等待后续清理重试。删除失败也保留占用，重复删除不会重复减额度。读取发现对象不存在时记录故障并只释放一次配额。不要把失败记录当成已经物理清理。

## 定时清理与故障排查

实际 `worker.ts` 的 `scheduled` 入口调用 `lib/files/cleanup.ts`，使用新鲜数据库连接。当前 `dev` 配置为每 15 分钟一次；云端绑定和调度仍待独立部署验收。本地 Miniflare 不自动执行这个计划，验收命令显式调用真正的定时入口，普通本地预览暂不自动清理。

每批最多处理 20 条到期、失败、删除中或超过一小时未完成的上传记录，再扫描当前站点前缀下最多 100 个对象。游标保存在数据库，下一批继续翻页；扫描完重新开始。不属于本站前缀的对象不会被删除。无元数据的对象保留至少一小时后再删除，包括账号级联删除或上传进程中断后的遗留对象。

批次使用两分钟租约避免常规重叠；过期租约可重新领取。旧执行器不能覆盖新租约的游标，重复删除不会重复释放配额。超时上传清理后，迟到上传不能重新标记为可下载；没有完成补偿的迟到对象由后续扫描回收。

`file_cleanup_run` 保存开始 / 完成时间、文件删除数、扫描数、孤立对象删除数、故障数和稳定故障代码，保留 30 天；不保存内容和凭据。未完成且租约已到期的记录表示执行可能中断，下一批重新核对。文件本身保留 `DELETE_FAILED` 等原因；失败记录更新时间用于后续重试，避免只反复处理第一批文件。扫描页删除失败时保留该页游标，重新扫描。

清理失败会记录批次并使定时调用报告失败。检查批次记录、文件状态及仅含记录 ID 的日志，再核对 R2 与数据库；恢复服务后下一次调用继续处理。管理员查看界面尚待 I9。云端清理延迟与调用成本仍需实测，不承诺到期瞬间完成物理删除。

| 错误代码 | 排查方向 |
| --- | --- |
| `UNAUTHORIZED` | 登录状态失效，或当前是匿名账号 |
| `DISABLED` / `NOT_CONFIGURED` | 检查配置开关和实际 `STORAGE` 绑定 |
| `BAD_FILE` / `TOO_LARGE` | 检查内容、声明类型、文件名和大小 |
| `QUOTA_EXCEEDED` | 检查数量、字节，以及未完成补偿的记录 |
| `RATE_LIMITED` | 等待操作窗口结束；HTTP 文件路由返回 `Retry-After` |
| `NOT_FOUND` / `FILE_NOT_READY` | 检查账号、文件 ID 和处理状态 |
| `EXPIRED` / `MISSING_OBJECT` | 文件已到期或实际对象不存在 |
| `STORAGE_UNAVAILABLE` | 检查本地资源服务、R2 与元数据写入；重试删除或保留故障记录 |

## 本地验收

```sh
bun run files:validate
```

命令要求开发环境与 loopback PostgreSQL，并需要该本地数据库用户具备创建和删除测试数据库的权限。它创建独立临时数据库，应用真实迁移，启动实际 Worker bundle 与隔离 R2，通过两个账号验证四种文件类型、下载字节、摘要、安全响应头、权限拒绝、到期拒绝和删除；两次触发定时清理，检查实际删除、配额只释放一次及批次记录；结束后销毁测试资源。它不迁移配置中的数据库，不调用真实邮件、OAuth 或支付服务。

需要复验浏览器流程时，先构建工作台，再运行可选模式：

```sh
bun app:build
bun run files:validate -- --browser
```

原文件验收完成后，命令继续保持自己的回环测试站运行，终端输出具体 `/files` 地址和该次临时目录内的 `browser.local.json` 路径。私有记录包含合成账号、上传样本、所选一次性数据库和停止标记；不要分享记录或复制到新站。用记录里的测试账号登录，只上传该目录的合成样本，不操作主预览文件。手机测试需在实际浏览器设置尺寸并使用页面主题按钮，不能用静态截图代替操作。

完成上传、预览、下载及删除后，确认库内文件与配额归零，再在该次记录的 `stop` 路径创建文件以结束。命令会核对数据库、配额和实际 R2 对象均为空，随后关闭自己的服务、删除临时库和目录。若还有文件则报告验收失败，清理不算浏览器删除成功。此模式默认阻断外部请求，不与 `--turnstile` 同跑；任务占用用例需在这份一次性库中安排未派发的测试任务，不能改正常预览的任务状态。

`apps/api/lib/files/storage.test.ts` 与 `cleanup.test.ts` 另用实际迁移和本地 R2 验证并发数量 / 字节配额、补偿失败、删除重试、缺失对象、操作限流、迟到上传、翻页与租约边界。孤立对象测试仅调整测试返回的对象时间来检查一小时宽限，实际对象由本地 R2 写入和删除。测试范围仍为本地模拟资源；生产权限、收费和配额需要独立云端验收。
