签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -0,0 +1,477 @@
:::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 数组,可直接用于 `<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] 占位符存库
```
:::tip 为什么用 [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)
```
:::warning 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(),
)
```
:::tip 关键设计
文件字段(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/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. 文件处理:三类文件字段(封面 save_file、图集 _build_images/_parse_images、富文本 save_content)
2. 临时文件迁移:前端上传到临时目录 → 提交表单后后端迁移到正式目录
3. XSS 防护:富文本经 bleach 白名单清洗 + CSS 表达式过滤
4. [IMG_URL] 占位符:数据库不存完整域名,查询时动态替换,便于域名迁移
5. 防空值覆盖:更新时跳过 None 值,避免未传字段覆盖旧值
6. 分页回退:并发删除导致当前页为空时,自动回退第一页
7. 数据字典:状态名称通过 dict_utils 动态映射,不硬编码
```
@@ -0,0 +1,130 @@
:::tip 分类管理
分类管理模块用于管理树形分类数据,如文章分类、产品分类等。通过 `parent_id` 字段实现无限级分类。
:::
## 模块结构
```
src/modules/cms/category/
├── models.py # 分类模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层
└── router.py # 路由控制器
```
## 分类模型
```python
# src/modules/category/models.py
# ============================================================
# 文章分类模型
# ============================================================
class Category(BaseModel, BaseDB):
"""文章分类模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "category"
__table_comment__ = "文章分类表"
# ============================================================
# 字段定义
# ============================================================
# 分类名称
category_name = Column(String(100), nullable=False, index=True, comment="分类名称")
# 分类编码
category_code = Column(String(100), nullable=True, comment="分类编码")
# 上级ID
parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级ID")
# 排序
sort = Column(Integer, default=0, server_default=text('0'), comment="排序")
# 分类备注
note = Column(String(100), nullable=True, comment="分类备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回分类名称"""
return '文章分类{}'.format(self.category_name)
```
:::tip 字段说明
- `category_name`:分类名称,带索引用于模糊搜索
- `category_code`:分类编码,可选字段,用于程序化标识分类
- `parent_id`:上级分类 ID,0 表示顶级分类
:::
## 树形结构
分类通过 `parent_id` 字段形成树形结构:
```
文章分类 (parent_id=0)
├── 技术文章 (parent_id=1)
│ ├── 前端技术 (parent_id=2)
│ └── 后端技术 (parent_id=2)
├── 产品动态 (parent_id=1)
└── 公司新闻 (parent_id=1)
```
## 构建分类树
分类树构建采用与菜单树相同的非递归方案(`children_map` 分组),避免深层递归性能问题:
```python
def get_tree(data, parent_id):
"""获取树状结构(非递归,children_map 分组)"""
children_map = {}
for item in data:
pid = item["parentId"]
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(item)
def build(pid):
result = []
for item in children_map.get(pid, []):
item["children"] = build(item["id"])
result.append(item)
return result
return build(parent_id)
```
## API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/category/list` | GET | `sys:category:list` | 分类列表 |
| `/api/v1/category/detail/{id}` | GET | `sys:category:detail` | 分类详情 |
| `/api/v1/category/add` | POST | `sys:category:add` | 新增分类 |
| `/api/v1/category/update` | PUT | `sys:category:update` | 编辑分类 |
| `/api/v1/category/delete/{id}` | DELETE | `sys:category:delete` | 删除分类 |
## 前端使用
分类选择通常使用 el-tree 或 el-cascader 组件:
```vue
<!-- 树形选择 -->
<el-tree-select
v-model="form.category_id"
:data="categoryTree"
:props="{ label: 'name', value: 'id' }"
/>
```
## 总结
分类管理模块具备以下特点:
```
1. 树形结构:parent_id 实现无限级分类,非递归构建树
2. 通用设计:适用于文章分类、产品分类等场景
3. 分类编码:category_code 字段支持程序化标识
4. 排序控制:sort 字段控制分类显示顺序
5. 前端集成:el-tree-select 或 el-cascader 组件
6. 继承 BaseService:声明 repository/form/model_name 即可获得完整 CRUD 能力
```
@@ -0,0 +1,197 @@
:::tip 行政区划
行政区划模块管理全国省/市/区/街道四级区域数据,支持树形结构查询、级联选择器、拼音自动生成等功能。用户表通过 `province_code`/`city_code`/`district_code`/`street_code` 四个字段关联城市表的 `area_code`。
:::
## 模块结构
```
src/modules/city/
├── models.py # 城市模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
```
## 城市模型
```python
# src/modules/city/models.py
class City(BaseModel, BaseDB):
"""
城市区域模型
支持省、市、县区、街道四级区域结构。
"""
__tablename__ = DB_PREFIX + "city"
__table_comment__ = "城市表"
# 城市区号
city_code = db.Column(db.String(6), nullable=False, index=True, comment="城市区号")
# 行政编码(用于用户表关联)
area_code = db.Column(db.String(20), nullable=False, index=True, comment="行政编码")
# 上级行政编码
parent_code = db.Column(db.String(20), nullable=False, index=True, comment="上级行政编码")
# 邮政编码
zip_code = db.Column(db.String(6), nullable=False, comment="邮政编码")
# 城市级别:0-省份 1-城市 2-县区 3-街道 4-社区
level = db.Column(db.Integer, default=0, server_default=text('0'), index=True,
comment="城市级别:0-省份 1-城市 2-县区 3-街道 4-社区")
# 上级城市ID
pid = db.Column(db.Integer, default=0, server_default=text('0'), index=True, comment="上级城市ID")
# 城市名称
name = db.Column(db.String(150), nullable=False, comment="城市名称")
# 城市简称
short_name = db.Column(db.String(150), nullable=False, index=True, comment="城市简称")
# 城市全称(沿 pid 链拼接,如"台湾省,云林,元长,华鹿")
full_name = db.Column(db.String(150), nullable=True, comment="城市全称")
# 城市拼音(由简称自动生成,如"TaiWan")
pinyin = db.Column(db.String(150), nullable=True, comment="城市拼音")
# 经纬度
lng = db.Column(db.String(150), nullable=True, comment="城市经度")
lat = db.Column(db.String(150), nullable=True, comment="城市纬度")
```
## 服务层特色
城市服务继承 `BaseService`,并通过钩子实现自动派生字段:
```python
# src/modules/city/service.py
class CityService(BaseService):
repository = CityRepository
form = CityForm
model_name = "城市"
@classmethod
def before_add(cls, form):
"""新增前钩子:自动补全 full_name / pinyin"""
data = form.data
return {
"full_name": cls._build_full_name(data.get("pid") or 0, data.get("name")),
"pinyin": cls._build_pinyin(data.get("shortName") or data.get("name")),
}
@classmethod
def before_delete(cls, ids):
"""删除前校验:检查是否存在子级城市"""
for cid in ids:
if CityRepository.count_children(cid, parent_field='pid') > 0:
raise BizError(f"城市ID {cid} 存在子级城市,请先删除子级城市")
@classmethod
def _detail_dict(cls, obj):
"""详情序列化:追加 parentName(父级城市名称)"""
data = model_to_dict(obj)
parent_id = getattr(obj, 'pid', None)
if parent_id:
parent = CityRepository.get_by_id(parent_id)
data['parentName'] = parent.name if parent else None
else:
data['parentName'] = None
return data
```
### 自动生成字段
| 字段 | 生成规则 | 示例 |
|:---|:---|:---|
| `full_name` | 沿 pid 链向上拼接,逗号分隔 | `台湾省,云林,元长,华鹿` |
| `pinyin` | 由简称生成无音调拼音,每音节首字母大写 | `TaiWan`、`HuaLu` |
### 删除前校验
城市采用树形结构(`pid` 字段),删除前检查是否存在子级城市:
```
广东省 (pid=0)
├── 深圳市 (pid=广东省.id)
│ ├── 南山区 (pid=深圳市.id)
│ └── 福田区 (pid=深圳市.id)
└── 广州市 (pid=广东省.id)
```
删除「深圳市」前,会检查其下是否存在「南山区」「福田区」等子级城市。
## 树形数据查询
城市模块提供树形数据接口,用于前端级联选择器:
```python
def get_city_tree():
"""获取城市树形数据,构建省-市-区三级树形结构"""
all_cities = CityRepository.list_all()
city_dicts = models_to_list(all_cities)
return _build_city_tree(city_dicts)
def _build_city_tree(city_list, parent_id=0):
"""递归构建城市树形结构"""
tree = []
for city in city_list:
if city.get("pid") == parent_id:
node = {
"id": city.get("id"),
"name": city.get("name"),
"code": city.get("area_code"),
"level": city.get("level"),
"children": _build_city_tree(city_list, city.get("id")),
}
if not node["children"]:
del node["children"]
tree.append(node)
return tree
```
## 与用户模块的关联
用户表通过四个字段关联城市表:
```python
# src/modules/system/user/models.py
class User(BaseModel, BaseDB):
# 省份编码
province_code = db.Column(db.String(30), nullable=False, comment="省份编码")
# 城市编码
city_code = db.Column(db.String(30), nullable=False, comment="城市编码")
# 县区编码
district_code = db.Column(db.String(30), nullable=False, comment="县区编码")
# 街道编码
street_code = db.Column(db.String(30), nullable=False, comment="街道编码")
# 省市区信息(拼接后的可读文本,如"广东省深圳市南山区")
city_info = db.Column(db.String(255), nullable=True, comment="省市区信息")
```
用户新增/更新时,前端通过级联选择器传入 `[省编码, 市编码, 区编码, 街道编码]` 数组,Service 层拆分到独立字段:
```python
# src/modules/system/user/service.py
def _apply_city(create_data, city):
"""将行政区划数组拆分到对应的字段"""
create_data["province_code"] = city[0]
create_data["city_code"] = city[1]
create_data["district_code"] = city[2]
create_data["street_code"] = city[3]
```
## API 接口
| 方法 | 路径 | 权限节点 | 说明 |
|:---|:---|:---|:---|
| GET | `/api/v1/city/list` | `sys:city:list` | 城市列表(支持 pid/name 筛选) |
| GET | `/api/v1/city/detail/<id>` | `sys:city:detail` | 城市详情 |
| GET | `/api/v1/city/child/<code>` | - | 子级城市列表(级联选择器用) |
| GET | `/api/v1/city/tree` | - | 城市树形数据 |
| POST | `/api/v1/city/add` | `sys:city:add` | 新增城市 |
| PUT | `/api/v1/city/update` | `sys:city:update` | 编辑城市 |
| DELETE | `/api/v1/city/delete/<id>` | `sys:city:delete` | 删除城市 |
## 数据字典
| 字典编码 | 用途 | 字典项示例 |
|:---|:---|:---|
| `city_level` | 城市级别名称 | 0=省份, 1=城市, 2=县区, 3=街道, 4=社区 |
## 总结
行政区划模块采用树形结构(`pid` 字段),支持省/市/区/街道四级管理。`before_add` 钩子自动补全 `full_name`(沿 pid 链拼接)和 `pinyin`(由简称生成),`before_delete` 钩子校验子级城市引用。用户表通过 `area_code` 字段关联城市表,前端使用级联选择器。
@@ -0,0 +1,201 @@
:::tip 公告/链接/城市
公告、链接、城市是三个简单 CRUD 内容模块,均继承 BaseService,提供标准的增删改查接口。适用于系统公告、友情链接、城市数据等场景。
:::
## 公告管理
### 公告模型
```python
# src/modules/notice/models.py
# ============================================================
# 通知公告模型
# ============================================================
class Notice(BaseModel, BaseDB):
"""通知公告模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "notice"
__table_comment__ = "通知公告表"
# ============================================================
# 字段定义
# ============================================================
# 通知标题
title = Column(String(255), nullable=False, index=True, comment="通知标题")
# 通知封面
cover = Column(String(255), nullable=True, comment="通知封面")
# 通知类型:1-通知 2-公告
type = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知类型:1-通知 2-公告")
# 点击率
click_num = Column(Integer, default=0, server_default=text('0'), comment="点击率")
# 通知状态:1-正常 2-停用
status = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知状态:1-正常 2-停用")
# 通知公告内容
content = Column(Text, nullable=True, comment="通知公告内容")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回通知标题作为字符串表示"""
return "通知公告{}".format(self.title)
```
### API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/notice/list` | GET | `sys:notice:list` | 公告列表 |
| `/api/v1/notice/add` | POST | `sys:notice:add` | 新增公告 |
| `/api/v1/notice/update` | PUT | `sys:notice:update` | 编辑公告 |
| `/api/v1/notice/delete/{id}` | DELETE | `sys:notice:delete` | 删除公告 |
## 链接管理
### 链接模型
```python
# src/modules/link/models.py
# ============================================================
# 通知公告模型
# ============================================================
class Notice(BaseModel, BaseDB):
"""通知公告模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "notice"
__table_comment__ = "通知公告表"
# ============================================================
# 字段定义
# ============================================================
# 通知标题
title = Column(String(255), nullable=False, index=True, comment="通知标题")
# 通知封面
cover = Column(String(255), nullable=True, comment="通知封面")
# 通知类型:1-通知 2-公告
type = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知类型:1-通知 2-公告")
# 点击率
click_num = Column(Integer, default=0, server_default=text('0'), comment="点击率")
# 通知状态:1-正常 2-停用
status = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知状态:1-正常 2-停用")
# 通知公告内容
content = Column(Text, nullable=True, comment="通知公告内容")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回通知标题作为字符串表示"""
return "通知公告{}".format(self.title)
```
### API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/link/list` | GET | `sys:link:list` | 链接列表 |
| `/api/v1/link/add` | POST | `sys:link:add` | 新增链接 |
| `/api/v1/link/update` | PUT | `sys:link:update` | 编辑链接 |
| `/api/v1/link/delete/{id}` | DELETE | `sys:link:delete` | 删除链接 |
## 城市管理
### 城市模型
```python
# src/modules/city/models.py
# ============================================================
# 城市模型
# ============================================================
class City(BaseModel, BaseDB):
"""城市/行政区划模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "city"
__table_comment__ = "城市行政区划表"
# ============================================================
# 字段定义
# ============================================================
# 城市区号
city_code = Column(String(6), nullable=False, index=True, comment="城市区号")
# 行政编码
area_code = Column(String(20), nullable=False, index=True, comment="行政编码")
# 上级行政编码(顶级/省级无上级,为 NULL)
parent_code = Column(String(20), nullable=True, index=True, comment="上级行政编码")
# 邮政编码
zip_code = Column(String(6), nullable=False, comment="邮政编码")
# 城市级别:0-省份 1-城市 2-县区 3-街道 4-社区
level = Column(Integer, default=0, server_default=text('0'), index=True, comment="城市级别:0-省份 1-城市 2-县区 3-街道 4-社区")
# 上级城市ID
pid = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级城市ID")
# 城市名称
name = Column(String(150), nullable=False, index=True, comment="城市名称")
# 城市简称
short_name = Column(String(150), nullable=False, comment="城市简称")
# 城市全称
full_name = Column(String(150), nullable=True, comment="城市全称")
# 城市拼音
pinyin = Column(String(150), nullable=True, comment="城市拼音")
# 城市经度
lng = Column(String(150), nullable=True, comment="城市经度")
# 城市纬度
lat = Column(String(150), nullable=True, comment="城市纬度")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回城市ID作为字符串表示"""
return '城市{}'.format(self.id)
```
### 树形结构
城市通过 `parent_code` 字段形成五级行政区划树形结构(省-市-区-街道-社区),`level` 字段标识级别:
```
中国 (parent_code=null, level=0)
├── 广东省 (parent_code=100000, level=0)
│ ├── 深圳市 (parent_code=440000, level=1)
│ │ ├── 南山区 (parent_code=440300, level=2)
│ │ │ └── 粤海街道 (parent_code=440305, level=3)
│ │ └── 福田区 (parent_code=440300, level=2)
│ └── 广州市 (parent_code=440000, level=1)
└── 浙江省 (parent_code=100000, level=0)
└── 杭州市 (parent_code=330000, level=1)
```
:::tip 行政区划与用户关联
用户表通过 `province_code`、`city_code`、`district_code`、`street_code` 四个字段关联城市表的 `area_code`,`city_info` 字段存储拼接后的城市名称(如"广东省深圳市南山区")。
:::
## 通用模式
三个模块均遵循相同的开发模式:
```
1. models.py:继承 BaseModel + BaseDB
2. schema.py:继承 BaseForm,定义 WTForms 字段约束
3. repository.py:继承 BaseRepository,实现 page / list_enabled
4. service.py:继承 BaseService,声明 repository / form / model_name
5. router.py:Blueprint + @check_permission + @check_demo 装饰器
```
## 总结
内容管理模块具备以下特点:
```
1. 简单 CRUD:继承 BaseService,声明差异点即可获得完整接口
2. 公告管理:支持富文本内容、状态管理
3. 链接管理:友情链接、合作伙伴链接,支持 Logo 图片
4. 城市管理:省/市/区/街道/社区五级行政区划,含经纬度、拼音等丰富字段
5. 统一模式:models/schema/repository/service/router 五文件结构
6. 模块级代理函数:service 末尾暴露代理函数供 router 调用
```
@@ -0,0 +1,405 @@
:::tip 部门/职级/岗位
部门、职级、岗位是组织架构管理的三个核心模块。部门采用树形结构,职级和岗位为简单 CRUD 模块,均继承 `BaseService`。用户表通过 `dept_id`、`position_id`、`level_id` 三个外键关联这三个模块。
:::
## 部门管理
### 部门模型
```python
# src/modules/system/dept/models.py
# ============================================================
# 部门模型
# ============================================================
class Dept(BaseModel, BaseDB):
"""部门模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "dept"
__table_comment__ = "部门表"
# ============================================================
# 字段定义
# ============================================================
# 部门名称
name = Column(String(150), nullable=False, index=True, comment="部门名称")
# 部门类型:1-公司 2-子公司 3-部门 4-小组
type = Column(Integer, default=0, server_default=text('0'), comment="部门类型:1-公司 2-子公司 3-部门 4-小组")
# 上级部门ID
parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级部门ID")
# 部门排序
sort = Column(Integer, default=0, server_default=text('0'), comment="部门排序")
# 备注
note = Column(String(255), nullable=True, comment="备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回部门名称作为字符串表示"""
return '部门{}'.format(self.name)
```
### 树形结构
部门通过 `parent_id` 字段形成树形结构,与菜单类似:
```
公司总部 (parent_id=0)
├── 技术部 (parent_id=1)
│ ├── 前端组 (parent_id=2)
│ └── 后端组 (parent_id=2)
└── 市场部 (parent_id=1)
```
### 删除前校验
部门删除时需检查是否存在下级部门或用户引用,避免组织架构悬空:
```python
# src/modules/system/dept/service.py
from src.common.base_service import BaseService
from src.core.exceptions import BizError
from src.modules.system.dept.repository import DeptRepository
from src.modules.system.user.repository import UserRepository
class DeptService(BaseService):
"""部门服务类"""
repository = DeptRepository
form = DeptForm
model_name = "部门"
@classmethod
def before_delete(cls, ids):
"""
删除前校验:检查下级部门和用户引用
Args:
ids: 待删除的部门ID列表
Raises:
BizError: 存在下级部门或用户引用时抛出
"""
for did in ids:
# 检查是否存在下级部门
if DeptRepository.count_children(did) > 0:
raise BizError(f"部门ID {did} 存在下级部门,请先删除下级部门")
# 检查是否存在用户引用
if UserRepository.count_by({'dept_id': did}) > 0:
raise BizError(f"部门ID {did} 已被用户引用,请先调整用户部门")
```
## 职级管理
### 职级模型
```python
# src/modules/system/level/models.py
# ============================================================
# 职级模型
# ============================================================
class Level(BaseModel, BaseDB):
"""职级模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "level"
__table_comment__ = "职级表"
# ============================================================
# 字段定义
# ============================================================
# 职级名称
name = Column(String(255), nullable=False, index=True, comment="职级名称")
# 职级状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="职级状态:1-在用 2-停用")
# 职级排序
sort = Column(Integer, default=0, server_default=text('0'), comment="职级排序")
# ============================================================
# 内置方法
# ============================================================
def __repr__(self):
"""返回职级名称作为字符串表示"""
return '职级:{}'.format(self.name)
```
### 表单验证
```python
# src/modules/system/level/schema.py
from wtforms import StringField, IntegerField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm
from src.modules.system.level.repository import LevelRepository
class LevelForm(BaseForm):
"""职级创建/编辑表单"""
name = StringField('职级名称', validators=[
DataRequired(message='职级名称不能为空'),
Length(max=150, message='职级名称长度不得超过150个字符')
])
status = IntegerField('职级状态', validators=[
DataRequired(message='职级状态不能为空'),
NumberRange(min=1, max=2, message='职级状态值在1~2之间')
])
sort = IntegerField('职级排序', validators=[
Optional(),
NumberRange(min=0, max=99999, message='职级排序值在0~99999之间')
])
def validate_name(self, field):
"""职级名称唯一性校验"""
if not field.data:
return
current_id = self.id.data if self.id.data else None
if LevelRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("职级名称已存在")
class LevelStatusForm(BaseForm):
"""职级状态更新表单"""
id = IntegerField('职级ID', validators=[
DataRequired(message='职级ID不能为空'),
NumberRange(min=1, message='职级ID必须大于0')
])
status = IntegerField('职级状态', validators=[
DataRequired(message='职级状态不能为空'),
NumberRange(min=1, max=2, message='职级状态值在1~2之间')
])
```
### 业务服务
职级模块继承 `BaseService`,声明差异点即可获得完整 CRUD 能力。删除前校验用户引用,支持 Excel 导入导出:
```python
# src/modules/system/level/service.py
from src.common.base_service import BaseService
from src.core.exceptions import BizError
from src.modules.system.level.schema import LevelForm, LevelStatusForm
from src.modules.system.level.repository import LevelRepository
from src.modules.system.user.repository import UserRepository
class LevelService(BaseService):
"""职级服务类,继承 BaseService 获得标准 CRUD 能力"""
repository = LevelRepository
form = LevelForm
status_form = LevelStatusForm
model_name = "职级"
@classmethod
def before_delete(cls, ids):
"""删除前校验:存在用户引用该职级时禁止删除"""
for lid in ids:
if UserRepository.count_by({'level_id': lid}) > 0:
raise BizError(f"职级ID {lid} 已被用户引用,无法删除")
# 模块级代理函数
get_level_page = LevelService.get_page
get_level_detail = LevelService.get_detail
add_level = LevelService.add
update_level = LevelService.update
delete_levels = LevelService.remove
update_level_status = LevelService.update_status
get_level_list = LevelService.get_list
```
## 岗位管理
### 岗位模型
```python
# src/modules/system/position/models.py
# ============================================================
# 岗位模型
# ============================================================
class Position(BaseModel, BaseDB):
"""岗位模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "position"
__table_comment__ = "岗位表"
# ============================================================
# 字段定义
# ============================================================
# 岗位名称
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
# ============================================================
# 内置方法
# ============================================================
def __repr__(self):
"""返回岗位名称作为字符串表示"""
return '岗位:{}'.format(self.name)
```
### 表单验证
```python
# src/modules/system/position/schema.py
from wtforms import StringField, IntegerField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm
from src.modules.system.position.repository import PositionRepository
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(max=150, message='岗位名称长度不得超过150个字符')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
])
sort = IntegerField('岗位排序', validators=[
Optional(),
NumberRange(min=0, max=99999, message='岗位排序值在0~99999之间')
])
def validate_name(self, field):
"""岗位名称唯一性校验"""
if not field.data:
return
current_id = self.id.data if self.id.data else None
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("岗位名称已存在")
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField('岗位ID', validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
])
```
### 数据访问层
```python
# src/modules/system/position/repository.py
from src.common.base_repository import BaseRepository
from src.common.constants.common import PAGE_SIZE
from src.modules.system.position.models import Position
class PositionRepository(BaseRepository):
"""岗位数据仓储,继承 BaseRepository 获得通用 CRUD 能力"""
model = Position
@classmethod
def page(cls, params: dict):
"""分页查询"""
return cls.find_page(
page=int(params.get('pageNo', 1)),
page_size=int(params.get('pageSize', PAGE_SIZE)),
filters={'status': params.get('status')},
like_filters={'name': params.get('name')},
order_by='sort',
)
@classmethod
def list_enabled(cls, params=None):
"""查询启用的岗位列表"""
params = params or {}
return cls.find_list(
filters={'status': 1},
like_filters={'name': params.get('name')},
order_by='sort',
)
```
### 业务服务
```python
# src/modules/system/position/service.py
from src.common.base_service import BaseService
from src.core.exceptions import BizError
from src.modules.system.position.schema import PositionForm, PositionStatusForm
from src.modules.system.position.repository import PositionRepository
from src.modules.system.user.repository import UserRepository
class PositionService(BaseService):
"""岗位服务类,继承 BaseService 获得标准 CRUD 能力"""
repository = PositionRepository
form = PositionForm
status_form = PositionStatusForm
model_name = "岗位"
@classmethod
def before_delete(cls, ids):
"""删除前校验:存在用户引用该岗位时禁止删除"""
for pid in ids:
if UserRepository.count_by({'position_id': pid}) > 0:
raise BizError(f"岗位ID {pid} 已被用户引用,无法删除")
# 模块级代理函数
get_position_page = PositionService.get_page
get_position_detail = PositionService.get_detail
add_position = PositionService.add
update_position = PositionService.update
delete_positions = PositionService.remove
update_position_status = PositionService.update_status
get_position_list = PositionService.get_list
```
## 通用 CRUD 接口
职级和岗位模块继承 BaseService,自动生成标准接口:
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/level/page` | GET | `sys:level:page` | 职级分页列表 |
| `/api/v1/level/list` | GET | `sys:level:list` | 职级列表(无分页) |
| `/api/v1/level/detail/{id}` | GET | `sys:level:detail` | 职级详情 |
| `/api/v1/level/add` | POST | `sys:level:add` | 新增职级 |
| `/api/v1/level/update` | PUT | `sys:level:update` | 编辑职级 |
| `/api/v1/level/delete/{id}` | DELETE | `sys:level:delete` | 删除职级 |
| `/api/v1/level/batchDelete` | DELETE | `sys:level:batchDelete` | 批量删除 |
| `/api/v1/level/status` | PUT | `sys:level:status` | 设置状态 |
| `/api/v1/level/import` | POST | `sys:level:import` | 导入 Excel |
| `/api/v1/level/export` | GET | `sys:level:export` | 导出 Excel |
| `/api/v1/position/page` | GET | `sys:position:page` | 岗位分页列表 |
| `/api/v1/position/list` | GET | `sys:position:list` | 岗位列表(无分页) |
| `/api/v1/position/detail/{id}` | GET | `sys:position:detail` | 岗位详情 |
| `/api/v1/position/add` | POST | `sys:position:add` | 新增岗位 |
| `/api/v1/position/update` | PUT | `sys:position:update` | 编辑岗位 |
| `/api/v1/position/delete/{id}` | DELETE | `sys:position:delete` | 删除岗位 |
| `/api/v1/position/batchDelete` | DELETE | `sys:position:batchDelete` | 批量删除 |
| `/api/v1/position/status` | PUT | `sys:position:status` | 设置状态 |
:::tip Excel 导入导出
职级模块支持 Excel 导入导出(`import_level` / `export_level`),导入时逐行校验并返回成功/失败条数。岗位模块结构相同,可按需添加导入导出功能。
:::
## 总结
组织架构模块具备以下特点:
```
1. 部门树形:parent_id 形成多级部门结构,删除前校验下级部门和用户引用
2. 职级/岗位:简单 CRUD,继承 BaseService,声明差异点即可
3. 表单验证:WTForms Field 约束(DataRequired/Length/NumberRange)
4. 数据仓库:BaseRepository 提供通用 CRUD 能力,模块级代理函数
5. 用户关联:用户表通过 dept_id、position_id、level_id 关联
6. 删除保护:before_delete 钩子防止引用悬空
```
@@ -0,0 +1,191 @@
:::tip 案例演示
案例演示模块是代码生成器的参考模板,展示了标准 CRUD 模块的完整实现。包含文件字段处理、枚举字典映射、状态管理等功能,可作为新模块开发的参考范例。
:::
## 模块结构
```
src/modules/example/
├── models.py # 案例模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
```
## 案例模型
```python
# src/modules/example/models.py
class Example(BaseModel, BaseDB):
"""
案例演示模型
包含文件字段(avatar)、枚举字段(type/status)等典型业务字段。
"""
__tablename__ = DB_PREFIX + "example"
__table_comment__ = "案例演示表"
# 案例名称
name = db.Column(db.String(255), nullable=False, index=True, comment="案例名称")
# 案例图片(存相对路径)
avatar = db.Column(db.String(255), nullable=False, comment="案例图片")
# 案例类型:1-类型1 2-类型2 3-类型3 4-类型4
type = db.Column(db.Integer, nullable=True, default=0, server_default=text('0'),
comment="案例类型:1-类型1 2-类型2 3-类型3 4-类型4")
# 案例状态:1-在用 2-停用
status = db.Column(db.Integer, nullable=True, default=0, server_default=text('0'),
index=True, comment="案例状态:1-在用 2-停用")
# 排序
sort = db.Column(db.Integer, nullable=True, default=0, server_default=text('0'),
comment="案例排序")
```
## 服务层实现
```python
# src/modules/example/service.py
class ExampleService(BaseService):
"""
案例演示服务类
展示文件字段处理、枚举映射等标准 CRUD 模式。
"""
repository = ExampleRepository
form = ExampleForm
status_form = ExampleStatusForm
model_name = "案例演示"
@classmethod
def before_add(cls, form):
"""新增前钩子:处理文件字段"""
_handle_file_fields(form)
@classmethod
def before_update(cls, form, obj):
"""更新前钩子:处理文件字段"""
_handle_file_fields(form)
@classmethod
def _enrich_page_records(cls, records):
"""分页富化:文件字段拼接 URL"""
for data in records:
data["avatar"] = get_file_url(data.get("avatar"))
return records
@classmethod
def _detail_dict(cls, obj):
"""详情序列化:文件字段拼接 URL"""
data = model_to_dict(obj)
data["avatar"] = get_file_url(data.get("avatar"))
return data
def _handle_file_fields(form):
"""文件字段处理:临时文件迁移到正式目录"""
avatar = form.data.get("avatar")
if avatar:
form.avatar.data = save_file(avatar, "example")
```
### 文件字段处理模式
这是项目中处理文件字段的标准模式:
| 步骤 | 时机 | 处理 |
|:---|:---|:---|
| 1. 前端上传 | 用户选择文件 | 上传到临时目录,获得临时 URL |
| 2. 提交表单 | 前端提交 | 将临时 URL 传给后端 |
| 3. `before_add`/`before_update` | Service 钩子 | `save_file` 迁移到正式目录 |
| 4. 存储数据库 | Repository.create | 存相对路径 |
| 5. 查询返回 | `_detail_dict`/`_enrich_page_records` | `get_file_url` 拼接完整 URL |
### 枚举字段映射
通过 `serialize_maps` 声明枚举字段与字典编码的映射:
```python
class ExampleService(BaseService):
serialize_maps = {
'type': 'example_type', # 案例类型 → 数据字典
'status': 'example_status' # 案例状态 → 数据字典
}
```
查询结果自动包含 `typeText` 和 `statusText` 字段。
## 表单验证
```python
# src/modules/example/schema.py
class ExampleForm(BaseForm):
"""案例创建/编辑表单"""
name = StringField('案例名称', validators=[
DataRequired(message='案例名称不能为空'),
Length(max=255, message='案例名称长度不得超过255个字符')
])
avatar = StringField('案例图片', validators=[Optional(), Length(max=255)])
type = IntegerField('案例类型', validators=[
Optional(), NumberRange(min=1, max=4, message='案例类型值在1~4之间')
])
status = IntegerField('案例状态', validators=[
Optional(), NumberRange(min=1, max=2, message='案例状态值在1~2之间')
])
sort = IntegerField('案例排序', validators=[
Optional(), NumberRange(min=0, max=99999)
])
def validate_name(self, field):
"""案例名称唯一性校验"""
if not field.data:
return
current_id = self.id.data if self.id.data else None
if ExampleRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("案例名称已存在")
class ExampleStatusForm(BaseForm):
"""案例状态更新表单"""
id = IntegerField('案例ID', validators=[
DataRequired(message='案例ID不能为空'),
NumberRange(min=1, message='案例ID必须大于0')
])
status = IntegerField('案例状态', validators=[
DataRequired(message='案例状态不能为空'),
NumberRange(min=1, max=2, message='案例状态值在1~2之间')
])
```
## API 接口
| 方法 | 路径 | 权限节点 | 说明 |
|:---|:---|:---|:---|
| GET | `/api/v1/example/page` | `sys:example:page` | 分页查询 |
| GET | `/api/v1/example/detail/<id>` | `sys:example:detail` | 详情查询 |
| GET | `/api/v1/example/list` | `sys:example:list` | 列表查询(无分页) |
| POST | `/api/v1/example/add` | `sys:example:add` | 新增案例 |
| PUT | `/api/v1/example/update` | `sys:example:update` | 编辑案例 |
| DELETE | `/api/v1/example/delete/<id>` | `sys:example:delete` | 删除案例 |
| DELETE | `/api/v1/example/batchDelete` | `sys:example:batchDelete` | 批量删除 |
| PUT | `/api/v1/example/status` | `sys:example:status` | 设置状态 |
## 数据字典
| 字典编码 | 用途 | 字典项示例 |
|:---|:---|:---|
| `example_type` | 案例类型名称 | 1=类型1, 2=类型2, 3=类型3, 4=类型4 |
| `example_status` | 案例状态名称 | 1=在用, 2=停用 |
## 作为代码生成器参考
案例演示模块是代码生成器的参考模板,新模块开发时可参考其模式:
1. **模型定义**:继承 `BaseModel + BaseDB`,字段注释完整
2. **表单验证**:继承 `BaseForm`,`validate_` 方法实现唯一性校验
3. **数据访问**:继承 `BaseRepository`,实现 `page()` / `list_enabled()`
4. **业务服务**:继承 `BaseService`,声明 `repository` / `form` / `model_name`
5. **路由控制**:Blueprint + `@check_permission` + `@check_demo` + `@operation_log`
## 总结
案例演示模块是标准 CRUD 模块的参考范例,展示了文件字段处理(`save_file`/`get_file_url`)、枚举字典映射(`serialize_maps`)、状态管理(`update_status`)等典型模式。新模块开发或代码生成器生成的代码均以此模块为参考。
@@ -0,0 +1,145 @@
:::tip 文件模板
文件模板模块用于管理系统中的文件模板,如导入导出模板、报表模板等。支持模板文件上传、按编码查询、文件 URL 自动转换等功能。
:::
## 模块结构
```
src/modules/file_template/
├── models.py # 文件模板模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
```
## 文件模板模型
```python
# src/modules/file_template/models.py
class FileTemplate(BaseModel, BaseDB):
"""
文件模板模型
用于存储文件模板信息,如:导入导出模板、报表模板等。
"""
__tablename__ = DB_PREFIX + "file_template"
__table_comment__ = "文件模板表"
# 模板名称
name = db.Column(db.String(100), nullable=True, index=True, comment="模板名称")
# 模板编码(唯一标识,用于程序化查询)
code = db.Column(db.String(50), nullable=False, index=True, comment="模板编码")
# 文件名称(原始文件名)
file_name = db.Column(db.String(255), nullable=True, comment="文件名称")
# 文件路径(存储相对路径,如 /file_template/xxx.xlsx)
file_path = db.Column(db.String(255), nullable=True, comment="文件路径")
# 备注
note = db.Column(db.String(255), nullable=True, comment="文件模板备注")
# 排序
sort = db.Column(db.Integer, nullable=False, default=0, server_default=text('0'),
comment="文件模板顺序")
```
## 服务层特色
文件模板服务继承 `BaseService`,通过钩子实现文件处理和 URL 转换:
```python
# src/modules/file_template/service.py
class FileTemplateService(BaseService):
repository = FileTemplateRepository
form = FileTemplateForm
model_name = "模板"
@classmethod
def before_add(cls, form):
"""新增前钩子:保存模板文件"""
_handle_template_file(form)
@classmethod
def before_update(cls, form, obj):
"""更新前钩子:保存模板文件"""
_handle_template_file(form)
@classmethod
def _detail_dict(cls, obj):
"""详情序列化:文件路径转换为可访问URL"""
data = model_to_dict(obj)
data["filePath"] = get_file_url(data.get("filePath"))
return data
@classmethod
def _enrich_page_records(cls, records):
"""分页富化:文件路径转换为可访问URL"""
for record in records:
record["filePath"] = get_file_url(record.get("filePath"))
return records
@classmethod
def get_by_code(cls, code):
"""按模板编码查询(用于程序化获取模板)"""
obj = cls.repository.first_by({"code": code})
return cls._detail_dict(obj) if obj else None
```
### 文件处理流程
```
前端上传临时文件 → 获得临时 URL(如 /temp/xxx.xlsx)
↓
提交表单(filePath 携带临时 URL)
↓
Service before_add/before_update 钩子
↓
save_file(filePath, "file_template")
↓
临时文件迁移到正式目录(upload/file_template/)
↓
数据库存相对路径
↓
查询时 get_file_url 拼接完整访问地址
```
### 按编码查询
`get_by_code` 方法用于程序化获取模板,返回与详情一致的序列化结构:
```python
# 按编码获取导入模板
template = FileTemplateService.get_by_code("user_import_template")
if template:
download_url = template["filePath"] # 已转为完整 URL
```
## API 接口
| 方法 | 路径 | 权限节点 | 说明 |
|:---|:---|:---|:---|
| GET | `/api/v1/fileTemplate/page` | `sys:fileTemplate:page` | 分页查询 |
| GET | `/api/v1/fileTemplate/detail/<id>` | `sys:fileTemplate:detail` | 详情查询 |
| GET | `/api/v1/fileTemplate/code/<code>` | - | 按编码查询 |
| POST | `/api/v1/fileTemplate/add` | `sys:fileTemplate:add` | 新增模板 |
| PUT | `/api/v1/fileTemplate/update` | `sys:fileTemplate:update` | 编辑模板 |
| DELETE | `/api/v1/fileTemplate/delete/<id>` | `sys:fileTemplate:delete` | 删除模板 |
| DELETE | `/api/v1/fileTemplate/batchDelete` | `sys:fileTemplate:batchDelete` | 批量删除 |
## 与 Excel 导入导出的配合
文件模板模块常与 Excel 导入导出功能配合使用:
```python
# 1. 获取导入模板下载地址
template = FileTemplateService.get_by_code("user_import_template")
download_url = template["filePath"]
# 2. 前端下载模板 → 填写数据 → 上传文件
# 3. 后端解析上传文件
abs_path = excel_utils.resolve_uploaded_excel()
rows, errors = excel_utils.parse_excel_rows(abs_path, USER_IMPORT_COLUMNS)
```
## 总结
文件模板模块用于管理系统中的各类模板文件。`before_add`/`before_update` 钩子自动将临时文件迁移到正式目录,`_detail_dict`/`_enrich_page_records` 钩子自动将文件路径转换为可访问 URL。`get_by_code` 方法支持按编码查询,便于程序化获取模板。
@@ -0,0 +1,203 @@
:::tip 定时任务
定时任务模块基于 APScheduler 实现,支持 Cron 表达式调度、任务执行日志、动态启停等功能。包含 job(任务定义)、job_log(执行日志)、scheduler(调度器)三个子模块。
:::
## 模块结构
```
src/modules/job/
├── models.py # 任务模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层
├── router.py # 路由控制器
├── scheduler_service.py # 调度器管理
├── executor.py # 任务执行器
├── mail_service.py # 邮件任务服务
└── sms_service.py # 短信任务服务
src/modules/job_log/
├── models.py # 执行日志模型
├── repository.py # 日志数据访问
├── service.py # 日志业务逻辑
└── router.py # 路由控制器
```
## 任务模型
```python
# src/modules/job/models.py
# ============================================================
# 定时任务模型
# ============================================================
class Job(BaseModel, BaseDB):
"""定时任务模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "job"
__table_comment__ = "定时任务表"
# ============================================================
# 字段定义
# ============================================================
# 任务名称
job_name = Column(String(255), nullable=False, index=True, comment="任务名称")
# 任务别名
job_alias = Column(String(255), nullable=True, comment="任务别名")
# 任务分组
job_group = Column(String(255), nullable=True, comment="任务分组")
# 任务触发器
job_trigger = Column(String(255), nullable=True, comment="任务触发器")
# 任务状态:0-未发布 1-运行中 2-暂停 3-删除
status = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True,
comment="任务状态:0-未发布 1-运行中 2-暂停 3-删除")
# Cron表达式
cron_expression = Column(String(255), nullable=True, comment="Cron表达式")
# 执行策略:1-立即执行 2-执行一次 3-放弃执行
execute_policy = Column(Integer, nullable=False, default=1, server_default=text('1'),
comment="执行策略:1-立即执行 2-执行一次 3-放弃执行")
# 是否同步任务:0-否 1-是
is_sync = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="是否同步任务:0-否 1-是")
# 任务URL
url = Column(String(255), nullable=True, comment="任务URL")
# 任务参数
params = Column(Text, nullable=True, comment="任务参数")
# 任务备注
note = Column(String(255), nullable=True, comment="任务备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回任务名称作为字符串表示"""
return "定时任务:{}".format(self.job_name)
```
## 执行日志模型
```python
# src/modules/job_log/models.py
# ============================================================
# 定时任务日志模型
# ============================================================
class JobLog(BaseModel, BaseDB):
"""定时任务日志模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "job_log"
__table_comment__ = "定时任务日志表"
# ============================================================
# 字段定义
# ============================================================
# 任务ID
job_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="任务ID")
# 任务名称
job_name = Column(String(255), nullable=False, comment="任务名称")
# 任务分组
job_group = Column(String(255), nullable=True, comment="任务分组")
# 任务触发器
job_trigger = Column(String(255), nullable=True, comment="任务触发器")
# 任务日志信息
job_message = Column(String(500), nullable=True, comment="任务日志信息")
# Cron表达式
cron_expression = Column(String(255), nullable=True, comment="Cron表达式")
# 执行状态:0-正常 1-失败
status = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="执行状态:0-正常 1-失败")
# 执行开始时间
start_time = Column(DateTime, nullable=True, comment="执行开始时间")
# 执行结束时间
end_time = Column(DateTime, nullable=True, comment="执行结束时间")
# 执行耗时,单位:毫秒
consume_time = Column(BigInteger, nullable=False, default=0, server_default=text('0'), comment="执行耗时,单位:毫秒")
# 异常信息
exception_info = Column(Text, nullable=True, comment="异常信息")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回任务名称作为字符串表示"""
return "定时任务日志:{}".format(self.job_name)
```
## 调度器管理
```python
# src/modules/job/scheduler_service.py
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
def init_scheduler():
"""启动时加载所有启用的任务"""
jobs = job_repo.get_all(status=0)
for job in jobs:
add_job(job)
return len(jobs)
def add_job(job):
"""添加任务到调度器"""
scheduler.add_job(
func=execute_job,
trigger='cron',
id=str(job.id),
args=[job.id],
**parse_cron(job.cron_expression),
)
def shutdown_scheduler():
"""关闭调度器"""
scheduler.shutdown()
```
:::tip 生命周期
调度器在 `create_app()` 中通过 `scheduler.init_app(app)` 初始化,加载所有状态为"运行"的任务。应用关闭时自动停止调度器。
:::
## Cron 表达式
| 字段 | 允许值 | 允许的特殊字符 |
|------|--------|---------------|
| 秒 | 0-59 | , - * / |
| 分 | 0-59 | , - * / |
| 时 | 0-23 | , - * / |
| 日 | 1-31 | , - * / |
| 月 | 1-12 | , - * / |
| 周 | 0-7 | , - * / |
常用示例:
```
0 0/5 * * * ? 每5分钟执行
0 0 2 * * ? 每天凌晨2点执行
0 0 0 1 * ? 每月1号零点执行
0 0 * * * ? 每小时执行
```
## API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/job/list` | GET | `sys:job:list` | 任务列表 |
| `/api/v1/job/add` | POST | `sys:job:add` | 新增任务 |
| `/api/v1/job/update` | PUT | `sys:job:update` | 编辑任务 |
| `/api/v1/job/delete/{id}` | DELETE | `sys:job:delete` | 删除任务 |
| `/api/v1/job/status` | PUT | `sys:job:status` | 启停任务 |
| `/api/v1/job/run` | POST | `sys:job:run` | 立即执行 |
| `/api/v1/jobLog/list` | GET | `sys:jobLog:list` | 执行日志 |
## 总结
定时任务模块具备以下特点:
```
1. APScheduler:基于成熟的调度框架,支持 Cron 表达式
2. 动态管理:通过接口动态添加、修改、启停任务
3. 执行日志:自动记录每次执行的开始时间、耗时、状态、异常
4. 并发控制:可配置是否允许并发执行
5. 错过策略:配置错过执行时的处理策略
6. 生命周期:应用启动自动加载,关闭自动停止
```
@@ -0,0 +1,173 @@
:::tip 菜单管理
菜单管理是 RBAC 权限系统的基础,负责管理系统的导航菜单和权限节点。支持目录/菜单(type=0)和权限按钮节点(type=1)两种类型,通过树形结构组织。权限节点由系统根据前端勾选自动生成,采用 `sys:module:action` 三段式命名。
:::
## 菜单类型
| type | 名称 | 说明 | 用途 |
|------|------|------|------|
| `0` | 目录/菜单 | 页面路由 | 前端侧边栏分组 + 路由生成 |
| `1` | 节点 | 权限按钮 | 控制操作权限(由系统自动生成) |
## 菜单模型
```python
# src/modules/system/menu/models.py
# ============================================================
# 菜单模型
# ============================================================
class Menu(BaseModel, BaseDB):
"""菜单模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "menu"
__table_comment__ = "菜单表"
# ============================================================
# 字段定义
# ============================================================
# 菜单名称
name = Column(String(150), nullable=False, index=True, comment="菜单名称")
# 菜单图标
icon = Column(String(50), nullable=True, comment="菜单图标")
# 菜单图标(备用)
icon2 = Column(String(50), nullable=True, comment="菜单图标")
# 上级ID
parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级ID")
# 菜单路径
path = Column(String(255), nullable=True, comment="菜单路径")
# 菜单组件
component = Column(String(255), nullable=True, comment="菜单组件")
# 权限节点
permission = Column(String(150), nullable=True, comment="权限节点")
# 打开方式:0-组件 1-内链 2-外链
target = Column(Integer, default=1, server_default=text('1'), comment="打开方式:0-组件 1-内链 2-外链")
# 菜单类型:0-菜单 1-节点
type = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单类型:0-菜单 1-节点")
# 状态选项
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单状态:0-正常 1-停用")
# 是否可见:0-可见 1-不可见
hide = Column(Integer, default=1, server_default=text('1'), comment="是否可见:0-可见 1-不可见")
# 排序
sort = Column(Integer, default=0, server_default=text('0'), comment="排序")
# 备注
note = Column(String(255), nullable=True, comment="备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回菜单ID作为字符串表示"""
return "菜单{}".format(self.id)
```
## 权限节点自动生成
当菜单类型为目录(type=0)且勾选了权限按钮时,系统自动在该菜单下生成 type=1 的权限节点。按钮 sort 值与权限操作的映射关系:
```python
# src/modules/system/menu/service.py
_BUTTON_SORT_VALUES = (1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65)
# sort 值与权限操作的映射
1 → 查询(sys:{module}:page)
5 → 添加(sys:{module}:add)
10 → 修改(sys:{module}:update)
15 → 删除(sys:{module}:delete)
20 → 详情(sys:{module}:detail)
25 → 设置状态(sys:{module}:status)
30 → 批量删除(sys:{module}:batchDelete)
50 → 导出(sys:{module}:export)
55 → 导入(sys:{module}:import)
60 → 分配权限(sys:{module}:permission)
65 → 重置密码(sys:{module}:resetPwd)
```
:::tip 权限节点生命周期
新增/编辑菜单时,`set_permission` 函数根据前端传入的 `checkedList` 数组,复用已有节点(保持 id 稳定,role_menu 授权不失效)或新建节点。本次未勾选的历史节点自动软删。删除菜单时,其下所有 type=1 权限节点一并软删,并清理 `role_menu` 关联。
:::
## 菜单树构建
使用 children_map 字典实现非递归树构建,避免深层递归性能问题:
```python
# src/modules/system/menu/service.py
def get_tree(data, parent_id):
"""获取树状结构(非递归,children_map 分组)"""
children_map = {}
for item in data:
pid = item["parentId"]
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(item)
def build(pid):
result = []
for item in children_map.get(pid, []):
item["children"] = build(item["id"])
result.append(item)
return result
return build(parent_id)
```
## 用户权限查询
权限查询通过 ORM join 实现,仅有效角色(未删除、未禁用)的权限生效:
```python
# src/modules/system/menu/service.py
def get_permissions_list_sync(user_id):
"""同步查询用户权限节点"""
if user_id == 1:
menu_list = menu_repo.get_all(type=1) # 超管拥有全部权限
else:
menu_list = (
menu_repo.filter()
.join(RoleMenu, RoleMenu.menu_id == Menu.id)
.join(UserRole, UserRole.role_id == RoleMenu.role_id)
.join(Role, Role.id == UserRole.role_id)
.filter(
and_(
UserRole.user_id == user_id,
or_(Menu.type == 1, and_(Menu.type == 0, Menu.permission != "")),
Menu.status == 0,
Menu.is_delete == 0,
Role.is_delete == 0,
)
)
.distinct()
.all()
)
return [item.permission for item in (menu_list or [])]
```
## API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/menu/list` | GET | `sys:menu:list` | 菜单列表 |
| `/api/v1/menu/detail/{id}` | GET | `sys:menu:detail` | 菜单详情(含权限节点 checkedList) |
| `/api/v1/menu/add` | POST | `sys:menu:add` | 新增菜单 |
| `/api/v1/menu/update` | PUT | `sys:menu:update` | 编辑菜单 |
| `/api/v1/menu/delete/{id}` | DELETE | `sys:menu:delete` | 删除菜单 |
:::warning 父级校验
编辑菜单时会校验父级:不能设为自身、不能设为自己的下级(沿父链向上走检测环路),避免菜单树成环导致渲染死循环。
:::
## 总结
菜单管理模块具备以下特点:
```
1. 树形结构:通过 parent_id 形成多级菜单树,非递归构建
2. 权限节点:type=1 按钮节点由 set_permission 自动生成和维护
3. 权限格式:sys:module:action 三段式,sort 值映射操作类型
4. 缓存联动:菜单增删改后通过 invalidate_menu_change 清理全量权限缓存
5. 环路防护:编辑时检测父级不能是自身或下级
6. 级联删除:删除菜单时一并软删其下权限节点和 role_menu 关联
```
@@ -0,0 +1,114 @@
:::tip 参数管理
参数管理模块用于管理系统配置和业务配置,每个参数包含名称、编码、值、类型、排序、状态等属性。本模块是典型的「BaseService 简单模块」,通过声明差异点即可获得完整 CRUD 能力。
:::
## 模块结构
```
src/modules/param/
├── models.py # 参数模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
```
## 参数服务
```python
# src/modules/param/service.py
from src.common.base_service import BaseService
from src.modules.param.schema import ParamForm, ParamStatusForm
from src.modules.param.repository import ParamRepository
class ParamService(BaseService):
"""
参数服务类
继承 BaseService 获得标准 CRUD 能力。
"""
repository = ParamRepository
form = ParamForm
status_form = ParamStatusForm
model_name = "参数"
# ============================================================
# 模块级代理函数:保持 router 调用兼容
# ============================================================
get_param_page = ParamService.get_page
get_param_detail = ParamService.get_detail
get_info = ParamService.get_info
add_param = ParamService.add
update_param = ParamService.update
delete_params = ParamService.remove
update_param_status = ParamService.update_status
```
:::tip 简单模块模式
参数模块是典型的简单 CRUD 模块,只需声明 `repository` / `form` / `model_name` 即可获得完整能力。唯一性校验在 Schema 的 `validate_` 方法中实现,分页查询在 Repository 的 `page()` 方法中定义。
:::
## 分页查询
Repository 的 `page()` 方法定义分页查询逻辑:
```python
# src/modules/param/repository.py
class ParamRepository(BaseRepository):
model = Param
@classmethod
def page(cls, params: dict):
"""参数分页查询"""
return cls.find_page(
page=int(params.get('pageNo', 1)),
page_size=int(params.get('pageSize', PAGE_SIZE)),
filters={'type': params.get('type'), 'status': params.get('status')},
like_filters={'name': params.get('name'), 'code': params.get('code')},
order_by='sort',
)
@classmethod
def list_enabled(cls, params=None):
"""查询启用的参数列表"""
params = params or {}
return cls.find_list(
filters={'status': 1},
like_filters={'name': params.get('name')},
order_by='sort',
)
```
## 枚举显示名映射
通过 `serialize_maps` 声明枚举字段与字典编码的映射关系,分页和详情接口自动补充 `{field}Text` 字段:
```python
class ParamService(BaseService):
serialize_maps = {
'type': 'param_type', # 参数类型 → 数据字典
'status': 'param_status' # 参数状态 → 数据字典
}
```
查询结果自动包含 `typeText` 和 `statusText` 字段,前端直接使用。
## API 接口
| 方法 | 路径 | 权限节点 | 说明 |
|:---|:---|:---|:---|
| GET | `/api/v1/param/page` | `sys:param:page` | 分页查询(支持 name/code 模糊、type/status 精确) |
| GET | `/api/v1/param/detail/<id>` | `sys:param:detail` | 详情查询 |
| GET | `/api/v1/param/list` | `sys:param:list` | 参数列表(无分页) |
| POST | `/api/v1/param/add` | `sys:param:add` | 新增参数 |
| PUT | `/api/v1/param/update` | `sys:param:update` | 编辑参数 |
| DELETE | `/api/v1/param/delete/<id>` | `sys:param:delete` | 删除参数 |
| DELETE | `/api/v1/param/batchDelete` | `sys:param:batchDelete` | 批量删除 |
| PUT | `/api/v1/param/status` | `sys:param:status` | 设置状态 |
## 总结
参数管理模块是典型的简单 CRUD 模块,继承 BaseService 声明 `repository` / `form` / `model_name` 即可获得完整能力。分页查询在 Repository 的 `page()` 方法中通过 `filters` / `like_filters` / `order_by` 定义,唯一性校验在 Schema 的 `validate_` 方法中实现。
@@ -0,0 +1,194 @@
:::tip 角色管理
角色是 RBAC 权限模型的核心,连接用户和菜单(权限)。通过为角色分配菜单权限,再将角色分配给用户,实现灵活的权限管理。角色删除时需清理关联的用户角色和角色菜单数据,并即时失效受影响用户的权限缓存。
:::
## 模块结构
```
src/modules/system/role/
├── models.py # 角色模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
src/modules/association/role_menu/
├── models.py # 角色-菜单关联模型
├── repository.py # 关联数据访问
└── service.py # 关联业务逻辑
```
## 角色模型
```python
# src/modules/system/role/models.py
# ============================================================
# 角色模型
# ============================================================
class Role(BaseModel, BaseDB):
"""角色模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "role"
__table_comment__ = "角色表"
# ============================================================
# 字段定义
# ============================================================
# 角色名称
name = Column(String(150), nullable=False, index=True, comment="角色名称")
# 角色编码
code = Column(String(30), nullable=False, index=True, comment="角色编码")
# 角色排序
sort = Column(Integer, default=0, server_default=text('0'), comment="角色排序")
# 角色备注
note = Column(String(255), nullable=True, comment="角色备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回角色名称作为字符串表示"""
return '角色{}:'.format(self.name)
```
## 角色-菜单关联
角色与菜单通过 `flask_role_menu` 关联表实现多对多关系:
```python
# src/modules/system/role_menu/models.py
# ============================================================
# 角色菜单关联模型
# ============================================================
class RoleMenu(BaseModel, BaseDB):
"""角色菜单关联模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "role_menu"
__table_comment__ = "角色菜单关联表"
# ============================================================
# 字段定义
# ============================================================
# 角色ID
role_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="角色ID")
# 菜单ID
menu_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单ID")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回角色ID作为字符串表示"""
return "角色菜单表{}".format(self.role_id)
```
## 自定义 Service
角色模块继承 BaseService,覆写删除前钩子处理关联清理和缓存失效:
```python
# src/modules/system/role/service.py
from src.common.base_service import BaseService
from src.modules.system.role.schema import RoleForm
from src.modules.system.role.repository import RoleRepository
from src.modules.association.user_role.repository import UserRoleRepository
from src.modules.system.menu.service import invalidate_user_cache
class RoleService(BaseService):
"""
角色服务类
继承 BaseService 获得标准 CRUD 能力,并扩展删除前清除用户权限缓存。
"""
repository = RoleRepository
form = RoleForm
model_name = "角色"
@classmethod
def before_delete(cls, ids):
"""
删除前钩子:清除该角色关联用户的权限缓存
Args:
ids: 待删除的角色ID列表
"""
for rid in ids:
for uid in UserRoleRepository.get_users_by_role(rid):
invalidate_user_cache(uid)
# 模块级代理函数
get_role_page = RoleService.get_page
get_role_detail = RoleService.get_detail
add_role = RoleService.add
update_role = RoleService.update
delete_roles = RoleService.remove
get_role_list = RoleService.get_list
```
## 删除与缓存清理
角色删除时,`before_delete` 钩子会先收集受影响用户并清理权限缓存:
```python
# src/modules/system/role/service.py
class RoleService(BaseService):
@classmethod
def before_delete(cls, ids):
"""
删除前钩子:清除该角色关联用户的权限缓存
在 BaseService.remove() 执行批量软删除之前调用,
确保权限缓存及时失效。
"""
for rid in ids:
for uid in UserRoleRepository.get_users_by_role(rid):
invalidate_user_cache(uid)
```
:::warning 删除顺序
`before_delete` 在 `BaseService.remove()` 的批量软删除**之前**执行,此时 UserRole 关联尚未删除,可以正常查询受影响用户。BaseService 的 `remove()` 方法会自动调用 `repository.batch_delete()` 完成软删除。
:::
## API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/role/page` | GET | `sys:role:page` | 角色分页列表 |
| `/api/v1/role/list` | GET | `sys:role:list` | 角色列表(无分页) |
| `/api/v1/role/detail/{id}` | GET | `sys:role:detail` | 角色详情 |
| `/api/v1/role/add` | POST | `sys:role:add` | 新增角色 |
| `/api/v1/role/update` | PUT | `sys:role:update` | 编辑角色 |
| `/api/v1/role/delete/{id}` | DELETE | `sys:role:delete` | 删除角色 |
| `/api/v1/role/batchDelete` | DELETE | `sys:role:batchDelete` | 批量删除 |
| `/api/v1/role/auth` | POST | `sys:role:auth` | 分配菜单权限 |
## 前端权限分配
角色管理页面通常包含菜单树组件,用于勾选分配权限:
```vue
<el-tree
ref="menuTree"
:data="menuTreeData"
show-checkbox
node-key="id"
:default-checked-keys="checkedMenuIds"
/>
```
## 总结
角色管理模块具备以下特点:
```
1. RBAC 核心:连接用户和权限的桥梁
2. 继承 BaseService:标准 CRUD + 表单校验(WTForms)
3. before_delete 钩子:删除前清理关联用户的权限缓存
4. 灵活组合:一个角色可分配多个菜单,一个用户可拥有多个角色
```
@@ -0,0 +1,324 @@
:::tip 用户管理
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。与简单 CRUD 模块不同,用户管理使用自定义 service 函数而非继承 BaseService 的类方法。
:::
## 模块结构
```
src/modules/system/user/
├── models.py # 用户模型
├── schema.py # 表单验证(WTForms)
├── repository.py # 数据访问层
├── service.py # 业务逻辑层(继承 BaseService)
└── router.py # 路由控制器
src/modules/association/user_role/
├── models.py # 用户-角色关联模型
├── repository.py # 关联数据访问
└── service.py # 关联业务逻辑
```
## 用户模型
```python
# src/modules/system/user/models.py
# ============================================================
# 用户模型
# ============================================================
class User(BaseModel, BaseDB):
"""用户模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "user"
__table_comment__ = "用户表"
# ============================================================
# 字段定义
# ============================================================
# 用户编码
code = Column(String(100), nullable=True, default='', server_default=text("''"), index=True, comment="用户编码")
# 用户姓名
realname = Column(String(150), nullable=False, index=True, comment="用户姓名")
# 性别:1-男 2-女 3-保密
gender = Column(Integer, default=1, server_default=text('1'), comment="性别:1-男 2-女 3-保密")
# 用户头像
avatar = Column(String(255), nullable=False, comment="用户头像")
# 手机号
mobile = Column(String(30), nullable=False, index=True, comment="手机号")
# 邮箱
email = Column(String(30), nullable=False, index=True, comment="邮箱")
# 部门ID
dept_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="部门ID")
# 职级ID
level_id = Column(Integer, default=0, server_default=text('0'), comment="职级ID")
# 岗位ID
position_id = Column(Integer, default=0, server_default=text('0'), comment="岗位ID")
# 省份编码
province_code = Column(String(30), nullable=False, comment="省份编码")
# 城市编码
city_code = Column(String(30), nullable=False, comment="城市编码")
# 县区编码
district_code = Column(String(30), nullable=False, comment="县区编码")
# 街道编码
street_code = Column(String(30), nullable=False, comment="街道编码")
# 省市区信息
city_info = Column(String(255), nullable=True, comment="省市区信息")
# 详细地址
address = Column(String(255), nullable=False, comment="详细地址")
# 用户名
username = Column(String(30), nullable=True, index=True, comment="用户名")
# 密码
password = Column(String(255), nullable=True, comment="密码")
# 加密盐
salt = Column(String(30), nullable=True, comment="加密盐")
# 个人简介
intro = Column(String(255), nullable=True, comment="个人简介")
# 状态:1-正常 2-禁用
status = Column(Integer, default=1, server_default=text('1'), index=True, comment="状态:1-正常 2-禁用")
# 排序
sort = Column(Integer, default=0, server_default=text('0'), comment="排序")
# 个人备注
note = Column(String(255), nullable=True, comment="个人备注")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回用户ID作为字符串表示"""
return "用户{}".format(self.id)
```
## 密码加密
使用 bcrypt 双重加盐方案,由 `src/core/password.py` 提供:
```python
# src/core/password.py
# +======================================================================
# | 模块: 密码加密工具
# | 说明: 基于 bcrypt 的双重加盐方案
# +======================================================================
"""密码加密工具:基于 bcrypt 的双重加盐方案。"""
import secrets
import bcrypt
# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms)
BCRYPT_ROUNDS = 12
# ============================================================
# 密码工具函数
# ============================================================
def generate_salt(length: int = 10) -> str:
"""生成随机盐值"""
return secrets.token_urlsafe(length)[:length]
def encrypt_password(password: str, salt: str) -> str:
"""使用 bcrypt 加密密码(外部 salt 双重加盐)"""
combined = (password + salt).encode('utf-8')
hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
return hashed.decode('utf-8')
def verify_password(password: str, salt: str, hashed: str) -> bool:
"""验证密码是否正确"""
combined = (password + salt).encode('utf-8')
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
```
:::tip 双重加盐
密码加密采用双重加盐:外部 salt(存储在 user 表)+ bcrypt 内部 salt。即使数据库泄露,攻击者也无法通过彩虹表破解密码。工作因子 12,单次加密约 250ms。
:::
## 角色关联
用户与角色通过 `flask_user_role` 关联表实现多对多关系:
```python
# src/modules/system/user_role/models.py
# ============================================================
# 用户角色关联模型
# ============================================================
class UserRole(BaseModel, BaseDB):
"""用户角色关联模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "user_role"
__table_comment__ = "用户角色关联表"
# ============================================================
# 字段定义
# ============================================================
# 用户ID
user_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="用户ID")
# 角色ID
role_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="角色ID")
# ============================================================
# 内置方法
# ============================================================
def __str__(self):
"""返回用户ID作为字符串表示"""
return "用户角色表{}".format(self.user_id)
```
新增用户时同时创建角色关联,删除用户时硬删关联数据:
```python
# src/modules/system/user/service.py 关键片段
from src.common.base_service import BaseService
from src.core.password import encrypt_password, generate_salt
from src.core.security import get_realname
from src.core.exceptions import BizError
from src.common.constants.common import DEFAULT_PASSWORD
from src.modules.association.user_role.repository import UserRoleRepository
class UserService(BaseService):
"""用户服务类,继承 BaseService 获得标准 CRUD 能力"""
repository = UserRepository
form = UserForm
status_form = UserStatusForm
model_name = "用户"
@classmethod
def add(cls, data: dict) -> None:
"""新增用户"""
# 1. 表单校验
form = cls._validate_form(data)
# 2. 处理密码:未传密码时使用默认密码
pwd = form.data.get("password") or DEFAULT_PASSWORD
salt = generate_salt()
# 3. 校验行政区划
city = form.data.get("city", [])
if len(city) != 4:
raise BizError("请选择完整的所在城市(省/市/区/街道)")
roles = form.data.get("roles")
# 4. 清理表单数据,准备入库
create_data = cls._clean_form_data(form)
create_data["password"] = encrypt_password(pwd, salt)
create_data["salt"] = salt
create_data["create_user"] = get_realname()
_apply_city(create_data, city)
# 5. 创建用户记录
user = cls.repository.create(**create_data)
user.code = f"{user.id:06d}" # 根据自增ID生成6位用户编码
# 6. 关联用户角色关系
_add_user_role(user.id, roles)
# 7. 清除权限缓存
invalidate_user_cache(user.id)
```
## 关联数据富化
用户列表和详情需要富化部门、职级、岗位、角色等关联数据:
```python
# src/modules/system/user/service.py
def _enrich_user_records(records):
"""为用户记录补充关联数据"""
dept_map = _get_dept_map() # {dept.id: dept.name}
level_map = _get_level_map() # {level.id: level.name}
position_map = _get_position_map() # {position.id: position.name}
gender_map = dict_utils.get_value_map("gender")
user_ids = [data.get("id") for data in records]
role_map = user_role.get_user_roles_batch(user_ids)
for data in records:
data["genderName"] = gender_map.get(str(data.get("gender")))
data["deptName"] = dept_map.get(data.get("deptId"))
data["levelName"] = level_map.get(data.get("levelId"))
data["positionName"] = position_map.get(data.get("positionId"))
data["roles"] = role_map.get(data.get("id"), [])
return records
```
## 权限缓存清理
用户增删改、角色变更、状态变更时需清理 Redis 中的权限缓存:
```python
from src.modules.system.menu.service import invalidate_user_cache
# 角色变更后清理缓存
invalidate_user_cache(user_id)
# 状态变更后清理缓存
invalidate_user_cache(id)
```
## Excel 导入导出
用户模块支持完整的 Excel 导入导出,导入列定义和导出列定义通过数组声明:
```python
# 导入列定义(顺序即 Excel 列序)
USER_IMPORT_COLUMNS = [
{"title": "登录账号", "key": "username", "required": True},
{"title": "用户姓名", "key": "realname", "required": True},
{"title": "性别", "key": "gender", "parser": _parse_gender},
{"title": "手机号", "key": "mobile", "required": True},
{"title": "邮箱", "key": "email", "required": True},
{"title": "部门", "key": "dept_id", "parser": _parse_dept},
{"title": "职级", "key": "level_id", "parser": _parse_level},
{"title": "岗位", "key": "position_id", "parser": _parse_position},
{"title": "用户角色", "key": "roles", "parser": _parse_roles},
{"title": "状态", "key": "status", "parser": _parse_status},
{"title": "城市信息", "key": "city_codes", "parser": _parse_city_info},
{"title": "详细地址", "key": "address", "required": True},
# ...
]
```
:::tip 导入解析器
每个导入列可声明 `parser` 函数,负责将 Excel 单元格值转换为目标类型。例如 `_parse_dept` 按部门名称查询部门 ID,`_parse_roles` 按角色名称(逗号分隔)查询角色 ID 列表。解析失败时返回行级错误信息,不影响其他行。
:::
## API 接口
| 接口 | 方法 | 权限节点 | 说明 |
|------|------|---------|------|
| `/api/v1/user/page` | GET | `sys:user:page` | 用户分页列表 |
| `/api/v1/user/detail/{id}` | GET | `sys:user:detail` | 用户详情 |
| `/api/v1/user/add` | POST | `sys:user:add` | 新增用户 |
| `/api/v1/user/update` | PUT | `sys:user:update` | 编辑用户 |
| `/api/v1/user/delete/{id}` | DELETE | `sys:user:delete` | 删除用户 |
| `/api/v1/user/batchDelete` | DELETE | `sys:user:batchDelete` | 批量删除 |
| `/api/v1/user/status` | PUT | `sys:user:status` | 设置状态 |
| `/api/v1/user/resetPwd` | PUT | `sys:user:resetPwd` | 重置密码 |
| `/api/v1/user/updatePassword` | PUT | - | 修改密码(本人) |
| `/api/v1/user/updateInfo` | PUT | - | 更新个人信息 |
| `/api/v1/user/import` | POST | `sys:user:import` | 导入用户 |
| `/api/v1/user/export` | GET | `sys:user:export` | 导出用户 |
:::warning 内置超管保护
用户 ID 为 1 的内置超管账号(`SUPERADMIN_ID`)禁止通过管理接口修改、删除、禁用、重置密码。这些操作会在 service 层提前拦截并返回失败提示。
:::
## 总结
用户管理模块具备以下特点:
```
1. 复杂业务:继承 BaseService 并覆写 add/update 钩子,支持头像/密码/行政区划/角色
2. 密码安全:bcrypt 双重加盐,工作因子12(约250ms)
3. 角色关联:多对多关系,通过 flask_user_role 关联表,先删后增策略
4. 权限缓存:角色/状态变更后即时清理 Redis 缓存
5. 关联富化:列表/详情补充部门、职级、岗位、角色、性别等展示名称
6. 行政区划:省/市/区/街道四级编码,自动拆分到独立字段
7. Excel 导入导出:列定义式导入解析,逐行校验,部分成功返回明细
8. 超管保护:ID=1 账号禁止修改/删除/禁用/重置密码
```