437 lines
16 KiB
Markdown
437 lines
16 KiB
Markdown
:::tip 文章管理
|
||
文章管理模块支持富文本编辑、分类关联、封面图片上传、图集管理等功能。适用于新闻、公告、帮助文档等内容管理场景。本模块是典型的「带文件处理」业务模块,涵盖了封面迁移、图集处理、富文本 XSS 清洗等核心机制。
|
||
:::
|
||
|
||
## 模块结构
|
||
|
||
```
|
||
src/modules/article/
|
||
├── models.py # 文章模型
|
||
├── schemas.py # 表单验证
|
||
├── repository.py # 数据访问层
|
||
└── service.py # 业务逻辑层(自定义,非 BaseService 子类)
|
||
```
|
||
|
||
:::warning 自定义 Service
|
||
文章模块因涉及文件处理、富文本清洗、图集解析等复杂逻辑,未继承 `BaseService`,而是编写自定义 service 函数。这是项目中复杂模块的典型写法,与简单模块(如 Param 继承 BaseService)形成对比。
|
||
:::
|
||
|
||
## 文章模型
|
||
|
||
```python
|
||
# src/modules/article/models.py
|
||
class Article(base_model, base_db):
|
||
"""文章模型类"""
|
||
__tablename__ = DB_PREFIX + "article"
|
||
__table_comment__ = "文章表"
|
||
|
||
# 分类ID
|
||
category_id = Column(Integer, nullable=True, index=True, comment="分类ID")
|
||
# 文章标题
|
||
title = Column(String(150), nullable=True, index=True, comment="文章标题")
|
||
# 文章封面(存相对路径,如 /article/20260907/xxx.png)
|
||
cover = Column(String(255), nullable=True, comment="文章封面")
|
||
# 文章图集(逗号分隔的相对路径,如 /article/20260907/a.png,/article/20260907/b.png)
|
||
images = Column(Text, nullable=True, comment="文章图集")
|
||
# 文章导读
|
||
intro = Column(String(255), nullable=True, comment="文章导读")
|
||
# 文章内容(富文本 HTML,图片地址存为 [IMG_URL] 占位符 + 相对路径)
|
||
content = Column(Text, nullable=True, comment="文章内容")
|
||
# 文章作者
|
||
author = Column(String(100), nullable=True, comment="文章作者")
|
||
# 状态:0-正常 1-下架
|
||
status = Column(Integer, default=0, server_default=text('0'), nullable=False, index=True,
|
||
comment="文章状态:0-正常 1-下架")
|
||
# 点击量
|
||
click = Column(Integer, default=0, server_default=text('0'), nullable=False, comment="文章点击率")
|
||
# 排序
|
||
sort = Column(Integer, default=0, server_default=text('0'), nullable=False, comment="文章排序")
|
||
```
|
||
|
||
其中 `id`、`create_user`、`create_time`、`update_user`、`update_time`、`is_delete` 由基类 `base_model` 自动提供。
|
||
|
||
## 表单验证
|
||
|
||
### ArticleForm(新增/编辑)
|
||
|
||
```python
|
||
class ArticleForm(BaseForm):
|
||
categoryId: Optional[int] = Field(None, ge=0, description="分类ID")
|
||
title: str = Field(..., min_length=1, max_length=150, description="文章标题")
|
||
cover: Optional[str] = Field(None, max_length=255, description="文章封面")
|
||
images: Optional[str] = Field(None, description="文章图集")
|
||
intro: Optional[str] = Field(None, max_length=255, description="文章导读")
|
||
content: Optional[str] = Field(None, description="文章内容")
|
||
author: Optional[str] = Field(None, max_length=100, description="文章作者")
|
||
status: int = Field(0, ge=0, le=1, description="文章状态:0-正常 1-下架")
|
||
click: int = Field(0, ge=0, le=2147483647, description="文章点击率")
|
||
sort: int = Field(0, ge=0, le=99999, description="文章排序")
|
||
```
|
||
|
||
| 字段 | 必填 | 约束 | 说明 |
|
||
|:----|:----|:----|:----|
|
||
| `categoryId` | 否 | `ge=0` | 关联分类表,0 或不传表示无分类 |
|
||
| `title` | 是 | `1~150 字符` | 文章标题,唯一业务标识 |
|
||
| `cover` | 否 | `max_length=255` | 封面图片的临时 URL 或相对路径 |
|
||
| `images` | 否 | 无长度限制 | 图集,逗号分隔的 URL 字符串 |
|
||
| `intro` | 否 | `max_length=255` | 文章导读/摘要 |
|
||
| `content` | 否 | 无长度限制 | 富文本 HTML 内容 |
|
||
| `author` | 否 | `max_length=100` | 文章作者 |
|
||
| `status` | 否 | `0~1` | 默认 0(正常),1 为下架 |
|
||
| `click` | 否 | `0~2147483647` | 点击量,前端通常不传 |
|
||
| `sort` | 否 | `0~99999` | 排序号,值越大越靠前 |
|
||
|
||
### ArticleStatusForm(状态变更)
|
||
|
||
```python
|
||
class ArticleStatusForm(BaseModel):
|
||
id: int = Field(..., gt=0, description="文章ID")
|
||
status: int = Field(..., ge=0, le=1, description="文章状态:0-正常 1-下架")
|
||
```
|
||
|
||
用于独立的状态变更接口,只需传 `id` 和 `status`。
|
||
|
||
## 文件处理机制
|
||
|
||
文章模块涉及三类文件字段,处理方式各不相同:
|
||
|
||
### 处理流程总览
|
||
|
||
```
|
||
前端上传临时文件 → 获得临时 URL(如 /temp/20260907/xxx.png)
|
||
↓
|
||
提交表单(cover / images / content 携带临时 URL)
|
||
↓
|
||
后端 service 调用 save_file / save_content
|
||
↓
|
||
临时文件迁移到正式目录(如 /article/20260907/xxx.png)
|
||
↓
|
||
数据库存相对路径(不存完整 URL)
|
||
↓
|
||
查询时调用 get_file_url 拼接完整访问地址
|
||
```
|
||
|
||
### 1. 封面图片(cover)— `save_file`
|
||
|
||
```python
|
||
# 新增时
|
||
cover = save_file(data.cover, "article") if data.cover else None
|
||
```
|
||
|
||
`save_file(url, directory)` 的处理逻辑:
|
||
1. 将前端传来的 URL 还原为磁盘绝对路径
|
||
2. 若文件在临时目录(`temp/`),移动到正式目录(`upload/article/日期/`)
|
||
3. 若文件已在正式目录,直接返回相对路径
|
||
4. 返回相对路径存入数据库(如 `/article/20260907/xxx.png`)
|
||
5. 远程 URL(非本站文件)不处理,返回空字符串
|
||
|
||
:::warning 防路径穿越
|
||
`save_file` 内部会校验磁盘真实路径必须位于 `UPLOAD_DIR` 内,防止 `../` 路径穿越攻击。
|
||
:::
|
||
|
||
### 2. 图集(images)— `_build_images` / `_parse_images`
|
||
|
||
图集在数据库中存储为**逗号分隔的相对路径字符串**:
|
||
|
||
```
|
||
/article/20260907/a.png,/article/20260907/b.png,/article/20260907/c.png
|
||
```
|
||
|
||
**写入时**(`_build_images`):
|
||
|
||
```python
|
||
def _build_images(images):
|
||
"""逗号分隔的临时 URL → 逐个 save_file 迁移 → 逗号分隔的相对路径"""
|
||
if not images:
|
||
return None
|
||
saved = []
|
||
for url in images.split(','):
|
||
url = url.strip()
|
||
if not url:
|
||
continue
|
||
img = save_file(url, "article")
|
||
saved.append(img or url)
|
||
return ','.join(saved)
|
||
```
|
||
|
||
**读取时**(`_parse_images`):
|
||
|
||
```python
|
||
def _parse_images(images):
|
||
"""逗号分隔的相对路径 → 逐个 get_file_url 拼接 → 完整 URL 数组"""
|
||
if not images:
|
||
return []
|
||
return [get_file_url(img) for img in images.split(',') if img]
|
||
```
|
||
|
||
前端拿到的是 URL 数组,可直接用于 `<img>` 渲染。
|
||
|
||
### 3. 富文本内容(content)— `save_content`
|
||
|
||
```python
|
||
# 新增时
|
||
content = save_content(data.content, data.title, "article") if data.content else None
|
||
```
|
||
|
||
`save_content(content, title, directory)` 的处理流程:
|
||
|
||
```
|
||
原始 HTML
|
||
↓
|
||
① bleach 白名单清洗(移除 <script>/<iframe> 等危险标签和事件属性)
|
||
↓
|
||
② CSS 表达式过滤(移除 style 中的 expression()、javascript: 等向量)
|
||
↓
|
||
③ 提取 <img src="..."> 中的临时图片 URL
|
||
↓
|
||
④ 逐个调用 save_file 迁移到正式目录
|
||
↓
|
||
⑤ 将 HTML 中的图片 src 替换为正式相对路径
|
||
↓
|
||
⑥ 图片 alt 属性设置为文章标题(HTML 转义防注入)
|
||
↓
|
||
⑦ 将正式 URL 中的 FASTAPI_FILE_URL 前缀替换为 [IMG_URL] 占位符存库
|
||
```
|
||
|
||
:::tip 为什么用 [IMG_URL] 占位符?
|
||
数据库中不存完整 URL(如 `http://localhost:8031/upload/article/...`),而是存 `[IMG_URL]/article/...`。查询详情时再将 `[IMG_URL]` 替换为实际的 `FASTAPI_FILE_URL`。这样更换域名或端口时无需批量更新数据库。
|
||
:::
|
||
|
||
## 查询分页
|
||
|
||
### 过滤条件
|
||
|
||
| 参数 | 类型 | 说明 |
|
||
|:----|:----|:----|
|
||
| `title` | 模糊搜索 | 文章标题,使用 `LIKE %keyword%` |
|
||
| `category_id` | 精确匹配 | 分类 ID |
|
||
| `status` | 精确匹配 | 文章状态:0-正常 1-下架 |
|
||
|
||
### 排序规则
|
||
|
||
按 `sort` 降序 → `id` 降序(即排序号越大越靠前,同排序号时新文章在前)。
|
||
|
||
### 分页回退容错
|
||
|
||
```python
|
||
# 若当前页超出末页(数据被并发删除),自动回退到第一页
|
||
if page > 1 and not article_list:
|
||
page = 1
|
||
count = query.count()
|
||
article_list = query.limit(limit).offset(0).all()
|
||
```
|
||
|
||
当用户停留在第 N 页时,如果其他用户删除了大量数据导致第 N 页为空,系统自动回退到第一页,避免返回空列表。
|
||
|
||
### 序列化处理
|
||
|
||
分页列表中每条记录会额外处理:
|
||
|
||
```python
|
||
data = item.to_dict()
|
||
# 1. 封面 URL 拼接
|
||
if data.get('cover'):
|
||
data['cover'] = get_file_url(data['cover'])
|
||
# 2. 图集解析为数组
|
||
data['images'] = _parse_images(data.get('images'))
|
||
# 3. 状态名称(数据字典映射)
|
||
data['statusName'] = get_dict_label('article_status', item.status)
|
||
```
|
||
|
||
## 查询详情
|
||
|
||
详情接口额外处理:
|
||
|
||
```python
|
||
data = article.to_dict()
|
||
# 富文本内容:[IMG_URL] 占位符替换为实际域名
|
||
data['content'] = (article.content or "").replace("[IMG_URL]", FASTAPI_FILE_URL)
|
||
# 封面 URL 拼接
|
||
if data.get('cover'):
|
||
data['cover'] = get_file_url(data['cover'])
|
||
# 图集:images 保留原始路径,imagesList 存完整 URL 数组
|
||
data['imagesList'] = _parse_images(data.get('images'))
|
||
# 状态名称
|
||
data['statusName'] = get_dict_label('article_status', article.status)
|
||
```
|
||
|
||
:::warning images vs imagesList
|
||
详情接口同时返回 `images`(逗号分隔原始路径)和 `imagesList`(完整 URL 数组)。前端展示用 `imagesList`,编辑回填用 `images`。
|
||
:::
|
||
|
||
## 新增文章
|
||
|
||
```python
|
||
def add_article(request, data: ArticleForm):
|
||
# 1. 处理封面:临时文件迁移到正式目录
|
||
cover = save_file(data.cover, "article") if data.cover else None
|
||
# 2. 处理图集:逗号分隔 URL 逐个迁移
|
||
images = _build_images(data.images)
|
||
# 3. 处理富文本:XSS 清洗 + 图片迁移 + 占位符替换
|
||
content = save_content(data.content, data.title, "article") if data.content else None
|
||
# 4. 插入数据库(排除表单中的原值,用处理后的值替代)
|
||
article_repo.create(
|
||
**data.model_dump(exclude={'id', 'cover', 'images', 'content'}),
|
||
cover=cover, images=images, content=content,
|
||
create_user=get_realname(request)
|
||
)
|
||
return R.ok(msg="添加成功")
|
||
```
|
||
|
||
:::tip 关键设计
|
||
`model_dump(exclude={'id', 'cover', 'images', 'content'})` 排除文件字段的原始值,再用处理后的值(`cover`/`images`/`content`)显式传入,避免临时 URL 直接存入数据库。
|
||
:::
|
||
|
||
## 更新文章
|
||
|
||
更新逻辑比新增更复杂,核心难点是**防空值覆盖**:
|
||
|
||
```python
|
||
def update_article(request, data: ArticleForm):
|
||
# 1. ID 校验
|
||
if not data.id or data.id <= 0:
|
||
return R.failed("记录ID不能为空")
|
||
article = article_repo.get_by_id(data.id)
|
||
if not article:
|
||
return R.failed("记录不存在")
|
||
|
||
# 2. 构造更新数据(排除 id;跳过 None,避免未传字段把旧值覆盖为 NULL)
|
||
updData = data.model_dump(exclude={'id'}, exclude_none=True)
|
||
|
||
# 3. 默认值字段特殊处理
|
||
for f in ('status', 'click', 'sort'):
|
||
if f not in data.model_fields_set:
|
||
updData.pop(f, None)
|
||
|
||
# 4. 文件字段:仅显式提供时更新
|
||
if data.cover is not None:
|
||
updData['cover'] = save_file(data.cover, "article") if data.cover else None
|
||
if data.images is not None:
|
||
updData['images'] = _build_images(data.images) if data.images else None
|
||
if data.content is not None:
|
||
updData['content'] = save_content(data.content, data.title, "article") if data.content else None
|
||
|
||
# 5. 更新数据库
|
||
result = article_repo.update(data.id, updData)
|
||
return R.ok(msg="更新成功") if result else R.failed("更新失败")
|
||
```
|
||
|
||
### 为什么需要 `exclude_none=True`?
|
||
|
||
前端编辑表单通常只传用户修改过的字段。未修改的字段可能为 `None`(Pydantic 可选字段默认值)。如果不跳过 `None` 值,会把数据库中的旧值覆盖为 `NULL`。
|
||
|
||
### 为什么需要 `model_fields_set` 判断?
|
||
|
||
`status`、`click`、`sort` 三个字段在 `ArticleForm` 中有默认值 `0`。即使前端不传,`model_dump()` 也会输出 `status=0`。如果直接更新,会把用户之前设置的状态(如 `1` 下架)意外覆盖为 `0`(正常)。
|
||
|
||
`data.model_fields_set` 记录了前端**显式传入**的字段集合。只有前端显式传了 `status`,才更新该字段。
|
||
|
||
```python
|
||
# 前端传了 { id: 1, title: "新标题" }(未传 status)
|
||
# data.model_fields_set = {'id', 'title'}
|
||
# status 不在其中 → 从 updData 中移除 → 不覆盖旧值
|
||
```
|
||
|
||
### 文件字段的清空语义
|
||
|
||
| 前端传值 | 行为 |
|
||
|:----|:----|
|
||
| `cover: "/temp/xxx.png"` | 迁移临时文件,更新封面 |
|
||
| `cover: ""` | 清空封面(置为 `None`) |
|
||
| `cover: null` 或不传 | 保留旧封面不变 |
|
||
|
||
图集和富文本同理。
|
||
|
||
## 删除文章
|
||
|
||
```python
|
||
def delete_article(article_id):
|
||
return BaseService.batch_delete_with_r(article_repo, article_id)
|
||
```
|
||
|
||
复用 `BaseService.batch_delete_with_r` 静态方法,支持单个 ID 或逗号分隔的多个 ID。软删除(`is_delete=1`),不物理删除数据。
|
||
|
||
## 状态变更
|
||
|
||
```python
|
||
def update_article_status(request, data: ArticleStatusForm):
|
||
article = article_repo.get_by_id(data.id)
|
||
if not article:
|
||
return R.failed("记录不存在")
|
||
result = article_repo.update(data.id, {
|
||
"status": data.status,
|
||
"update_user": get_realname(request),
|
||
"update_time": datetime.now(tz=timezone.utc)
|
||
})
|
||
return R.ok(msg="设置成功") if result else R.failed("设置失败")
|
||
```
|
||
|
||
独立的状态变更接口,只更新 `status` 字段,不影响其他字段。
|
||
|
||
## API 接口
|
||
|
||
| 接口 | 方法 | 权限节点 | 说明 |
|
||
|:----|:----|:----|:----|
|
||
| `/api/v1/article/page` | GET | `sys:article:page` | 分页查询(支持 title/category_id/status 过滤) |
|
||
| `/api/v1/article/detail/{id}` | GET | `sys:article:detail` | 详情查询(含富文本 URL 还原) |
|
||
| `/api/v1/article/add` | POST | `sys:article:add` | 新增文章(含文件迁移 + 富文本清洗) |
|
||
| `/api/v1/article/update` | PUT | `sys:article:update` | 编辑文章(防空值覆盖) |
|
||
| `/api/v1/article/status` | PUT | `sys:article:status` | 设置状态(正常/下架) |
|
||
| `/api/v1/article/delete/{id}` | DELETE | `sys:article:delete` | 单条删除 |
|
||
| `/api/v1/article/batchDelete` | DELETE | `sys:article:batchDelete` | 批量删除 |
|
||
|
||
## 数据字典
|
||
|
||
文章模块使用以下数据字典(需在字典管理中配置):
|
||
|
||
| 字典编码 | 用途 | 字典项示例 |
|
||
|:----|:----|:----|
|
||
| `article_status` | 文章状态名称 | 0=正常, 1=下架 |
|
||
|
||
分页列表和详情接口通过 `get_dict_label('article_status', item.status)` 将状态值转换为中文名称,输出到 `statusName` 字段。
|
||
|
||
## 前端对接要点
|
||
|
||
### 封面上传
|
||
|
||
前端使用上传组件,先上传到临时目录获得临时 URL,提交表单时将临时 URL 传给后端:
|
||
|
||
```javascript
|
||
// 上传成功后
|
||
form.cover = '/temp/20260907/abc.png' // 临时 URL
|
||
// 提交表单时,后端自动迁移到正式目录
|
||
```
|
||
|
||
### 图集管理
|
||
|
||
图集为逗号分隔的 URL 字符串,前端通常用数组管理:
|
||
|
||
```javascript
|
||
// 编辑回填:将逗号分隔字符串转为数组
|
||
form.imagesList = detail.imagesList // 后端已解析为数组
|
||
// 提交时:将数组转为逗号分隔字符串
|
||
form.images = form.imagesList.join(',')
|
||
```
|
||
|
||
### 富文本编辑器
|
||
|
||
推荐使用 TinyMCE 或 wangEditor,注意:
|
||
- 编辑器上传图片也走临时目录,内容提交后后端统一迁移
|
||
- 详情接口返回的 `content` 已将 `[IMG_URL]` 替换为完整 URL,可直接渲染
|
||
- 编辑回填时,编辑器接收的是已还原的完整 HTML
|
||
|
||
## 总结
|
||
|
||
文章管理模块是项目中典型的「带文件处理」复杂业务模块,核心特点:
|
||
|
||
```
|
||
1. 自定义 Service:未继承 BaseService,文件处理/富文本清洗/图集解析均为自定义逻辑
|
||
2. 三类文件字段:封面(save_file)、图集(_build_images/_parse_images)、富文本(save_content)
|
||
3. 临时文件迁移:前端上传到临时目录 → 提交表单后后端迁移到正式目录
|
||
4. XSS 防护:富文本经 bleach 白名单清洗 + CSS 表达式过滤
|
||
5. [IMG_URL] 占位符:数据库不存完整域名,查询时动态替换,便于域名迁移
|
||
6. 防空值覆盖:exclude_none=True + model_fields_set 判断,避免未传字段覆盖旧值
|
||
7. 分页回退:并发删除导致当前页为空时,自动回退第一页
|
||
8. 数据字典:状态名称通过 get_dict_label 动态映射,不硬编码
|
||
```
|