Files
hms/docs/superpowers/specs/2026-05-10-media-library-banner-design.md
iven 5c5c099fb2 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: 新增"首页推荐"分类种子迁移
2026-05-10 11:40:44 +08:00

632 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 媒体库与轮播图管理 — 设计规格
> 日期: 2026-05-10 | 模块: erp-health | 状态: 设计中
## 1. 背景与目标
### 1.1 问题
小程序访客首页存在两个硬编码问题:
1. **轮播图硬编码** — CSS 渐变背景,无图片,无后台管理。运营人员无法动态更新轮播内容。
2. **"核心功能"区域硬编码** — 3 张静态功能卡片,应改为按分类筛选的文章列表,让运营人员通过发布文章控制首页展示内容。
此外,当前系统缺少**通用媒体库**能力:
- 上传的文件没有数据库记录,无法搜索、分类、复用
- 文章编辑器和轮播图各自独立上传图片,无法共享资源
- 没有文件夹组织,上传量大后难以管理
### 1.2 目标
1. 新增**媒体库模块**:上传图片 → 文件夹分类 → 搜索筛选 → 多处复用(轮播图、文章封面、文章插图)
2. 新增**轮播图管理**:从媒体库选图 → 配置标题/副标题/跳转 → 排序/定时上下架
3. 改造**小程序访客首页**:轮播图从 API 动态获取,"核心功能"改为文章卡片列表
4. 公开/私有访问控制:媒体资源可标记为公开,未登录用户可查看
### 1.3 范围
- 媒体库放在 `erp-health` 模块内(当前只有健康管理使用媒体库)
- 支持图片类型JPEG/PNG/GIF/WebP文档和视频不在本期范围
- 在线编辑仅支持裁剪+缩放,不做复杂图片编辑
- 缩略图自动生成,尺寸固定 200×200
### 1.4 依赖
- 现有 `POST /api/v1/upload` 端点和本地文件存储
- 现有文章管理模块entity/handler/service/dto
- 现有 Wangeditor 富文本编辑器
- 现有 Ant Design 组件库和主题系统
## 2. 数据模型
### 2.1 media_folder — 媒体文件夹
树形结构,支持嵌套分类。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID v7 | PK | 主键 |
| tenant_id | UUID | NOT NULL, IDX | 租户隔离 |
| name | String(100) | NOT NULL | 文件夹名称 |
| parent_id | UUID | NULL, FK→media_folder.id | 父文件夹NULL 表示根级) |
| sort_order | i32 | DEFAULT 0 | 同级排序 |
| created_at | DateTime | NOT NULL | 创建时间 |
| updated_at | DateTime | NOT NULL | 更新时间 |
| created_by | UUID | NULL | 创建人 |
| updated_by | UUID | NULL | 更新人 |
| deleted_at | DateTime | NULL | 软删除标记 |
| version | i32 | NOT NULL, DEFAULT 1 | 乐观锁 |
**约束:**
- 同一 `tenant_id` + `parent_id``name` 唯一(未删除的)
- 删除文件夹时,必须先清空其下所有子文件夹和媒体资源(或级联移动到父级)
### 2.2 media_item — 媒体资源
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID v7 | PK | 主键 |
| tenant_id | UUID | NOT NULL, IDX | 租户隔离 |
| folder_id | UUID | NULL, FK→media_folder.id | 所属文件夹NULL 表示未分类) |
| filename | String(255) | NOT NULL | 原始文件名 |
| storage_path | String(500) | NOT NULL, UNIQUE | 存储路径 `/uploads/{tenant}/{uuid}.ext` |
| thumbnail_path | String(500) | NULL | 缩略图路径 `/uploads/{tenant}/thumb_{uuid}.ext` |
| content_type | String(100) | NOT NULL | MIME 类型 |
| file_size | i64 | NOT NULL | 文件大小bytes |
| width | i32 | NULL | 图片宽度px |
| height | i32 | NULL | 图片高度px |
| alt_text | String(255) | NULL | 替代文本(用于无障碍和 SEO |
| is_public | bool | NOT NULL, DEFAULT false | 公开标记(公开资源无需认证即可访问) |
| created_at | DateTime | NOT NULL | 创建时间 |
| updated_at | DateTime | NOT NULL | 更新时间 |
| created_by | UUID | NULL | 上传人 |
| updated_by | UUID | NULL | 更新人 |
| deleted_at | DateTime | NULL | 软删除标记 |
| version | i32 | NOT NULL, DEFAULT 1 | 乐观锁 |
**索引:**
- `idx_media_item_tenant_folder` ON (tenant_id, folder_id) — 按文件夹查询
- `idx_media_item_tenant_public` ON (tenant_id, is_public) — 筛选公开资源
- `idx_media_item_content_type` ON (content_type) — 按类型筛选
### 2.3 banner — 轮播图
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID v7 | PK | 主键 |
| tenant_id | UUID | NOT NULL, IDX | 租户隔离 |
| media_item_id | UUID | NOT NULL, FK→media_item.id | 关联媒体资源 |
| title | String(100) | NULL | 标题(覆盖媒体库 alt_text |
| subtitle | String(255) | NULL | 副标题 |
| link_type | String(20) | NULL | 跳转类型:`article` / `external` / `none` |
| link_target | String(500) | NULL | 跳转目标(文章 ID 或 URL |
| sort_order | i32 | NOT NULL, DEFAULT 0 | 展示排序 |
| status | String(20) | NOT NULL, DEFAULT 'active' | 状态:`active` / `inactive` |
| start_time | DateTime | NULL | 定时上架时间NULL 表示立即) |
| end_time | DateTime | NULL | 定时下架时间NULL 表示永久) |
| created_at | DateTime | NOT NULL | 创建时间 |
| updated_at | DateTime | NOT NULL | 更新时间 |
| created_by | UUID | NULL | 创建人 |
| updated_by | UUID | NULL | 更新人 |
| deleted_at | DateTime | NULL | 软删除标记 |
| version | i32 | NOT NULL, DEFAULT 1 | 乐观锁 |
**索引:**
- `idx_banner_tenant_status` ON (tenant_id, status) — 按状态筛选
- `idx_banner_sort` ON (tenant_id, sort_order) — 排序查询
**约束:**
- `link_type` 仅允许 `article` / `external` / `none`
- `status` 仅允许 `active` / `inactive`
- `end_time` > `start_time`(当两者都不为 NULL 时)
## 3. API 设计
### 3.1 媒体库 — `/api/v1/health/media`
| 方法 | 路径 | 说明 | 权限码 |
|------|------|------|--------|
| GET | `/media` | 分页列表,支持 folder_id / content_type / keyword / is_public 筛选 | `health.media.list` |
| POST | `/media/upload` | 上传文件到媒体库multipart/form-data可选指定 folder_id 和 is_public | `health.media.manage` |
| GET | `/media/{id}` | 获取单个资源详情(含完整 URL | `health.media.list` |
| 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: [] }`,最大 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`
2. 后端调用现有上传逻辑保存文件到 `/uploads/{tenant_id}/{uuid}.ext`
3. 读取图片尺寸width/height生成 200×200 缩略图保存到 `/uploads/{tenant_id}/thumb_{uuid}.ext`
4. 创建 `media_item` 数据库记录
5. 返回 `MediaItemResp`
**列表查询参数:**
- `folder_id` — 按文件夹筛选NULL 表示未分类,不传表示全部)
- `content_type` — 按 MIME 类型前缀筛选(如 `image/`
- `keyword` — 按文件名模糊搜索
- `is_public` — 按公开状态筛选
- `page` / `page_size` — 分页
- `sort_by` — 排序字段created_at / file_size / filename默认 created_at DESC
### 3.2 文件夹 — `/api/v1/health/media-folders`
| 方法 | 路径 | 说明 | 权限码 |
|------|------|------|--------|
| GET | `/media-folders` | 返回当前租户的完整文件夹树 | `health.media.list` |
| POST | `/media-folders` | 创建文件夹name + parent_id | `health.media.manage` |
| PUT | `/media-folders/{id}` | 更新(重命名 / 移动 parent_id含 version | `health.media.manage` |
| DELETE | `/media-folders/{id}` | 删除空文件夹(文件夹内有资源时拒绝) | `health.media.manage` |
**文件夹树响应格式:**
```json
{
"data": [
{
"id": "uuid",
"name": "轮播图",
"parent_id": null,
"sort_order": 0,
"children": [],
"item_count": 5
}
]
}
```
### 3.3 轮播图管理 — `/api/v1/health/banners`
| 方法 | 路径 | 说明 | 权限码 |
|------|------|------|--------|
| 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 }]`,返回 `ApiResponse<()>` | `health.banners.manage` |
**级联规则:**
- 软删除 `media_item` 时,将关联的 banners 设为 `status = 'inactive'`
- Banner 查询响应包含 `media_deleted: bool` 标记UI 据此显示占位图
**创建/更新 DTO**
```json
{
"media_item_id": "uuid",
"title": "专业血透中心",
"subtitle": "三甲级医护团队全程守护",
"link_type": "article",
"link_target": "article-uuid",
"sort_order": 1,
"status": "active",
"start_time": null,
"end_time": null
}
```
### 3.4 公开端点(无需认证)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/public/banners` | 返回当前生效的轮播图列表 |
| GET | `/public/articles` | 返回指定分类下的已发布文章(小程序访客页用) |
**`GET /public/banners` 逻辑:**
1. 不需要 JWT 认证
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": [
{
"id": "uuid",
"title": "专业血透中心",
"subtitle": "三甲级医护团队全程守护",
"image_url": "/uploads/{tenant}/xxx.jpg?token=xxx&expires=xxx",
"link_type": "article",
"link_target": "article-uuid"
}
]
}
```
**`GET /public/articles` 逻辑:**
1. 复用现有 `list_articles` service 逻辑,但跳过认证
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.list` | 查看轮播图列表 | admin, operator |
| `health.banners.manage` | 创建/编辑/删除/排序轮播图 | admin, operator |
## 4. 公开访问机制
### 4.1 问题
当前 `/uploads/` 路径受 JWT 中间件保护,所有文件访问需要认证。但小程序访客页面的轮播图需要未登录用户可见。
### 4.2 方案:公开标记 + 签名 URL
**数据库层:** `media_item.is_public` 标记资源是否可公开访问。
**访问流程:**
1. `GET /public/banners` 返回图片 URL 时,对 `is_public = true` 的资源生成签名 URL
- 格式:`/uploads/{tenant}/{file}?token={hmac_signature}&expires={unix_timestamp}`
- **签名输入**`HMAC-SHA256(key, "{normalized_path}\n{expires}")`
- `normalized_path` 经过路径遍历规范化处理(去除 `..`、连续 `/` 等),确保签名绑定到真实文件路径
- 有效期 1 小时
2.`is_public = false` 的资源,仍通过标准 JWT 认证访问
**文件服务中间件改动:**
- 现有 `upload_auth_middleware` 增加签名 URL 验证分支:
- 请求携带 `?token=&expires=` 参数时:
1. 解析并规范化请求路径(防止 `..` 遍历)
2. 验证规范化路径仍以 `/uploads/{tenant_id}/` 为前缀
3. 用规范化路径 + expires 重新计算 HMAC与 token 参数比对
4. 检查 expires 未过期
5. 查询数据库确认该 `media_item.is_public = true`(防止从公开改为私有后签名仍有效)
- 验证通过则放行,不通过返回 401
- 不携带签名参数时,走原有 JWT 认证逻辑
**安全考虑:**
- 签名密钥不暴露给客户端,仅后端生成和验证
- 签名 URL 有时效性,过期自动失效
- 私有资源不受影响,仍需 JWT
- 每个签名 URL 绑定规范化后的文件路径,无法通过路径遍历篡改
- 资源从公开改为私有时,中间件会查询数据库验证 is_public 状态,签名即时失效
### 4.3 配置新增
```toml
[storage]
upload_dir = "./uploads"
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
**路径:** `apps/web/src/pages/health/MediaLibrary.tsx`
**布局:方案 A左树 + 右网格)**
采用 PageContainer 包裹,内部水平分割:
**左侧栏200px**
- 标题"文件夹" + 新建按钮
- 树形列表,每项显示图标 + 名称 + 资源数量
- "全部资源"为默认选中项(不对应 folder_id
- 右键/更多菜单:重命名、删除
- 支持拖拽排序
**右侧主区域:**
- **工具栏**上传按钮primary + 搜索框 + 类型下拉 + 公开状态下拉 + 视图切换(网格/列表)
- **网格视图**4 列缩略图卡片,每张 200×200
- 卡片内容:缩略图 + 左上角公开标记 badge + 右上角 hover 操作按钮(复制 URL、编辑、删除
- 下方显示文件名和尺寸信息
- 点击卡片打开大图预览 Modal
- **列表视图**Ant Design Table列为缩略图(40×40)/文件名/类型/大小/公开状态/创建时间/操作
- **分页**:底部分页条
**上传交互:**
- 点击"上传图片"按钮打开文件选择器accept="image/*"multiple
- 上传过程中显示进度条
- 上传完成自动刷新列表
**编辑 Modal**
- alt_text 文本输入
- 文件夹下拉选择(移动到...
- 公开/私有开关
- 保存后刷新列表
**批量操作:**
- 勾选多个卡片 → 底部浮动操作栏:批量删除 / 批量移动 / 批量设置公开
### 5.2 轮播图管理页面 — BannerManage
**路径:** `apps/web/src/pages/health/BannerManage.tsx`
**布局PageContainer + Table + Drawer**
- **标题栏**:标题 + "新增轮播图"按钮
- **表格列**:排序 / 缩略图(40×40) / 标题 / 副标题 / 跳转(类型+目标) / 状态(Tag) / 生效时间 / 操作(编辑/删除)
- **拖拽排序**:使用 `@dnd-kit` 实现行拖拽,松手后调用 `PUT /banners/sort`
- **Drawer 表单**(新增/编辑):
- 图片选择:点击区域打开 MediaPicker 弹窗
- 标题、副标题文本输入
- 跳转设置:类型下拉 + 目标输入(选择"文章"时弹出文章搜索选择器)
- 生效时间DateRangePicker
- 排序值:数字输入
- 状态开关
### 5.3 MediaPicker 组件
**路径:** `apps/web/src/components/MediaPicker/index.tsx`
从媒体库页面抽取的独立选图组件:
- **Props**
- `visible: boolean` — 显示/隐藏
- `onSelect: (item: MediaItemResp) => void` — 选中回调
- `accept?: string` — 文件类型限制
- `multiple?: boolean` — 是否多选
- **布局:** Modal 弹窗,内部复用网格视图(不带左侧树,用下拉选择文件夹)
- **交互:** 点击图片卡片选中(高亮边框),点击确认按钮触发 onSelect
- **集成上传:** 弹窗内可直接上传新图片,上传后自动出现在列表中
- **使用场景:**
1. 轮播图管理 — 选择图片
2. 文章编辑器 — 选择封面图
3. 文章编辑器 — Wangeditor 插入图片时选图
### 5.4 ArticleEditor 增强
改造现有 `ArticleEditor.tsx`
1. **封面图**:从当前的上传组件改为 MediaPicker 组件。点击封面图区域打开 MediaPicker选中后回填 URL。
2. **富文本插图**:改造 Wangeditor 的 `MENU_CONF.uploadImage`
- `customUpload` 改为调用 `POST /media/upload`(上传到媒体库并返回 media_item
- 新增 `customBrowse` 按钮打开 MediaPicker选中后插入 `<img>` 标签
- 插入的图片 URL 附加 `?token=xxx` 用于认证
## 6. 小程序改动
### 6.1 访客首页轮播图
**文件:** `apps/miniprogram/src/pages/index/index.tsx`
改动:
1. 删除 `CAROUSEL_SLIDES` 硬编码常量
2. 新增 `useEffect` 在组件挂载时调用 `GET /public/banners`
3. 数据存入组件 state`banners` 数组)
4. `<SwiperItem>` 从 banners 数据渲染:
- 背景:从 `image_url` 加载 `<Image>` 组件
- 标题/副标题:从 banner.title / banner.subtitle 渲染
- 跳转:点击 SwiperItem 时根据 link_type 跳转article → navigateTo 文章详情external → Taro 导航)
5. 加载中显示骨架屏Skeleton加载失败不显示轮播图降级处理
### 6.2 文章列表区域
**文件:** `apps/miniprogram/src/pages/index/index.tsx`
改动:
1. 删除"核心功能"区域的 3 张硬编码卡片
2. 新增 `useEffect` 调用 `GET /public/articles?category_id={首页推荐分类ID}&page_size=4`
3. 渲染文章卡片列表:
- 前 2 篇使用两列网格布局(`mp-article-grid`),显示封面图 + 标题 + 摘要
- 后 2 篇使用横向列表布局(左图右文),节省空间
- 底部"查看更多"链接,跳转到文章列表页(需登录)
4. 区域标题从"核心功能"改为"健康资讯"
5. 无数据时显示空状态占位
### 6.3 小程序 API 服务
**文件:** `apps/miniprogram/src/services/article.ts`
新增公开 API 调用(不需要 token
```typescript
// 获取公开轮播图(无需认证)
export async function getPublicBanners(): Promise<Banner[]> {
const { data } = await client.get('/public/banners');
return data;
}
// 获取公开文章列表(无需认证)
export async function getPublicArticles(categoryId: string, pageSize = 4): Promise<ArticleListItem[]> {
const { data } = await client.get('/public/articles', { params: { category_id: categoryId, page_size: pageSize } });
return data;
}
```
需要在 `request.ts` 中增加对 `/public/` 路径的跳过认证逻辑(不附加 Authorization header
## 7. 缩略图与在线编辑
### 7.1 缩略图生成
**时机:** 上传图片后,在 `media_service` 中自动生成。
**规格:**
- 尺寸200×200px居中裁剪cover 模式)
- 格式保持原格式JPEG→JPEG, PNG→PNG
- 质量JPEG 85%
- 命名:`thumb_{uuid}.ext`,与原文件同目录
- 库:使用 `image` crateRust 纯实现,无系统依赖)
**流程:**
1. 接收上传文件 → 保存原文件
2. 读取图片 → 获取原始 width/height
3. 居中裁剪到正方形 → 缩放到 200×200
4. 保存缩略图
5. 数据库记录 storage_path + thumbnail_path + width + height
### 7.2 在线编辑
**范围:** 仅支持裁剪 + 缩放,不做旋转、滤镜、文字标注等。
**实现方案:**
- 前端:使用 `react-image-crop`
- 后端:接收裁剪参数 `{ x, y, width, height }`,裁剪原图并保存为新版本
- 替换策略:裁剪后覆盖原文件(保留原文件名),同时重新生成缩略图
- 不保留编辑历史(简化实现)
**交互:**
1. 媒体库中点击"编辑"按钮
2. 打开 Modal显示原图 + 裁剪框
3. 调整裁剪区域 → 确认
4. 调用 `PUT /media/{id}/crop`,传裁剪参数
5. 后端裁剪 → 覆盖原文件 → 重新生成缩略图
6. 前端刷新列表
### 7.3 新增 API
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/media/{id}/crop` | 裁剪图片body: `{ x, y, width, height, version }` |
## 8. 权限与安全
### 8.1 权限码注册
`erp-health/src/module.rs``ErpModule::permissions()` 中注册:
```rust
("health.media.list", "查看媒体库"),
("health.media.manage", "管理媒体资源(上传/编辑/删除/裁剪)"),
("health.banners.list", "查看轮播图列表"),
("health.banners.manage", "管理轮播图(创建/编辑/删除/排序)"),
```
默认分配给 `admin``operator` 角色。
### 8.2 安全考虑
| 风险 | 缓解措施 |
|------|----------|
| 上传恶意文件 | 复用现有 Content-Type 白名单 + Magic Bytes 验证 |
| 路径遍历攻击 | 文件名使用 UUID v7不使用用户输入的文件名 |
| 签名 URL 泄露 | 1 小时有效期 + 绑定具体文件路径 + HMAC 验证 |
| 公开资源被枚举 | UUID v7 不可预测,不暴露列表端点给未认证用户 |
| 批量删除误操作 | 前端二次确认 + 软删除可恢复 |
| 磁盘空间耗尽 | 配置 max_file_size 限制单文件大小,监控 uploads 目录 |
| 跨租户访问 | 所有查询强制 tenant_id 过滤,文件路径包含 tenant_id |
### 8.3 文件类型限制
上传端点仅允许图片类型:
| 类型 | MIME | Magic Bytes |
|------|------|-------------|
| JPEG | `image/jpeg` | `FF D8 FF` |
| PNG | `image/png` | `89 50 4E 47` |
| GIF | `image/gif` | `GIF87a` / `GIF89a` |
| WebP | `image/webp` | `RIFF....WEBP` |
现有上传端点已支持文档类型PDF/Word/Excel媒体库上传端点在此基础上进一步限制为仅图片。
## 9. 关键文件清单
### 9.1 后端新增
| 文件 | 说明 |
|------|------|
| `crates/erp-health/src/entity/media_folder.rs` | 文件夹实体 |
| `crates/erp-health/src/entity/media_item.rs` | 媒体资源实体 |
| `crates/erp-health/src/entity/banner.rs` | 轮播图实体 |
| `crates/erp-health/src/handler/media_handler.rs` | 媒体库 HTTP 处理 |
| `crates/erp-health/src/handler/banner_handler.rs` | 轮播图 HTTP 处理 |
| `crates/erp-health/src/service/media_service.rs` | 媒体库业务逻辑CRUD + 缩略图生成 + 裁剪) |
| `crates/erp-health/src/service/banner_service.rs` | 轮播图业务逻辑CRUD + 排序 + 定时生效) |
| `crates/erp-health/src/dto/media_dto.rs` | 媒体库 DTO |
| `crates/erp-health/src/dto/banner_dto.rs` | 轮播图 DTO |
| 迁移文件3 个) | media_folder / media_item / banner 建表 |
### 9.2 后端修改
| 文件 | 改动 |
|------|------|
| `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` | 公开路由注册到 `/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 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/health/MediaPicker/index.tsx` | 媒体选择器组件(放在 health 目录下,与模块 API 耦合) |
| `apps/web/src/api/health/media.ts` | 媒体库 API 客户端 |
| `apps/web/src/api/health/banners.ts` | 轮播图 API 客户端 |
### 9.4 前端修改
| 文件 | 改动 |
|------|------|
| `apps/web/src/pages/health/ArticleEditor.tsx` | 封面图和插图改用 MediaPicker |
| `apps/web/src/router/` | 添加媒体库和轮播图路由 |
| `apps/web/src/api/upload.ts` | 可选:上传后自动创建 media_item |
### 9.5 小程序修改
| 文件 | 改动 |
|------|------|
| `apps/miniprogram/src/pages/index/index.tsx` | 轮播图和文章列表接入 API |
| `apps/miniprogram/src/services/article.ts` | 新增公开 API 调用 |
| `apps/miniprogram/src/services/request.ts` | `/public/` 路径跳过认证 |
## 10. 验证清单
### 10.1 后端验证
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test --workspace` 全部通过
- [ ] 数据库迁移正反执行成功
- [ ] `POST /media/upload` 上传图片 → 返回 media_item 含缩略图
- [ ] `GET /media` 列表筛选(按文件夹/类型/关键词/公开状态)
- [ ] `PUT /media/{id}` 更新元数据 → 乐观锁生效
- [ ] `DELETE /media/{id}` 软删除 → 文件从磁盘移除
- [ ] `POST /media/{id}/crop` 裁剪 → 原文件和缩略图更新
- [ ] `POST /banners` 创建轮播图 → 关联 media_item
- [ ] `PUT /banners/sort` 排序 → sort_order 更新
- [ ] `GET /public/banners` 无认证 → 返回签名 URL
- [ ] 签名 URL 访问文件 → 成功返回图片
- [ ] 过期签名 URL → 返回 401
- [ ] 私有资源不附加 JWT → 返回 401
### 10.2 管理后台验证
- [ ] 媒体库页面:文件夹树加载/新建/重命名/删除
- [ ] 媒体库页面:上传图片 → 列表显示缩略图
- [ ] 媒体库页面:搜索/筛选/分页正常
- [ ] 媒体库页面:编辑 alt_text / 公开状态 → 保存成功
- [ ] 媒体库页面:裁剪图片 → 结果正确
- [ ] 轮播图管理:新增 → 从 MediaPicker 选图 → 保存
- [ ] 轮播图管理:拖拽排序 → sort_order 更新
- [ ] 轮播图管理:设置定时上下架 → 状态正确切换
- [ ] 文章编辑器:封面图从 MediaPicker 选图
- [ ] 文章编辑器:插图从 MediaPicker 选图或直接上传
### 10.3 小程序验证
- [ ] 访客首页轮播图:未登录 → 动态显示后台配置的轮播图
- [ ] 访客首页轮播图:点击跳转正确(文章详情 / 外部链接)
- [ ] 访客首页文章列表:显示"健康资讯"分类下已发布文章
- [ ] 访客首页文章列表:点击"查看更多"跳转(需登录引导)
- [ ] 轮播图加载失败 → 不显示轮播区域(降级处理)
- [ ] 文章为空 → 显示空状态占位
- [ ] 关怀模式下轮播图和文章列表显示正常