feat: add configurable S3 object storage

This commit is contained in:
237899745
2026-07-25 13:23:11 +08:00
parent 61fa9cb820
commit d1f093685d
29 changed files with 3703 additions and 284 deletions

View File

@@ -678,6 +678,41 @@ Authorization: Bearer <admin_token>
Content-Type: application/json
```
### 11.6 S3 存储端点
```http
GET /admin/storage/endpoints
Authorization: Bearer <admin_token>
```
```http
POST /admin/storage/endpoints
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"name": "119 S3",
"internal_endpoint": "http://10.70.0.2:3900",
"public_endpoint": "https://files.example.com",
"bucket": "imageforge-results",
"region": "garage",
"access_key": "<access-key>",
"secret_key": "<secret-key>",
"force_path_style": true,
"presign_ttl_seconds": 300
}
```
```http
PUT /admin/storage/endpoints/{endpoint_id}
POST /admin/storage/endpoints/{endpoint_id}/test
POST /admin/storage/endpoints/{endpoint_id}/activate
DELETE /admin/storage/endpoints/{endpoint_id}
Authorization: Bearer <admin_token>
```
凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。活动端点不能直接编辑或删除。
---
## 12. WebSocket网站任务进度

View File

@@ -341,7 +341,14 @@ CREATE TABLE tasks (
completed_at TIMESTAMPTZ,
-- 到期清理:匿名可默认 24h登录用户应由应用按套餐写入更长 retention
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours')
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours'),
retention_hours INTEGER NOT NULL DEFAULT 24,
zip_storage_backend VARCHAR(16),
zip_storage_endpoint_id UUID REFERENCES storage_endpoints(id) ON DELETE RESTRICT,
zip_storage_key TEXT,
zip_storage_etag TEXT,
zip_size BIGINT
);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
@@ -365,7 +372,12 @@ CREATE TABLE task_files (
compressed_size BIGINT,
saved_percent DECIMAL(6, 2),
storage_path VARCHAR(500), -- S3 key 或本地路径
storage_path VARCHAR(500), -- 兼容旧版本本地成品路径
input_path TEXT, -- 批任务原图临时路径,处理后清空
storage_backend VARCHAR(16) NOT NULL DEFAULT 'local',
storage_endpoint_id UUID REFERENCES storage_endpoints(id) ON DELETE RESTRICT,
storage_key TEXT,
storage_etag TEXT,
status file_status NOT NULL DEFAULT 'pending',
error_message TEXT,
@@ -377,6 +389,12 @@ CREATE INDEX idx_task_files_task_id ON task_files(task_id);
CREATE INDEX idx_task_files_status ON task_files(status);
```
### 4.9.1 storage_endpoints - S3 端点
端点凭据使用 `API_KEY_PEPPER` 派生密钥并通过 AES-256-GCM 加密。每个对象保存自己的 `storage_endpoint_id`,因此切换活动端点不会影响历史对象。活动端点和仍有关联对象的端点不能删除;管理端删除采用软归档,待无引用 30 天后由 Worker 清理配置。
同一时间只允许一个 `is_active = true` 的端点接收新对象。没有配置活动端点时系统使用 `STORAGE_PATH` 本地回退。
### 4.10 idempotency_keys - 幂等记录
```sql
CREATE TABLE idempotency_keys (

131
docs/s3-storage-plan.md Normal file
View File

@@ -0,0 +1,131 @@
# ImageForge 双服务器 S3 方案
> 本文是部署前方案与验收清单。本次开发未在 `118.145.177.79` 或 `119.29.142.248` 上安装、修改或启动任何服务。
## 1. 现状与结论
| 节点 | 实测资源 | 建议职责 | 关键约束 |
| --- | --- | --- | --- |
| `118.145.177.79` | 8 vCPU、31 GiB RAM、约 104 GiB 可用磁盘 | ImageForge API、Worker、PostgreSQL、Redis、ZIP 临时生成 | 已有较多容器,`9000` 端口已占用,后续建议应用只绑定 `127.0.0.1:18080` |
| `119.29.142.248` | 2 vCPU、1.9 GiB RAM、约 29 GiB 可用磁盘、200M 共享带宽 | Garage S3、Nginx/TLS、下载出口 | 只能作为首期临时对象存储;系统盘容量而不是带宽会最先成为瓶颈 |
首期选择 Garage 2.3,而不是 MinIO。Garage 面向低资源和普通互联网环境,支持本项目所需的 SigV4、Path-style、预签名 URL、对象读写删除、分片上传和生命周期。MinIO 官方单节点说明包含 2 GiB 预分配并建议 32 GiB 内存,不适合 119 当前的 2 GiB 机器。
- Garage 目标与适用场景:[Goals and use cases](https://garagehq.deuxfleurs.fr/documentation/design/goals/)
- Garage 单节点与 2.3.0 镜像:[Quick Start](https://garagehq.deuxfleurs.fr/documentation/quick-start/)
- Garage S3 兼容性:[S3 Compatibility](https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/)
- Garage 配置项:[Configuration](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/)
- MinIO 单节点资源说明:[Deploy MinIO Single-Node](https://min.io/docs/minio/container/operations/install-deploy-manage/deploy-minio-single-node-single-drive.html)
## 2. 目标架构
```mermaid
flowchart LR
U["用户浏览器"] -->|"上传、鉴权、任务查询"| A["118: ImageForge API"]
A --> P["118: PostgreSQL"]
A --> R["118: Redis"]
A --> W["118: Image Worker"]
W -->|"WireGuard / S3 PUT"| S["119: Garage S3"]
A -->|"鉴权通过后返回 307"| U
U -->|"5 分钟预签名 GET"| N["119: Nginx TLS"]
N --> S
```
核心规则:
1. Bucket 始终私有,不开放匿名读。
2. 用户先访问 ImageForge 下载接口;后端检查用户、匿名会话和过期时间后,返回 `307` 到 5 分钟预签名 URL。
3. 图片和 ZIP 的实际下载字节只经过 119118 不再承担下载出口。
4. 批处理原图只在 118 的共享 `uploads/orig` 临时保存Worker 完成或失败后删除,不上传 S3。
5. 批量 ZIP 在 118 临时生成64 MiB 以上自动使用 S3 分片上传,上传后删除临时目录。
6. 每个成品记录写入时的端点 ID。以后切换端点不会使旧文件失联旧端点凭据会保留到关联对象清空。
`/api/v1/compress/direct` 保留原有“响应体直接返回图片”的 API 语义,避免破坏现有调用方;网站下载、历史记录下载、普通压缩结果和批量 ZIP 均走 S3。
## 3. 网络与端口
建议先在两台服务器之间建立 WireGuard
| 用途 | 118 | 119 |
| --- | --- | --- |
| WireGuard 地址 | `10.70.0.1/24` | `10.70.0.2/24` |
| S3 内部 Endpoint | 客户端 | `10.70.0.2:3900` |
| Garage RPC | 后续扩容节点 | `10.70.0.2:3901` |
| 公网下载 | 不开放 | `files.<你的域名>:443` |
119 防火墙只允许:公网 `80/443`、受限来源的 `22`、WireGuard 对端访问 `3900/3901`。Garage 管理端口 `3903` 只绑定回环地址。不要把 `3900/3901/3903` 直接暴露到公网。
119 已有 Nginx 和 `wy.workyai.cn`,后续只新增独立的 `files.<你的域名>` server block不能覆盖现有站点配置。公网 Nginx 只允许 `GET/HEAD`,应用上传走 WireGuard 内部 Endpoint。
## 4. 保留与清理
| 用户层级 | 数据库精确保留 | 对象前缀 | S3 兜底生命周期 |
| --- | --- | --- | --- |
| 未登录、免费用户 | 24 小时 | `results/1d/``archives/1d/` | 3 天 |
| 低级会员 Pro | 7 天 | `results/7d/``archives/7d/` | 9 天 |
| 高级会员 Business | 15 天 | `results/15d/``archives/15d/` | 17 天 |
Worker 每 5 分钟按 `expires_at` 精确删除对象删除成功后才删除数据库任务。S3 生命周期多保留 2 天,只负责处理数据库故障、进程崩溃或上传后未能落库的孤儿对象,不能作为精确会员权限判断。未完成的分片上传 1 天后由生命周期中止。
## 5. 119 首期容量
119 当前约 29 GiB 可用建议至少给系统、Docker/Nginx 日志和 Garage 元数据保留 9 GiB因此首期只按约 20 GiB 对象容量规划:
- 70%(约 14 GiB告警检查日新增量和清理是否正常。
- 85%(约 17 GiB紧急告警停止营销放量并准备扩盘或新端点。
- 90%(约 18 GiB写入保护不要继续依赖该系统盘接收新对象。
粗略容量公式:`日均压缩后新增量 × 加权平均保留天数 × 1.2`。例如每天 1 GiB、平均保留 5 天,仅对象约 6 GiB每天 4 GiB 时就会接近首期上限。200 Mbps 理论上约 25 MB/s但共享带宽、磁盘随机读和 2 核 CPU 会使实际吞吐更低。
正式增长前优先给 119 挂载独立的 100 GiB 以上数据盘到 `/srv/garage/data`。单节点 `replication_factor = 1` 没有冗余,官方也不建议用于长期生产数据;本项目对象最长 15 天且可重新生成,低用户量阶段可以接受,但不能替代备份。后续可新增 Garage 节点并提升副本数,或在管理后台新增另一套 S3 端点并切换新对象。
## 6. 管理后台配置
部署 S3 并创建 Bucket/Key 后,在“管理后台 -> 对象存储”新增:
| 字段 | 首期建议值 |
| --- | --- |
| 名称 | `119 高带宽 S3` |
| 内部 Endpoint | `http://10.70.0.2:3900` |
| 公网 Endpoint | `https://files.<你的域名>` |
| Bucket | `imageforge-results` |
| Region | `garage` |
| Force path style | 开启 |
| 签名有效期 | `300` 秒 |
Access Key 和 Secret Key 使用项目现有 AES-256-GCM 机制加密入库,页面只显示掩码。保存后先执行“全链路测试”,再点“验证并启用”。后端激活前会再次执行 `HeadBucket + 内部 PutObject/GetObject + 公网预签名 GET + 内部 DeleteObject`。活动端点或仍有关联对象的端点不能直接编辑、归档,需先新增并启用替代端点,等待旧对象过期后再移除。
## 7. 后续部署顺序(本次不执行)
1. 为下载域名添加 DNS确认 119 的 80/443 可用,并建立 WireGuard。
2. 在 119 创建 `/srv/garage/{meta,data,snapshots}`,生成三个权限为 `0600` 的 secret 文件。
3. 使用 `docker/storage/garage.toml.example``docker-compose.storage.yml.example` 启动 Garage。
4. 创建 `imageforge-results` Bucket 和仅限该 Bucket 的应用 Key应用生命周期文件 `lifecycle.json`
5. 新增 `nginx-files.conf.example` 对应的 HTTPS 站点;确认访问日志不记录签名查询字符串。
6. 在 118 备份数据库,部署新版本并执行迁移;首次启动没有活动 S3 时仍使用本地存储。
7. 管理后台保存 119 端点,执行测试并启用。
8. 使用 JPEG、PNG透明/非透明、WebP、AVIF、GIF、BMP、TIFF、ICO以及中文名、同名文件、大图和批量 ZIP 做验收。
Garage 初始化命令示例(实际部署时执行,输出的 Secret Key 只录入管理后台,不提交仓库):
```bash
docker compose -f docker-compose.storage.yml exec garage /garage bucket create imageforge-results
docker compose -f docker-compose.storage.yml exec garage /garage key create imageforge-app
docker compose -f docker-compose.storage.yml exec garage /garage bucket allow \
--read --write --owner imageforge-results --key imageforge-app
aws --endpoint-url http://127.0.0.1:3900 \
s3api put-bucket-lifecycle-configuration \
--bucket imageforge-results \
--lifecycle-configuration file://lifecycle.json
```
## 8. 上线验收
- 管理端测试必须完成读、写、删Bucket 内不能残留健康检查对象。
- 普通下载接口先返回 `307``Location` 指向下载域名且有效期约 300 秒。
- 未授权用户无法取得签名 URL任务过期后应用下载接口返回 404。
- 单文件与批任务数据库均记录正确的 `storage_endpoint_id` 和对象键。
- 切换到第二端点后,新对象进入第二端点,第一端点历史对象仍可下载。
- S3 停止时同步请求返回 503批任务保留临时原图并重试不静默写回本地。
- 删除 S3 对象失败时,过期任务数据库记录保留并在下一轮重试。
- 119 磁盘 70%/85% 告警、容器重启、NTP 时间同步和证书续期均验证通过。