Skip to content

文章管理

文章管理模块支持富文本编辑、分类关联、封面图片上传、图集管理等功能。适用于新闻、公告、帮助文档等内容管理场景。本模块是典型的「带文件处理」业务模块,涵盖了封面迁移、图集处理、富文本 XSS 清洗等核心机制。

模块结构 ​

src/modules/cms/article/
├── models.py     # 文章模型
├── schema.py     # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py    # 业务逻辑层
└── router.py     # 路由控制器

Service 设计

文章模块涉及文件处理、富文本清洗、图集解析等复杂逻辑。可选择继承 BaseService 并覆写钩子方法,或编写独立的 service 函数。简单 CRUD 模块(如 Param、Position)继承 BaseService 即可。

文章模型 ​

python
# src/modules/article/models.py
class Article(BaseModel, BaseDB):
    """文章模型类"""
    __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
# src/modules/cms/article/schema.py
from wtforms import StringField, IntegerField, TextAreaField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm


class ArticleForm(BaseForm):
    """文章创建/编辑表单"""
    categoryId = IntegerField('分类ID', validators=[Optional(), NumberRange(min=0)])
    title = StringField('文章标题', validators=[
        DataRequired(message='文章标题不能为空'),
        Length(max=150, message='文章标题长度不得超过150个字符')
    ])
    cover = StringField('文章封面', validators=[Optional(), Length(max=255)])
    images = StringField('文章图集', validators=[Optional()])
    intro = StringField('文章导读', validators=[Optional(), Length(max=255)])
    content = TextAreaField('文章内容', validators=[Optional()])
    author = StringField('文章作者', validators=[Optional(), Length(max=100)])
    status = IntegerField('文章状态', validators=[
        Optional(), NumberRange(min=0, max=1, message='文章状态值在0~1之间')
    ])
    sort = IntegerField('文章排序', validators=[
        Optional(), NumberRange(min=0, max=99999)
    ])
