docs(health): 设计规格评审修复 — 3 CRITICAL + 5 HIGH + 关键 MEDIUM
修复项: - C1: 公开端点增加租户识别机制(X-Tenant-Id / query param) - C2: 签名 URL 增加路径规范化 + HMAC 输入格式 + is_public 实时校验 - C3: crop 端点补全权限码 health.media.manage - H2: secret_key 生产环境 panic 保护 - H3: 软删除 media_item 级联设置 banner inactive - H4: 补全 health.banners.list 权限码 - H5: 公开路由注册到 public_routes + 菜单种子迁移 - M3: 公开文章返回专用 PublicArticleListItem DTO - M4: 新增"首页推荐"分类种子迁移
This commit is contained in:
@@ -132,7 +132,8 @@
|
||||
| PUT | `/media/{id}` | 更新元数据(alt_text / folder_id / is_public),含 version 乐观锁 | `health.media.manage` |
|
||||
| DELETE | `/media/{id}` | 软删除,同时删除存储文件和缩略图 | `health.media.manage` |
|
||||
| POST | `/media/{id}/move` | 移动到指定文件夹(body: `{ folder_id, version }`) | `health.media.manage` |
|
||||
| POST | `/media/batch-delete` | 批量软删除(body: `{ ids: [] }`) | `health.media.manage` |
|
||||
| POST | `/media/batch-delete` | 批量软删除(body: `{ ids: [] }`,最大 50 条,事务内原子执行) | `health.media.manage` |
|
||||
| POST | `/media/{id}/crop` | 裁剪图片(body: `{ x, y, width, height, version }`),覆盖原文件+重新生成缩略图,version 自增 | `health.media.manage` |
|
||||
|
||||
**上传流程:**
|
||||
1. 客户端 `POST /media/upload`,multipart 包含 `file` + 可选 `folder_id` + `is_public`
|
||||
@@ -178,11 +179,15 @@
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限码 |
|
||||
|------|------|------|--------|
|
||||
| GET | `/banners` | 管理列表(含所有状态,支持 status 筛选) | `health.banners.manage` |
|
||||
| GET | `/banners` | 管理列表(含所有状态,支持 status 筛选) | `health.banners.list` |
|
||||
| POST | `/banners` | 创建轮播图 | `health.banners.manage` |
|
||||
| PUT | `/banners/{id}` | 更新,含 version 乐观锁 | `health.banners.manage` |
|
||||
| DELETE | `/banners/{id}` | 软删除 | `health.banners.manage` |
|
||||
| PUT | `/banners/sort` | 批量更新排序(body: `[{ id, sort_order }]`) | `health.banners.manage` |
|
||||
| PUT | `/banners/sort` | 批量更新排序(body: `[{ id, sort_order }]`,返回 `ApiResponse<()>`) | `health.banners.manage` |
|
||||
|
||||
**级联规则:**
|
||||
- 软删除 `media_item` 时,将关联的 banners 设为 `status = 'inactive'`
|
||||
- Banner 查询响应包含 `media_deleted: bool` 标记,UI 据此显示占位图
|
||||
|
||||
**创建/更新 DTO:**
|
||||
```json
|
||||
@@ -208,10 +213,11 @@
|
||||
|
||||
**`GET /public/banners` 逻辑:**
|
||||
1. 不需要 JWT 认证
|
||||
2. 查询条件:`status = 'active'` AND `deleted_at IS NULL` AND 当前时间在 `[start_time, end_time]` 范围内(start_time/end_time 为 NULL 时不限)
|
||||
3. 按 `sort_order ASC` 排序
|
||||
4. 返回图片 URL 时附带短时效签名 token(1 小时有效)
|
||||
5. 返回格式:
|
||||
2. **租户识别**:通过 `X-Tenant-Id` 请求头或 `?tenant_id=` 查询参数传递(公开端点无法从 JWT 提取)。小程序端将 tenant_id 编译为环境变量。
|
||||
3. 查询条件:`tenant_id = {指定租户}` AND `status = 'active'` AND `deleted_at IS NULL` AND 当前时间在 `[start_time, end_time]` 范围内(start_time/end_time 为 NULL 时不限)
|
||||
4. 按 `sort_order ASC` 排序
|
||||
5. 返回图片 URL 时附带短时效签名 token(1 小时有效)
|
||||
6. 返回格式:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
@@ -229,18 +235,20 @@
|
||||
|
||||
**`GET /public/articles` 逻辑:**
|
||||
1. 复用现有 `list_articles` service 逻辑,但跳过认证
|
||||
2. 固定筛选 `status = 'published'`
|
||||
3. 必须传 `category_id` 参数(指定"首页推荐"分类)
|
||||
4. 分页:默认 page_size=4
|
||||
5. 返回 `ArticleListItem[]`(含 cover_image)
|
||||
2. 租户识别同上(`X-Tenant-Id` 或 `?tenant_id=`)
|
||||
3. 固定筛选 `status = 'published'`
|
||||
4. 必须传 `category_id` 参数(指定"首页推荐"分类)
|
||||
5. 分页:默认 page_size=4
|
||||
6. 返回专用 `PublicArticleListItem[]`(仅含 id/title/summary/cover_image/published_at,不暴露 status/version 等内部字段)
|
||||
|
||||
### 3.5 新增权限码
|
||||
|
||||
| 权限码 | 说明 | 默认角色 |
|
||||
|--------|------|----------|
|
||||
| `health.media.list` | 查看媒体库 | admin, operator |
|
||||
| `health.media.manage` | 上传/编辑/删除媒体资源 | admin, operator |
|
||||
| `health.banners.manage` | 管理轮播图 | admin, operator |
|
||||
| `health.media.manage` | 上传/编辑/删除/裁剪媒体资源 | admin, operator |
|
||||
| `health.banners.list` | 查看轮播图列表 | admin, operator |
|
||||
| `health.banners.manage` | 创建/编辑/删除/排序轮播图 | admin, operator |
|
||||
|
||||
## 4. 公开访问机制
|
||||
|
||||
@@ -255,13 +263,19 @@
|
||||
**访问流程:**
|
||||
1. `GET /public/banners` 返回图片 URL 时,对 `is_public = true` 的资源生成签名 URL:
|
||||
- 格式:`/uploads/{tenant}/{file}?token={hmac_signature}&expires={unix_timestamp}`
|
||||
- HMAC-SHA256 签名,密钥从配置读取(`storage.secret_key`)
|
||||
- **签名输入**:`HMAC-SHA256(key, "{normalized_path}\n{expires}")`
|
||||
- `normalized_path` 经过路径遍历规范化处理(去除 `..`、连续 `/` 等),确保签名绑定到真实文件路径
|
||||
- 有效期 1 小时
|
||||
2. 对 `is_public = false` 的资源,仍通过标准 JWT 认证访问
|
||||
|
||||
**文件服务中间件改动:**
|
||||
- 现有 `upload_auth_middleware` 增加签名 URL 验证分支:
|
||||
- 请求携带 `?token=&expires=` 参数时,验证签名和过期时间
|
||||
- 请求携带 `?token=&expires=` 参数时:
|
||||
1. 解析并规范化请求路径(防止 `..` 遍历)
|
||||
2. 验证规范化路径仍以 `/uploads/{tenant_id}/` 为前缀
|
||||
3. 用规范化路径 + expires 重新计算 HMAC,与 token 参数比对
|
||||
4. 检查 expires 未过期
|
||||
5. 查询数据库确认该 `media_item.is_public = true`(防止从公开改为私有后签名仍有效)
|
||||
- 验证通过则放行,不通过返回 401
|
||||
- 不携带签名参数时,走原有 JWT 认证逻辑
|
||||
|
||||
@@ -269,7 +283,8 @@
|
||||
- 签名密钥不暴露给客户端,仅后端生成和验证
|
||||
- 签名 URL 有时效性,过期自动失效
|
||||
- 私有资源不受影响,仍需 JWT
|
||||
- 每个签名 URL 绑定具体文件路径,无法篡改
|
||||
- 每个签名 URL 绑定规范化后的文件路径,无法通过路径遍历篡改
|
||||
- 资源从公开改为私有时,中间件会查询数据库验证 is_public 状态,签名即时失效
|
||||
|
||||
### 4.3 配置新增
|
||||
|
||||
@@ -280,6 +295,10 @@ max_file_size = "10MB"
|
||||
secret_key = "change-me-in-production" # 签名 URL 密钥
|
||||
```
|
||||
|
||||
**密钥安全保护**(与 `ERP__CRYPTO__KEK` 同等策略):
|
||||
- 开发环境:使用默认值,启动时 `tracing::warn!` 提示
|
||||
- 生产环境(`NODE_ENV=production`):如果 secret_key 是默认值,启动时 `panic!` 终止
|
||||
|
||||
## 5. 管理后台 UI
|
||||
|
||||
### 5.1 媒体库页面 — MediaLibrary
|
||||
@@ -470,8 +489,9 @@ export async function getPublicArticles(categoryId: string, pageSize = 4): Promi
|
||||
|
||||
```rust
|
||||
("health.media.list", "查看媒体库"),
|
||||
("health.media.manage", "管理媒体资源(上传/编辑/删除)"),
|
||||
("health.banners.manage", "管理轮播图"),
|
||||
("health.media.manage", "管理媒体资源(上传/编辑/删除/裁剪)"),
|
||||
("health.banners.list", "查看轮播图列表"),
|
||||
("health.banners.manage", "管理轮播图(创建/编辑/删除/排序)"),
|
||||
```
|
||||
|
||||
默认分配给 `admin` 和 `operator` 角色。
|
||||
@@ -522,23 +542,33 @@ export async function getPublicArticles(categoryId: string, pageSize = 4): Promi
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `crates/erp-health/src/module.rs` | 注册路由 + 权限码 |
|
||||
| `crates/erp-health/src/module.rs` | 注册路由 + 权限码;公开路由添加到 `HealthModule::public_routes()`(继承现有 IP 速率限制) |
|
||||
| `crates/erp-health/src/entity/mod.rs` | 导出新实体 |
|
||||
| `crates/erp-health/src/handler/mod.rs` | 导出新 handler |
|
||||
| `crates/erp-health/src/service/mod.rs` | 导出新 service |
|
||||
| `crates/erp-health/src/dto/mod.rs` | 导出新 DTO |
|
||||
| `crates/erp-server/src/main.rs` | 注册公开路由组(`/public/`) |
|
||||
| `crates/erp-server/src/handlers/upload.rs` | upload_auth_middleware 增加签名 URL 验证 |
|
||||
| `crates/erp-server/src/main.rs` | 公开路由注册到 `/api/v1/public` 路由组(不挂 JWT 中间件) |
|
||||
| `crates/erp-server/src/handlers/upload.rs` | upload_auth_middleware 增加签名 URL 验证(含路径规范化) |
|
||||
| `crates/erp-server/src/config.rs` | StorageConfig 增加 secret_key 字段 |
|
||||
| `crates/erp-server/config/default.toml` | 增加 secret_key 配置 |
|
||||
| 迁移文件(菜单种子) | 新增"媒体库"和"轮播图管理"菜单项(health 模块组下) |
|
||||
| 迁移文件(分类种子) | 新增"首页推荐"文章分类,ID 作为配置常量 |
|
||||
|
||||
### 9.3 前端新增
|
||||
### 9.3 Cargo 依赖
|
||||
|
||||
| crate | 版本 | features | 放在哪个 crate |
|
||||
|-------|------|----------|----------------|
|
||||
| `image` | `0.25` | `default-features = false, features = ["jpeg", "png", "webp"]` | `erp-health` |
|
||||
| `hmac` | `0.12` | `features = ["sha2"]` | `erp-server`(签名 URL) |
|
||||
| `sha2` | `0.10` | 默认 | `erp-server`(签名 URL) |
|
||||
|
||||
### 9.4 前端新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `apps/web/src/pages/health/MediaLibrary.tsx` | 媒体库页面 |
|
||||
| `apps/web/src/pages/health/BannerManage.tsx` | 轮播图管理页面 |
|
||||
| `apps/web/src/components/MediaPicker/index.tsx` | 媒体选择器组件 |
|
||||
| `apps/web/src/components/health/MediaPicker/index.tsx` | 媒体选择器组件(放在 health 目录下,与模块 API 耦合) |
|
||||
| `apps/web/src/api/health/media.ts` | 媒体库 API 客户端 |
|
||||
| `apps/web/src/api/health/banners.ts` | 轮播图 API 客户端 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user