diff --git a/docs/superpowers/specs/2026-05-10-media-library-banner-design.md b/docs/superpowers/specs/2026-05-10-media-library-banner-design.md index 89752d0..4792084 100644 --- a/docs/superpowers/specs/2026-05-10-media-library-banner-design.md +++ b/docs/superpowers/specs/2026-05-10-media-library-banner-design.md @@ -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 客户端 |