签入版本
This commit is contained in:
@@ -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 账号禁止修改/删除/禁用/重置密码
|
||||
```
|
||||
Reference in New Issue
Block a user