:::tip 文章管理 文章管理模块支持富文本编辑、分类关联、封面图片上传、图集管理等功能。适用于新闻、公告、帮助文档等内容管理场景。本模块是典型的「带文件处理」业务模块,涵盖了封面迁移、图集处理、富文本 XSS 清洗等核心机制。 ::: ## 模块结构 ``` src/modules/cms/article/ ├── models.py # 文章模型 ├── schema.py # 表单验证(WTForms) ├── repository.py # 数据访问层 ├── service.py # 业务逻辑层 └── router.py # 路由控制器 ``` :::warning 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(非本站文件)不处理,返回空字符串 :::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 白名单清洗(移除