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:
iven
2026-05-10 11:40:44 +08:00
parent a12fe0e8a9
commit 5c5c099fb2

View File

@@ -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 时附带短时效签名 token1 小时有效)
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 时附带短时效签名 token1 小时有效)
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 客户端 |