文件、存储与分享¶
文件模块分为三层:sys_files 元数据、storage.Store 物理存储、/uploads 访问鉴权。业务表只存 URL,不直接持有文件内容。
1. 存储布局与限制¶
- 文件名随机(16 字节 hex),扩展名清洗后保留;原始文件名只存在
sys_files.name。 - 路径由
storage.Local.resolve做穿越校验,拒绝..等非法 key。 - 单文件大小:
storage.max_upload_mb(默认 20MB);HTTP 请求体硬上限 64MB(在 handler 里用MaxBytesReader)。 - 上传失败或落库失败会删除已写入的物理文件,避免产生孤儿文件。
- 多副本部署必须使用共享卷,或替换
storage.Store为对象存储(见第 7 节)。
2. 上传¶
POST /files(system:file:upload),multipart 字段 file:
curl -X POST http://127.0.0.1:8080/admin/v1/files \
-H "Authorization: Bearer $TOKEN" \
-F "file=@demo.png"
响应是文件元数据:
{
"id": 12,
"name": "demo.png",
"key": "2026/11/8f3c….png",
"url": "/uploads/2026/11/8f3c….png",
"mime": "image/png",
"ext": "png",
"size": 24567,
"uploader_id": 1,
"created_at": "2026-11-10T10:00:00+08:00"
}
key 是存储层相对路径,不参与前端展示;业务表存 url 即可。
3. 列表与筛选¶
GET /files(system:file:list):
| 参数 | 说明 |
|---|---|
keyword |
文件名模糊搜索 |
mime |
MIME 前缀,如 image/ |
kind |
大类,可逗号组合:image、video、audio、document、archive、other |
page / page_size |
分页 |
前端 FileSelect / FilePicker 的 types 参数就是这组大类;两者共享同一份 utils/file.ts 判断逻辑。
4. 下载与访问鉴权¶
4.1 API 下载¶
GET /files/:id/download(system:file:download)以流式返回,并同时设置 ASCII 与 UTF-8 文件名:
4.2 /uploads 静态访问¶
/uploads 不是公开目录,middleware.UploadAuth 支持三种凭证:
- 浏览器 Cookie:登录/刷新/
/auth/me下发的HttpOnly文件访问 Cookie,路径限定/uploads。<img>自动携带。 - Bearer:API 客户端直接带
Authorization: Bearer <access>。 - 分享令牌:
/uploads/...?share=<token>,无需登录,撤销后立即失效;带share时不会回退到登录态。
鉴权失败时返回 401,并设置 Cache-Control: no-store;响应头由 UploadSecurityHeaders 统一加 CSP sandbox、X-Content-Type-Options: nosniff,防止上传内容被当作主动页面执行。
5. 可撤销分享¶
POST /files/:id/shares(system:file:download):
expires是秒数,默认 1 小时,最小 1 分钟、最大 7 天。- 服务端生成 32 字节随机令牌,数据库只存 SHA-256 摘要,明文只在创建响应里返回一次:
GET /files/:id/shares返回历史记录(不含明文),DELETE /files/:id/shares/:shareID立即撤销。- 创建分享时会顺手清理 30 天前已过期/已撤销的记录,避免表膨胀。
- 分享令牌与文件路径必须匹配,否则返回 401。
- 删除文件时同步删除该文件的全部分享记录。
6. 删除¶
DELETE /files/:id(system:file:delete):
- 先删
sys_files元数据。 - 再删物理文件;物理文件已不存在视为成功。
- 清理该文件的分享记录。
由于元数据删除在前,即使磁盘删除失败也不会留下「可下载但无记录」的文件;反过来会产生少量孤儿文件,建议用定时任务/运维脚本定期清理 data/uploads 中不在 sys_files.key 里的文件。
7. 替换为对象存储¶
实现 internal/pkg/storage.Store:
type Store interface {
Save(ctx context.Context, filename string, r io.Reader) (Info, error)
Open(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
URL(key string) string
}
然后在 internal/bootstrap/providers.go 的 provideStorage 返回你的实现(S3/OSS/MinIO 等)。约定:
Key用对象 key,不要带 bucket。URL返回可直接访问的地址;如果对象存储需要签名,建议仍走/uploads代理或在URL()里生成短期签名。Save必须返回实际写入的Size,FileService依赖它判断超限。- 对象存储更适合多副本;本地存储只适合单机/共享卷。
8. 接口一览¶
| 方法 | 路径 | 权限 |
|---|---|---|
| GET | /files |
system:file:list |
| POST | /files |
system:file:upload |
| GET | /files/:id/download |
system:file:download |
| GET/POST | /files/:id/shares |
system:file:download |
| DELETE | /files/:id/shares/:shareID |
system:file:download |
| DELETE | /files/:id |
system:file:delete |
9. 前端组件¶
// 表单字段:value/onChange 是 URL 或 URL[]
<Form.Item name="avatar" label="头像">
<FileSelect types="image" />
</Form.Item>
// 多选,最多 8 个图片/视频
<FileSelect types={['image', 'video']} multiple limit={8} />
// 只要选择弹窗,拿完整 FileItem[]
<FilePicker
open={open}
types="image"
multiple={false}
onCancel={() => setOpen(false)}
onConfirm={(files) => save(files[0])}
/>
FileSelect只回传 URL,保持 Form 值可序列化。FilePicker内置上传入口(受system:file:upload权限控制),上传后立即选中。- 选择弹窗支持搜索、分页、跨页保留已选、按类型过滤。
10. 安全清单¶
- 生产使用 HTTPS,文件 Cookie 才会带
Secure -
storage.root不在 Web 服务器可直接列目录的位置 - 反向代理也限制
/uploads的请求体与超时 - 定期清理孤儿文件与过期分享记录
- 不要把
sys_files.key直接暴露给前端 - 上传目录不要挂载为可执行脚本目录