Files
djangoadmin-wiki/DjangoAdmin_FastAPI_NaiveVue/zh/volume4/business/article.md
T
2026-09-12 23:12:15 +08:00

437 lines
16 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.
:::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 动态映射,不硬编码
```