:::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 数组,可直接用于 `
` 渲染。
### 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 白名单清洗(移除