字段必填约束说明
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(BaseForm):
    """文章状态更新表单"""
    id = IntegerField('文章ID', validators=[
        DataRequired(message='文章ID不能为空'),
        NumberRange(min=1, message='文章ID必须大于0')
    ])
    status = IntegerField('文章状态', validators=[
        DataRequired(message='文章状态不能为空'),
        NumberRange(min=0, max=1, message='文章状态值在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(非本站文件)不处理,返回空字符串

防路径穿越

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 中的文件访问前缀替换为 [IMG_URL] 占位符存库

为什么用 [IMG_URL] 占位符?

数据库中不存完整 URL(如 http://localhost:8021/upload/article/...),而是存 [IMG_URL]/article/...。查询详情时再将 [IMG_URL] 替换为实际的文件访问 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]", 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)

images vs imagesList

详情接口同时返回 images(逗号分隔原始路径)和 imagesList(完整 URL 数组)。前端展示用 imagesList,编辑回填用 images。

新增文章 ​

python
def add_article(data: dict):
    """新增文章"""
    # 1. 表单校验
    form = ArticleForm(data=data)
    if not form.validate():
        raise BizError(regular.get_err(form))

    # 2. 处理封面:临时文件迁移到正式目录
    cover = save_file(form.data.get('cover'), "article") if form.data.get('cover') else None
    # 3. 处理图集:逗号分隔 URL 逐个迁移
    images = _build_images(form.data.get('images'))
    # 4. 处理富文本:XSS 清洗 + 图片迁移 + 占位符替换
    content = save_content(form.data.get('content'), form.data.get('title'), "article") if form.data.get('content') else None

    # 5. 插入数据库(用处理后的值替代原始值)
    ArticleRepository.create(
        category_id=form.data.get('categoryId'),
        title=form.data.get('title'),
        cover=cover,
        images=images,
        intro=form.data.get('intro'),
        content=content,
        author=form.data.get('author'),
        status=form.data.get('status', 0),
        sort=form.data.get('sort', 0),
        create_user=get_realname(),
        update_user=get_realname(),
    )

关键设计

文件字段(cover/images/content)经过处理后再传入 create(),避免临时 URL 直接存入数据库。非文件字段直接从表单数据中取值。

更新文章 ​

更新逻辑比新增更复杂,核心难点是防空值覆盖:

python
def update_article(data: dict):
    """
    更新文章

    处理流程:
        1. ID 校验
        2. 表单校验
        3. 文件字段处理(仅显式提供时更新)
        4. 更新数据库
    """
    # 1. 表单校验
    form = ArticleForm(data=data)
    if not form.validate():
        raise BizError(regular.get_err(form))

    # 2. ID 校验
    article_id = form.data.get("id")
    if not article_id or int(article_id) <= 0:
        raise BizError("记录ID不能为空")
    article = ArticleRepository.get_by_id(article_id)
    if not article:
        raise BizError("记录不存在")

    # 3. 构造更新数据(过滤掉非模型字段)
    update_data = {}
    for key, value in form.data.items():
        if key in ('id', 'cover', 'images', 'content'):
            continue  # 文件字段单独处理
        if value is not None:
            update_data[key] = value

    # 4. 文件字段:仅前端显式传值时更新
    if form.data.get('cover') is not None:
        update_data['cover'] = save_file(form.data['cover'], "article") if form.data['cover'] else None
    if form.data.get('images') is not None:
        update_data['images'] = _build_images(form.data['images']) if form.data['images'] else None
    if form.data.get('content') is not None:
        update_data['content'] = save_content(form.data['content'], form.data.get('title'), "article") if form.data['content'] else None

    # 5. 更新数据库
    result = ArticleRepository.update_by_id(article_id, update_data)
    if not result:
        raise BizError("更新失败")

为什么需要跳过 None 值? ​

前端编辑表单通常只传用户修改过的字段。未修改的字段在请求体中可能为 null(JSON)或不存在。如果不跳过 None 值,会把数据库中的旧值覆盖为 NULL。因此只更新前端显式传入的非空字段。

文件字段的清空语义 ​

前端传值行为
cover: "/temp/xxx.png"迁移临时文件,更新封面
cover: ""清空封面(置为 None)
cover: null 或不传保留旧封面不变

图集和富文本同理。

删除文章 ​

python
# 通过 BaseService 的 remove 方法实现批量软删除
delete_articles = ArticleService.remove

复用 BaseService.remove() 方法,支持单个 ID 或逗号分隔的多个 ID。软删除(is_delete=1),不物理删除数据。

状态变更 ​

python
def update_article_status(data: dict):
    """更新文章状态"""
    form = ArticleStatusForm(data=data)
    if not form.validate():
        raise BizError(regular.get_err(form))

    article_id = form.data.get("id")
    if not ArticleRepository.exists(article_id):
        raise BizError("记录不存在")

    result = ArticleRepository.update_status(article_id, form.data.get("status"))
    if not result:
        raise BizError("设置失败")

独立的状态变更接口,只更新 status 字段,不影响其他字段。

API 接口 ​

接口方法权限节点说明
/api/v1/article/pageGETsys:article:page分页查询(支持 title/category_id/status 过滤)
/api/v1/article/detail/{id}GETsys:article:detail详情查询(含富文本 URL 还原)
/api/v1/article/addPOSTsys:article:add新增文章(含文件迁移 + 富文本清洗)
/api/v1/article/updatePUTsys:article:update编辑文章(防空值覆盖)
/api/v1/article/statusPUTsys:article:status设置状态(正常/下架)
/api/v1/article/delete/{id}DELETEsys:article:delete单条删除
/api/v1/article/batchDeleteDELETEsys: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. 文件处理:三类文件字段(封面 save_file、图集 _build_images/_parse_images、富文本 save_content)
2. 临时文件迁移:前端上传到临时目录 → 提交表单后后端迁移到正式目录
3. XSS 防护:富文本经 bleach 白名单清洗 + CSS 表达式过滤
4. [IMG_URL] 占位符:数据库不存完整域名,查询时动态替换,便于域名迁移
5. 防空值覆盖:更新时跳过 None 值,避免未传字段覆盖旧值
6. 分页回退:并发删除导致当前页为空时,自动回退第一页
7. 数据字典:状态名称通过 dict_utils 动态映射,不硬编码

小蚂蚁云团队 · 提供技术支持