签入版本
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
:::tip 文章管理
|
||||
文章管理模块支持富文本编辑、分类关联、封面图片上传、图集管理等功能。适用于新闻、公告、帮助文档等内容管理场景。本模块是典型的「带文件处理」业务模块,涵盖了封面迁移、图集处理、富文本 XSS 清洗等核心机制。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/article/
|
||||
├── models.py # 文章模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
└── service.py # 业务逻辑层(自定义,非 BaseService 子类)
|
||||
```
|
||||
|
||||
:::warning 自定义 Service
|
||||
文章模块因涉及文件处理、富文本清洗、图集解析等复杂逻辑,未继承 `BaseService`,而是编写自定义 service 函数。这是项目中复杂模块的典型写法,与简单模块(如 Param 继承 BaseService)形成对比。
|
||||
:::
|
||||
|
||||
## 文章模型
|
||||
|
||||
```python
|
||||
# src/modules/article/models.py
|
||||
class Article(BaseModel):
|
||||
"""文章模型类"""
|
||||
__tablename__ = DB_PREFIX + "article"
|
||||
__table_comment__ = "文章表"
|
||||
|
||||
# 分类ID
|
||||
category_id = Column(Integer, nullable=True, index=True, comment="分类ID")
|
||||
# 文章标题
|
||||
title = Column(String(150), nullable=True, index=True, comment="文章标题")
|
||||
# 文章封面(存相对路径,如 /article/20260907/xxx.png)
|
||||
cover = Column(String(255), nullable=True, comment="文章封面")
|
||||
# 文章图集(逗号分隔的相对路径,如 /article/20260907/a.png,/article/20260907/b.png)
|
||||
images = Column(Text, nullable=True, comment="文章图集")
|
||||
# 文章导读
|
||||
intro = Column(String(255), nullable=True, comment="文章导读")
|
||||
# 文章内容(富文本 HTML,图片地址存为 [IMG_URL] 占位符 + 相对路径)
|
||||
content = Column(Text, nullable=True, comment="文章内容")
|
||||
# 文章作者
|
||||
author = Column(String(100), nullable=True, comment="文章作者")
|
||||
# 状态:0-正常 1-下架
|
||||
status = Column(Integer, default=0, server_default=text('0'), nullable=False, index=True,
|
||||
comment="文章状态:0-正常 1-下架")
|
||||
# 点击量
|
||||
click = Column(Integer, default=0, server_default=text('0'), nullable=False, comment="文章点击率")
|
||||
# 排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), nullable=False, comment="文章排序")
|
||||
```
|
||||
|
||||
其中 `id`、`create_user`、`create_time`、`update_user`、`update_time`、`is_delete` 由基类 `base_model` 自动提供。
|
||||
|
||||
## 表单验证
|
||||
|
||||
### ArticleForm(新增/编辑)
|
||||
|
||||
```python
|
||||
class ArticleForm(BaseSchema):
|
||||
categoryId: Optional[int] = Field(None, ge=0, description="分类ID")
|
||||
title: str = Field(..., min_length=1, max_length=150, description="文章标题")
|
||||
cover: Optional[str] = Field(None, max_length=255, description="文章封面")
|
||||
images: Optional[str] = Field(None, description="文章图集")
|
||||
intro: Optional[str] = Field(None, max_length=255, description="文章导读")
|
||||
content: Optional[str] = Field(None, description="文章内容")
|
||||
author: Optional[str] = Field(None, max_length=100, description="文章作者")
|
||||
status: int = Field(0, ge=0, le=1, description="文章状态:0-正常 1-下架")
|
||||
click: int = Field(0, ge=0, le=2147483647, description="文章点击率")
|
||||
sort: int = Field(0, ge=0, le=99999, description="文章排序")
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 约束 | 说明 |
|
||||
|:----|:----|:----|:----|
|
||||
| `categoryId` | 否 | `ge=0` | 关联分类表,0 或不传表示无分类 |
|
||||
| `title` | 是 | `1~150 字符` | 文章标题,唯一业务标识 |
|
||||
| `cover` | 否 | `max_length=255` | 封面图片的临时 URL 或相对路径 |
|
||||
| `images` | 否 | 无长度限制 | 图集,逗号分隔的 URL 字符串 |
|
||||
| `intro` | 否 | `max_length=255` | 文章导读/摘要 |
|
||||
| `content` | 否 | 无长度限制 | 富文本 HTML 内容 |
|
||||
| `author` | 否 | `max_length=100` | 文章作者 |
|
||||
| `status` | 否 | `0~1` | 默认 0(正常),1 为下架 |
|
||||
| `click` | 否 | `0~2147483647` | 点击量,前端通常不传 |
|
||||
| `sort` | 否 | `0~99999` | 排序号,值越大越靠前 |
|
||||
|
||||
### ArticleStatusForm(状态变更)
|
||||
|
||||
```python
|
||||
class ArticleStatusForm(BaseModel):
|
||||
id: int = Field(..., gt=0, description="文章ID")
|
||||
status: int = Field(..., ge=0, le=1, description="文章状态:0-正常 1-下架")
|
||||
```
|
||||
|
||||
用于独立的状态变更接口,只需传 `id` 和 `status`。
|
||||
|
||||
## 文件处理机制
|
||||
|
||||
文章模块涉及三类文件字段,处理方式各不相同:
|
||||
|
||||
### 处理流程总览
|
||||
|
||||
```
|
||||
前端上传临时文件 → 获得临时 URL(如 /temp/20260907/xxx.png)
|
||||
↓
|
||||
提交表单(cover / images / content 携带临时 URL)
|
||||
↓
|
||||
后端 service 调用 save_file / save_content
|
||||
↓
|
||||
临时文件迁移到正式目录(如 /article/20260907/xxx.png)
|
||||
↓
|
||||
数据库存相对路径(不存完整 URL)
|
||||
↓
|
||||
查询时调用 get_file_url 拼接完整访问地址
|
||||
```
|
||||
|
||||
### 1. 封面图片(cover)— `save_file`
|
||||
|
||||
```python
|
||||
# 新增时
|
||||
cover = save_file(data.cover, "article") if data.cover else None
|
||||
```
|
||||
|
||||
`save_file(url, directory)` 的处理逻辑:
|
||||
1. 将前端传来的 URL 还原为磁盘绝对路径
|
||||
2. 若文件在临时目录(`temp/`),移动到正式目录(`upload/article/日期/`)
|
||||
3. 若文件已在正式目录,直接返回相对路径
|
||||
4. 返回相对路径存入数据库(如 `/article/20260907/xxx.png`)
|
||||
5. 远程 URL(非本站文件)不处理,返回空字符串
|
||||
|
||||
:::warning 防路径穿越
|
||||
`save_file` 内部会校验磁盘真实路径必须位于 `UPLOAD_DIR` 内,防止 `../` 路径穿越攻击。
|
||||
:::
|
||||
|
||||
### 2. 图集(images)— `_build_images` / `_parse_images`
|
||||
|
||||
图集在数据库中存储为**逗号分隔的相对路径字符串**:
|
||||
|
||||
```
|
||||
/article/20260907/a.png,/article/20260907/b.png,/article/20260907/c.png
|
||||
```
|
||||
|
||||
**写入时**(`_build_images`):
|
||||
|
||||
```python
|
||||
def _build_images(images):
|
||||
"""逗号分隔的临时 URL → 逐个 save_file 迁移 → 逗号分隔的相对路径"""
|
||||
if not images:
|
||||
return None
|
||||
saved = []
|
||||
for url in images.split(','):
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
img = save_file(url, "article")
|
||||
saved.append(img or url)
|
||||
return ','.join(saved)
|
||||
```
|
||||
|
||||
**读取时**(`_parse_images`):
|
||||
|
||||
```python
|
||||
def _parse_images(images):
|
||||
"""逗号分隔的相对路径 → 逐个 get_file_url 拼接 → 完整 URL 数组"""
|
||||
if not images:
|
||||
return []
|
||||
return [get_file_url(img) for img in images.split(',') if img]
|
||||
```
|
||||
|
||||
前端拿到的是 URL 数组,可直接用于 `<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 中的 TORNADO_FILE_URL 前缀替换为 [IMG_URL] 占位符存库
|
||||
```
|
||||
|
||||
:::tip 为什么用 [IMG_URL] 占位符?
|
||||
数据库中不存完整 URL(如 `http://localhost:8041/upload/article/...`),而是存 `[IMG_URL]/article/...`。查询详情时再将 `[IMG_URL]` 替换为实际的 `TORNADO_FILE_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]", TORNADO_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(request, data: ArticleForm):
|
||||
# 1. 处理封面:临时文件迁移到正式目录
|
||||
cover = save_file(data.cover, "article") if data.cover else None
|
||||
# 2. 处理图集:逗号分隔 URL 逐个迁移
|
||||
images = _build_images(data.images)
|
||||
# 3. 处理富文本:XSS 清洗 + 图片迁移 + 占位符替换
|
||||
content = save_content(data.content, data.title, "article") if data.content else None
|
||||
# 4. 插入数据库(排除表单中的原值,用处理后的值替代)
|
||||
article_repo.create(
|
||||
**data.model_dump(exclude={'id', 'cover', 'images', 'content'}),
|
||||
cover=cover, images=images, content=content,
|
||||
create_user=get_realname(request)
|
||||
)
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
:::tip 关键设计
|
||||
`model_dump(exclude={'id', 'cover', 'images', 'content'})` 排除文件字段的原始值,再用处理后的值(`cover`/`images`/`content`)显式传入,避免临时 URL 直接存入数据库。
|
||||
:::
|
||||
|
||||
## 更新文章
|
||||
|
||||
更新逻辑比新增更复杂,核心难点是**防空值覆盖**:
|
||||
|
||||
```python
|
||||
def update_article(request, data: ArticleForm):
|
||||
# 1. ID 校验
|
||||
if not data.id or data.id <= 0:
|
||||
return R.failed("记录ID不能为空")
|
||||
article = article_repo.get_by_id(data.id)
|
||||
if not article:
|
||||
return R.failed("记录不存在")
|
||||
|
||||
# 2. 构造更新数据(排除 id;跳过 None,避免未传字段把旧值覆盖为 NULL)
|
||||
updData = data.model_dump(exclude={'id'}, exclude_none=True)
|
||||
|
||||
# 3. 默认值字段特殊处理
|
||||
for f in ('status', 'click', 'sort'):
|
||||
if f not in data.model_fields_set:
|
||||
updData.pop(f, None)
|
||||
|
||||
# 4. 文件字段:仅显式提供时更新
|
||||
if data.cover is not None:
|
||||
updData['cover'] = save_file(data.cover, "article") if data.cover else None
|
||||
if data.images is not None:
|
||||
updData['images'] = _build_images(data.images) if data.images else None
|
||||
if data.content is not None:
|
||||
updData['content'] = save_content(data.content, data.title, "article") if data.content else None
|
||||
|
||||
# 5. 更新数据库
|
||||
result = article_repo.update(data.id, updData)
|
||||
return R.ok(msg="更新成功") if result else R.failed("更新失败")
|
||||
```
|
||||
|
||||
### 为什么需要 `exclude_none=True`?
|
||||
|
||||
前端编辑表单通常只传用户修改过的字段。未修改的字段可能为 `None`(Pydantic 可选字段默认值)。如果不跳过 `None` 值,会把数据库中的旧值覆盖为 `NULL`。
|
||||
|
||||
### 为什么需要 `model_fields_set` 判断?
|
||||
|
||||
`status`、`click`、`sort` 三个字段在 `ArticleForm` 中有默认值 `0`。即使前端不传,`model_dump()` 也会输出 `status=0`。如果直接更新,会把用户之前设置的状态(如 `1` 下架)意外覆盖为 `0`(正常)。
|
||||
|
||||
`data.model_fields_set` 记录了前端**显式传入**的字段集合。只有前端显式传了 `status`,才更新该字段。
|
||||
|
||||
```python
|
||||
# 前端传了 { id: 1, title: "新标题" }(未传 status)
|
||||
# data.model_fields_set = {'id', 'title'}
|
||||
# status 不在其中 → 从 updData 中移除 → 不覆盖旧值
|
||||
```
|
||||
|
||||
### 文件字段的清空语义
|
||||
|
||||
| 前端传值 | 行为 |
|
||||
|:----|:----|
|
||||
| `cover: "/temp/xxx.png"` | 迁移临时文件,更新封面 |
|
||||
| `cover: ""` | 清空封面(置为 `None`) |
|
||||
| `cover: null` 或不传 | 保留旧封面不变 |
|
||||
|
||||
图集和富文本同理。
|
||||
|
||||
## 删除文章
|
||||
|
||||
```python
|
||||
def delete_article(article_id):
|
||||
return article_service.delete(self, article_id)
|
||||
```
|
||||
|
||||
复用 `BaseService.delete()` 方法,支持单个 ID 或逗号分隔的多个 ID。软删除(`is_delete=1`),不物理删除数据。
|
||||
|
||||
## 状态变更
|
||||
|
||||
```python
|
||||
def update_article_status(request, data: ArticleStatusForm):
|
||||
article = article_repo.get_by_id(data.id)
|
||||
if not article:
|
||||
return R.failed("记录不存在")
|
||||
result = article_repo.update(data.id, {
|
||||
"status": data.status,
|
||||
"update_user": get_realname(request),
|
||||
"update_time": datetime.now(tz=timezone.utc)
|
||||
})
|
||||
return R.ok(msg="设置成功") if result else R.failed("设置失败")
|
||||
```
|
||||
|
||||
独立的状态变更接口,只更新 `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. 自定义 Service:未继承 BaseService,文件处理/富文本清洗/图集解析均为自定义逻辑
|
||||
2. 三类文件字段:封面(save_file)、图集(_build_images/_parse_images)、富文本(save_content)
|
||||
3. 临时文件迁移:前端上传到临时目录 → 提交表单后后端迁移到正式目录
|
||||
4. XSS 防护:富文本经 bleach 白名单清洗 + CSS 表达式过滤
|
||||
5. [IMG_URL] 占位符:数据库不存完整域名,查询时动态替换,便于域名迁移
|
||||
6. 防空值覆盖:exclude_none=True + model_fields_set 判断,避免未传字段覆盖旧值
|
||||
7. 分页回退:并发删除导致当前页为空时,自动回退第一页
|
||||
8. 数据字典:状态名称通过 get_dict_label 动态映射,不硬编码
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
:::tip 分类管理
|
||||
分类管理模块用于管理树形分类数据,如文章分类、产品分类等。通过 `parent_id` 字段实现无限级分类。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/category/
|
||||
├── models.py # 分类模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
└── service.py # 业务逻辑层
|
||||
```
|
||||
|
||||
## 分类模型
|
||||
|
||||
```python
|
||||
# src/modules/category/models.py
|
||||
# ============================================================
|
||||
# 文章分类模型
|
||||
# ============================================================
|
||||
class Category(BaseModel):
|
||||
"""文章分类模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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:声明 page_like_fields/page_eq_fields 即可获得分页能力
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# 行政区划管理
|
||||
|
||||
:::tip 说明
|
||||
行政区划管理模块提供省市区三级行政区划数据的查询和管理能力,支持树形结构展示和下拉选择。模块位于 `src/modules/city/`。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/city/
|
||||
├── models.py # 行政区划模型
|
||||
├── schemas.py # 参数验证
|
||||
├── repository.py # 数据访问层
|
||||
├── service.py # 业务逻辑层
|
||||
└── handlers.py # 请求处理器
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
```python
|
||||
class City(BaseModel):
|
||||
"""行政区划模型"""
|
||||
__tablename__ = "city"
|
||||
|
||||
name = Column(String(100), nullable=False, comment="名称")
|
||||
code = Column(String(20), default="", comment="行政区划代码")
|
||||
parent_id = Column(Integer, default=0, comment="父级ID(0=顶级)")
|
||||
level = Column(Integer, default=0, comment="层级:1=省 2=市 3=区")
|
||||
sort = Column(Integer, default=0, comment="排序")
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 权限 | 说明 |
|
||||
|:-----|:-----|:-----|:-----|
|
||||
| `/api/v1/city/page` | GET | `sys:city:list` | 分页查询 |
|
||||
| `/api/v1/city/detail/(\d+)` | GET | `sys:city:detail` | 详情查询 |
|
||||
| `/api/v1/city/add` | POST | `sys:city:add` | 新增 |
|
||||
| `/api/v1/city/update` | PUT | `sys:city:update` | 更新 |
|
||||
| `/api/v1/city/delete/(\d+)` | DELETE | `sys:city:delete` | 删除 |
|
||||
| `/api/v1/city/getCityList` | GET | `sys:city:list` | 获取下拉树形列表 |
|
||||
|
||||
## 树形结构
|
||||
|
||||
行政区划按省→市→区三级组织,通过 `parent_id` 字段形成树形结构。下拉选择接口返回嵌套的树形数据。
|
||||
|
||||
## 总结
|
||||
|
||||
行政区划管理模块提供省市区三级数据的 CRUD 和树形查询能力,支持下拉选择组件的数据源。
|
||||
@@ -0,0 +1,201 @@
|
||||
:::tip 公告/链接/城市
|
||||
公告、链接、城市是三个简单 CRUD 内容模块,均继承 BaseService,提供标准的增删改查接口。适用于系统公告、友情链接、城市数据等场景。
|
||||
:::
|
||||
|
||||
## 公告管理
|
||||
|
||||
### 公告模型
|
||||
|
||||
```python
|
||||
# src/modules/notice/models.py
|
||||
# ============================================================
|
||||
# 通知公告模型
|
||||
# ============================================================
|
||||
class Notice(BaseModel):
|
||||
"""通知公告模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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):
|
||||
"""通知公告模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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):
|
||||
"""城市/行政区划模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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
|
||||
2. schemas.py:继承 BaseSchema,定义 Field 约束
|
||||
3. repository.py:继承 BaseRepository,模块级单例
|
||||
4. service.py:继承 BaseService,声明差异字段
|
||||
5. endpoints:@permission_required + @check_demo 装饰器
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
内容管理模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 简单 CRUD:继承 BaseService,声明差异点即可获得完整接口
|
||||
2. 公告管理:支持富文本内容、状态管理
|
||||
3. 链接管理:友情链接、合作伙伴链接,支持 Logo 图片
|
||||
4. 城市管理:省/市/区/街道/社区五级行政区划,含经纬度、拼音等丰富字段
|
||||
5. 统一模式:models/schemas/repository/service/endpoints 五层结构
|
||||
6. 模块级单例:repository 和 service 均在文件末尾创建单例实例
|
||||
```
|
||||
@@ -0,0 +1,334 @@
|
||||
:::tip 部门/职级/岗位
|
||||
部门、职级、岗位是组织架构管理的三个核心模块。部门采用树形结构,职级和岗位为简单 CRUD 模块,均继承 `BaseService`。用户表通过 `dept_id`、`position_id`、`level_id` 三个外键关联这三个模块。
|
||||
:::
|
||||
|
||||
## 部门管理
|
||||
|
||||
### 部门模型
|
||||
|
||||
```python
|
||||
# src/modules/dept/models.py
|
||||
# ============================================================
|
||||
# 部门模型
|
||||
# ============================================================
|
||||
class Dept(BaseModel):
|
||||
"""部门模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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/dept/service.py
|
||||
def _before_delete(self, ids) -> Optional[str]:
|
||||
id_list = parse_id_list(str(ids))
|
||||
|
||||
# 检查是否存在下级部门
|
||||
if id_list and self.repo.filter(
|
||||
Dept.parent_id.in_(id_list), Dept.is_delete == 0
|
||||
).first():
|
||||
return "存在下级部门,请先删除下级部门"
|
||||
|
||||
# 检查是否存在用户引用
|
||||
if id_list and user_repo.filter(
|
||||
User.dept_id.in_(id_list), User.is_delete == 0
|
||||
).first():
|
||||
return "存在用户引用该部门,请先调整用户部门"
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## 职级管理
|
||||
|
||||
### 职级模型
|
||||
|
||||
```python
|
||||
# src/modules/level/models.py
|
||||
# ============================================================
|
||||
# 职级模型
|
||||
# ============================================================
|
||||
class Level(BaseModel):
|
||||
"""职级模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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/level/schemas.py
|
||||
# ============================================================
|
||||
# 职级表单类
|
||||
# ============================================================
|
||||
class LevelForm(BaseSchema):
|
||||
"""职级创建/编辑表单"""
|
||||
name: str = Field(..., min_length=1, max_length=150, description="职级名称")
|
||||
status: int = Field(..., ge=1, le=2, description="职级状态:1-在用 2-停用")
|
||||
sort: int = Field(..., ge=0, le=99999, description="职级排序")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置状态表单类
|
||||
# ============================================================
|
||||
class LevelStatusForm(BaseModel):
|
||||
"""职级状态更新表单"""
|
||||
id: int = Field(..., gt=0, description="职级ID")
|
||||
status: int = Field(..., ge=1, le=2, description="职级状态:1-在用 2-停用")
|
||||
```
|
||||
|
||||
### 业务服务
|
||||
|
||||
职级模块继承 `BaseService`,声明差异点即可获得完整 CRUD 能力。删除前校验用户引用,支持 Excel 导入导出:
|
||||
|
||||
```python
|
||||
# src/modules/level/service.py
|
||||
class LevelService(BaseService(Level]):
|
||||
repo = level_repo
|
||||
model = Level
|
||||
page_like_fields = ('name',)
|
||||
page_eq_fields = ('status',)
|
||||
page_order_by = (('sort', 'asc'),)
|
||||
unique_fields = {'name': '职级名称不能重复'}
|
||||
|
||||
def _before_delete(self, ids) -> Optional[str]:
|
||||
"""删除前校验:存在用户引用该职级时禁止删除"""
|
||||
id_list = parse_id_list(str(ids))
|
||||
if id_list and user_repo.filter(User.level_id.in_(id_list), User.is_delete == 0).first():
|
||||
return "存在用户引用该职级,请先调整用户职级"
|
||||
return None
|
||||
|
||||
level_service = LevelService()
|
||||
```
|
||||
|
||||
## 岗位管理
|
||||
|
||||
### 岗位模型
|
||||
|
||||
```python
|
||||
# src/modules/position/models.py
|
||||
# ============================================================
|
||||
# 岗位模型
|
||||
# ============================================================
|
||||
class Position(BaseModel):
|
||||
"""岗位模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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/position/schemas.py
|
||||
# ============================================================
|
||||
# 岗位表单类
|
||||
# ============================================================
|
||||
class PositionForm(BaseSchema):
|
||||
"""岗位创建/编辑表单"""
|
||||
name: str = Field(..., min_length=1, max_length=150, description="岗位名称")
|
||||
status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用")
|
||||
sort: int = Field(..., ge=0, le=99999, description="岗位排序")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置状态表单类
|
||||
# ============================================================
|
||||
class PositionStatusForm(BaseModel):
|
||||
"""岗位状态更新表单"""
|
||||
id: int = Field(..., gt=0, description="岗位ID")
|
||||
status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用")
|
||||
```
|
||||
|
||||
### 数据访问层
|
||||
|
||||
```python
|
||||
# src/modules/position/repository.py
|
||||
# +======================================================================
|
||||
# | 模块: 岗位数据访问层
|
||||
# | 说明: 封装岗位模型的数据访问操作
|
||||
# +======================================================================
|
||||
|
||||
from position.models import Position
|
||||
from core.base_repository import BaseRepository
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位仓库类
|
||||
# ============================================================
|
||||
class PositionRepository(BaseRepository(Position]):
|
||||
"""岗位数据仓库,继承基础仓库获得通用 CRUD 能力"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 仓库实例
|
||||
# ============================================================
|
||||
position_repo = PositionRepository()
|
||||
```
|
||||
|
||||
### 业务服务
|
||||
|
||||
```python
|
||||
# src/modules/position/service.py
|
||||
# ============================================================
|
||||
# 岗位业务服务类
|
||||
# ============================================================
|
||||
class PositionService(BaseService(Position]):
|
||||
"""岗位业务服务类,继承基础服务获得通用 CRUD 能力"""
|
||||
# 数据访问与模型
|
||||
repo = position_repo
|
||||
model = Position
|
||||
# 分页差异点
|
||||
page_like_fields = ('name',)
|
||||
page_eq_fields = ('status',)
|
||||
page_order_by = (('sort', 'asc'),)
|
||||
# 唯一性校验
|
||||
unique_fields = {'name': '岗位名称不能重复'}
|
||||
|
||||
# ============================================================
|
||||
# 删除前校验
|
||||
# ============================================================
|
||||
def _before_delete(self, ids) -> Optional[str]:
|
||||
"""删除前校验:存在用户引用该岗位时禁止删除,避免用户岗位悬空"""
|
||||
id_list = parse_id_list(str(ids))
|
||||
if id_list and user_repo.filter(User.position_id.in_(id_list), User.is_delete == 0).first():
|
||||
return "存在用户引用该岗位,请先调整用户岗位"
|
||||
return None
|
||||
|
||||
# ============================================================
|
||||
# 获取岗位数据列表
|
||||
# ============================================================
|
||||
def get_position_list(self, request):
|
||||
"""获取岗位数据列表"""
|
||||
# 与 /page 一致的筛选条件:name 模糊、status 精确;保留软删过滤与排序
|
||||
query = self._apply_page_filters(self.repo.filter_by(), request)
|
||||
query = query.order_by(self.model.sort.asc())
|
||||
# 返回结果
|
||||
return [v.to_dict() for v in query.all()]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模块级单例
|
||||
# ============================================================
|
||||
position_service = PositionService()
|
||||
```
|
||||
|
||||
## 通用 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. 表单验证:Pydantic Field 约束(ge/le/min_length/max_length)
|
||||
4. 数据仓库:BaseRepository 提供通用 CRUD 能力,模块级单例
|
||||
5. 用户关联:用户表通过 dept_id、position_id、level_id 关联
|
||||
6. 删除保护:_before_delete 钩子防止引用悬空
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# 文件模板管理
|
||||
|
||||
:::tip 说明
|
||||
文件模板管理模块提供文件导入导出模板的管理能力,用于维护各种业务场景的 Excel/Word 等模板文件。模块位于 `src/modules/file_template/`。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/file_template/
|
||||
├── models.py # 模板模型
|
||||
├── schemas.py # 参数验证
|
||||
├── repository.py # 数据访问层
|
||||
├── service.py # 业务逻辑层
|
||||
└── handlers.py # 请求处理器
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
```python
|
||||
class FileTemplate(BaseModel):
|
||||
"""文件模板模型"""
|
||||
__tablename__ = "file_template"
|
||||
|
||||
name = Column(String(255), nullable=False, comment="模板名称")
|
||||
code = Column(String(100), default="", comment="模板编码")
|
||||
file_url = Column(String(500), default="", comment="模板文件路径")
|
||||
remark = Column(String(500), default="", comment="备注")
|
||||
status = Column(Integer, default=0, comment="状态")
|
||||
sort = Column(Integer, default=0, comment="排序")
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 权限 | 说明 |
|
||||
|:-----|:-----|:-----|:-----|
|
||||
| `/api/v1/filetemplate/page` | GET | `sys:filetemplate:list` | 分页查询 |
|
||||
| `/api/v1/filetemplate/detail/(\d+)` | GET | `sys:filetemplate:detail` | 详情查询 |
|
||||
| `/api/v1/filetemplate/add` | POST | `sys:filetemplate:add` | 新增 |
|
||||
| `/api/v1/filetemplate/update` | PUT | `sys:filetemplate:update` | 更新 |
|
||||
| `/api/v1/filetemplate/delete/(\d+)` | DELETE | `sys:filetemplate:delete` | 删除 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 数据导入模板:用户管理、商品管理等模块的 Excel 导入模板
|
||||
- 数据导出模板:报表导出的格式模板
|
||||
- 打印模板:合同、发票等文档的打印模板
|
||||
|
||||
## 总结
|
||||
|
||||
文件模板管理模块提供模板文件的 CRUD 管理能力,为数据导入导出、打印等业务场景提供模板支持。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 定时任务日志
|
||||
|
||||
:::tip 说明
|
||||
定时任务日志模块记录定时任务的执行历史,包括执行时间、执行结果、耗时等信息。模块位于 `src/modules/job_log/`,与定时任务模块(`modules/job/`)配合使用。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/job_log/
|
||||
├── models.py # 日志模型
|
||||
├── schemas.py # 参数验证
|
||||
├── repository.py # 数据访问层
|
||||
├── service.py # 业务逻辑层
|
||||
└── handlers.py # 请求处理器
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
```python
|
||||
class JobLog(BaseModel):
|
||||
"""定时任务日志模型"""
|
||||
__tablename__ = "job_log"
|
||||
|
||||
job_id = Column(Integer, nullable=False, comment="任务ID")
|
||||
job_name = Column(String(255), default="", comment="任务名称")
|
||||
job_group = Column(String(255), default="", comment="任务分组")
|
||||
invoke_target = Column(String(500), default="", comment="调用目标")
|
||||
job_message = Column(String(500), default="", comment="日志信息")
|
||||
status = Column(Integer, default=0, comment="状态:0=正常 1=失败")
|
||||
exception_info = Column(Text, default="", comment="异常信息")
|
||||
start_time = Column(String(50), default="", comment="开始时间")
|
||||
end_time = Column(String(50), default="", comment="结束时间")
|
||||
elapsed_time = Column(Integer, default=0, comment="耗时(毫秒)")
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 权限 | 说明 |
|
||||
|:-----|:-----|:-----|:-----|
|
||||
| `/api/v1/joblog/page` | GET | `sys:joblog:list` | 分页查询 |
|
||||
| `/api/v1/joblog/detail/(\d+)` | GET | `sys:joblog:detail` | 详情查询 |
|
||||
| `/api/v1/joblog/delete/(\d+)` | DELETE | `sys:joblog:delete` | 删除 |
|
||||
| `/api/v1/joblog/batchDelete` | POST | `sys:joblog:delete` | 批量删除 |
|
||||
|
||||
## 与定时任务模块的关系
|
||||
|
||||
| 模块 | 职责 |
|
||||
|:-----|:-----|
|
||||
| `modules/job/` | 定时任务的 CRUD 管理和调度执行 |
|
||||
| `modules/job_log/` | 任务执行日志的记录和查询 |
|
||||
|
||||
任务执行时自动记录日志,包括成功/失败状态、异常信息、执行耗时等。
|
||||
|
||||
## 总结
|
||||
|
||||
定时任务日志模块记录任务执行历史,支持按任务名称、状态、时间范围筛选,支持单条和批量删除,便于任务执行情况的监控和问题排查。
|
||||
@@ -0,0 +1,199 @@
|
||||
:::tip 定时任务
|
||||
定时任务模块基于 APScheduler 实现,支持 Cron 表达式调度、任务执行日志、动态启停等功能。包含 job(任务定义)、job_log(执行日志)、scheduler(调度器)三个子模块。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/job/
|
||||
├── models.py # 任务模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
├── service.py # 业务逻辑层
|
||||
├── scheduler.py # 调度器管理
|
||||
└── job_executor.py # 任务执行器
|
||||
|
||||
src/modules/job_log/
|
||||
├── models.py # 执行日志模型
|
||||
├── repository.py # 日志数据访问
|
||||
└── service.py # 日志业务逻辑
|
||||
```
|
||||
|
||||
## 任务模型
|
||||
|
||||
```python
|
||||
# src/modules/job/models.py
|
||||
# ============================================================
|
||||
# 定时任务模型
|
||||
# ============================================================
|
||||
class Job(BaseModel):
|
||||
"""定时任务模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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):
|
||||
"""定时任务日志模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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.py
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
def init_scheduler():
|
||||
"""启动时加载所有启用的任务"""
|
||||
jobs = job_repo.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 生命周期
|
||||
调度器在应用启动时初始化(`_combined_lifespan`),加载所有状态为"运行"的任务。应用关闭时自动停止调度器。
|
||||
:::
|
||||
|
||||
## 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/menu/models.py
|
||||
# ============================================================
|
||||
# 菜单模型
|
||||
# ============================================================
|
||||
class Menu(BaseModel):
|
||||
"""菜单模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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/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/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/menu/service.py
|
||||
|
||||
def get_permissions_list_sync(user_id):
|
||||
"""同步查询用户权限节点"""
|
||||
if user_id == 1:
|
||||
menu_list = menu_repo.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,276 @@
|
||||
:::tip 参数管理
|
||||
参数管理模块用于管理动态系统参数,与系统配置模块类似但更侧重于业务参数的灵活配置。支持按参数键查询、修改,无需重启服务即可生效。本模块是典型的「BaseService 简单模块」,通过声明差异点即可获得完整 CRUD 能力。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/param/
|
||||
├── models.py # 参数模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
└── service.py # 业务逻辑层(继承 BaseService)
|
||||
```
|
||||
|
||||
## 参数模型
|
||||
|
||||
```python
|
||||
# src/modules/param/models.py
|
||||
class Param(BaseModel):
|
||||
"""参数模型类"""
|
||||
__tablename__ = DB_PREFIX + "param"
|
||||
__table_comment__ = "系统参数表"
|
||||
|
||||
# 参数名称
|
||||
name = Column(String(100), nullable=False, index=True, comment="参数名称")
|
||||
# 参数编码(唯一标识,用于业务代码中按编码取值)
|
||||
code = Column(String(100), nullable=False, index=True, comment="参数编码")
|
||||
# 参数值
|
||||
value = Column(String(255), nullable=False, comment="参数值")
|
||||
# 参数类型:0-系统 1-业务
|
||||
type = Column(Integer, nullable=False, comment="参数类型:0-系统 1-业务")
|
||||
# 参数状态:1-正常 2-禁用
|
||||
status = Column(Integer, nullable=False, index=True, comment="参数状态:1-正常 2-禁用")
|
||||
# 参数排序
|
||||
sort = Column(Integer, nullable=False, comment="参数排序")
|
||||
# 参数备注
|
||||
note = Column(String(255), nullable=True, comment="参数备注")
|
||||
```
|
||||
|
||||
其中 `id`、`create_user`、`create_time`、`update_user`、`update_time`、`is_delete` 由基类 `base_model` 自动提供。
|
||||
|
||||
## 表单验证
|
||||
|
||||
```python
|
||||
class ParamForm(BaseSchema):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="参数名称")
|
||||
code: str = Field(..., min_length=1, max_length=100, description="参数编码")
|
||||
value: str = Field(..., min_length=1, max_length=255, description="参数值")
|
||||
type: int = Field(..., ge=0, le=1, description="参数类型:0-系统 1-业务")
|
||||
status: int = Field(..., ge=1, le=2, description="参数状态:1-正常 2-禁用")
|
||||
sort: int = Field(..., ge=0, le=99999, description="参数排序")
|
||||
note: Optional[str] = Field(None, max_length=255, description="参数备注")
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 约束 | 说明 |
|
||||
|:----|:----|:----|:----|
|
||||
| `name` | 是 | `1~100 字符` | 参数名称,唯一 |
|
||||
| `code` | 是 | `1~100 字符` | 参数编码,唯一,业务代码中按此取值 |
|
||||
| `value` | 是 | `1~255 字符` | 参数值,字符串类型 |
|
||||
| `type` | 是 | `0~1` | 0=系统参数,1=业务参数 |
|
||||
| `status` | 是 | `1~2` | 1=正常,2=禁用(注意:非 0/1) |
|
||||
| `sort` | 是 | `0~99999` | 排序号,值越大越靠前 |
|
||||
| `note` | 否 | `max_length=255` | 备注说明 |
|
||||
|
||||
:::warning status 字段的取值范围
|
||||
参数模块的 `status` 使用 `1=正常, 2=禁用`,与文章模块的 `0=正常, 1=下架` 不同。这是历史约定,开发时需注意区分。
|
||||
:::
|
||||
|
||||
## Service 层设计
|
||||
|
||||
### 继承 BaseService
|
||||
|
||||
参数模块是典型的 BaseService 简单模块,只需声明差异点即可获得完整 CRUD 能力:
|
||||
|
||||
```python
|
||||
class ParamService(BaseService(Param]):
|
||||
"""参数业务服务类,继承基础服务获得通用 CRUD 能力"""
|
||||
|
||||
# ============ 必须声明 ============
|
||||
repo = param_repo # 数据访问层实例
|
||||
model = Param # 模型类
|
||||
|
||||
# ============ 分页差异点 ============
|
||||
page_like_fields = ('name', 'code') # 模糊搜索字段
|
||||
page_eq_fields = ('type', 'status') # 等值搜索字段
|
||||
page_order_by = (('sort', 'asc'), ('id', 'desc')) # 排序规则
|
||||
|
||||
# ============ 唯一性校验 ============
|
||||
unique_fields = {
|
||||
'name': '参数名称不能重复',
|
||||
'code': '参数编码不能重复'
|
||||
}
|
||||
|
||||
# ============ 枚举显示名映射 ============
|
||||
serialize_maps = {
|
||||
'type': 'param_type', # 参数类型 → 数据字典
|
||||
'status': 'param_status' # 参数状态 → 数据字典
|
||||
}
|
||||
|
||||
# ============ 自定义方法 ============
|
||||
def get_value_by_code(self, code: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""根据参数编码获取参数值"""
|
||||
param = self.repo.get_one(code=code, status=1)
|
||||
return param.value if param else default
|
||||
```
|
||||
|
||||
### 差异点详解
|
||||
|
||||
#### page_like_fields(模糊搜索)
|
||||
|
||||
声明后,BaseService 自动从 `request.query_params` 中取同名参数做 `LIKE %值%` 查询:
|
||||
|
||||
```python
|
||||
# 声明 page_like_fields = ('name', 'code') 后
|
||||
# 请求 GET /api/v1/param/page?name=默认&code=PASSWORD
|
||||
# 自动生成:WHERE name LIKE '%默认%' AND code LIKE '%PASSWORD%'
|
||||
```
|
||||
|
||||
#### page_eq_fields(等值搜索)
|
||||
|
||||
声明后,自动取同名参数做精确匹配:
|
||||
|
||||
```python
|
||||
# 声明 page_eq_fields = ('type', 'status') 后
|
||||
# 请求 GET /api/v1/param/page?type=0&status=1
|
||||
# 自动生成:WHERE type = 0 AND status = 1
|
||||
```
|
||||
|
||||
#### page_order_by(排序规则)
|
||||
|
||||
```python
|
||||
# 声明 page_order_by = (('sort', 'asc'), ('id', 'desc'))
|
||||
# 生成:ORDER BY sort ASC, id DESC
|
||||
# 即排序号越小越靠前,同排序号时新记录在前
|
||||
```
|
||||
|
||||
#### unique_fields(唯一性校验)
|
||||
|
||||
```python
|
||||
# 声明 unique_fields = {'name': '参数名称不能重复', 'code': '参数编码不能重复'}
|
||||
# 新增时:查询是否存在同名/同编码记录
|
||||
# 编辑时:查询是否存在同名/同编码记录(排除自身 ID)
|
||||
# 若重复:返回 R.failed('参数名称不能重复')
|
||||
```
|
||||
|
||||
BaseService 内部自动调用 `repo.exists_by_field(field, value, exclude_id)` 实现,无需手写校验逻辑。
|
||||
|
||||
#### serialize_maps(枚举显示名映射)
|
||||
|
||||
```python
|
||||
# 声明 serialize_maps = {'type': 'param_type', 'status': 'param_status'}
|
||||
# 序列化时自动调用 get_dict_map('param_type') 获取字典映射
|
||||
# 输出时自动补 data['type_name'] = '系统参数' / '业务参数'
|
||||
# 输出时自动补 data['status_name'] = '正常' / '禁用'
|
||||
```
|
||||
|
||||
分页列表和详情接口均自动输出枚举名称,前端可直接展示中文。
|
||||
|
||||
## 按编码取值
|
||||
|
||||
这是参数模块的核心自定义方法,供其他业务模块调用:
|
||||
|
||||
```python
|
||||
param_service = ParamService()
|
||||
|
||||
# 获取参数值(存在且状态=正常时返回值,否则返回默认值)
|
||||
default_pwd = param_service.get_value_by_code("USER_DEFAULT_PASSWORD", "123456")
|
||||
upload_limit = param_service.get_value_by_code("UPLOAD_MAX_SIZE", "10")
|
||||
maintenance = param_service.get_value_by_code("MAINTENANCE_MODE", "0")
|
||||
```
|
||||
|
||||
### 实现原理
|
||||
|
||||
```python
|
||||
def get_value_by_code(self, code: str, default: Optional[str] = None) -> Optional[str]:
|
||||
param = self.repo.get_one(code=code, status=1) # 按编码查询,状态必须为正常
|
||||
return param.value if param else default
|
||||
```
|
||||
|
||||
- `repo.get_one(code=code, status=1)` 自动过滤软删除记录(`is_delete=0`)
|
||||
- `status=1` 确保禁用的参数不会被读取
|
||||
- 未找到时返回 `default` 值,避免调用方判空
|
||||
|
||||
:::tip 典型用法
|
||||
在其他 service 中注入参数值,实现运行时可调的业务配置:
|
||||
|
||||
```python
|
||||
from modules.param.service import param_service
|
||||
|
||||
def reset_password(user_id):
|
||||
# 从参数表读取默认密码,无需硬编码
|
||||
default_pwd = param_service.get_value_by_code("USER_DEFAULT_PASSWORD", "123456")
|
||||
# ... 重置密码逻辑
|
||||
```
|
||||
:::
|
||||
|
||||
## Repository 层
|
||||
|
||||
```python
|
||||
class ParamRepository(BaseRepository(Param]):
|
||||
"""参数数据仓库,继承基础仓库获得通用 CRUD 能力"""
|
||||
pass
|
||||
|
||||
param_repo = ParamRepository(Param)
|
||||
```
|
||||
|
||||
参数模块无需自定义查询方法,BaseRepository 的通用方法已满足需求。
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|:----|:----|:----|:----|
|
||||
| `/api/v1/param/page` | GET | `sys:param:page` | 分页查询(支持 name/code 模糊搜索 + type/status 精确匹配) |
|
||||
| `/api/v1/param/detail/{id}` | GET | `sys:param:detail` | 详情查询 |
|
||||
| `/api/v1/param/add` | POST | `sys:param:add` | 新增参数(自动校验 name/code 唯一性) |
|
||||
| `/api/v1/param/update` | PUT | `sys:param:update` | 编辑参数(自动校验 name/code 唯一性,排除自身) |
|
||||
| `/api/v1/param/delete/{id}` | DELETE | `sys:param:delete` | 单条删除 |
|
||||
| `/api/v1/param/batchDelete` | DELETE | `sys:param:batchDelete` | 批量删除 |
|
||||
|
||||
### 查询参数
|
||||
|
||||
分页接口支持以下查询参数(BaseService 自动处理):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `pageNo` | int | 页码,默认 1 |
|
||||
| `pageSize` | int | 每页条数,默认 10 |
|
||||
| `name` | string | 参数名称,模糊搜索 |
|
||||
| `code` | string | 参数编码,模糊搜索 |
|
||||
| `type` | int | 参数类型,精确匹配(0=系统 1=业务) |
|
||||
| `status` | int | 参数状态,精确匹配(1=正常 2=禁用) |
|
||||
|
||||
## 数据字典
|
||||
|
||||
参数模块使用以下数据字典(需在字典管理中配置):
|
||||
|
||||
| 字典编码 | 用途 | 字典项示例 |
|
||||
|:----|:----|:----|
|
||||
| `param_type` | 参数类型名称 | 0=系统参数, 1=业务参数 |
|
||||
| `param_status` | 参数状态名称 | 1=正常, 2=禁用 |
|
||||
|
||||
BaseService 的 `serialize_maps` 声明后,分页列表和详情接口自动输出 `type_name` 和 `status_name` 字段。
|
||||
|
||||
## 与系统配置的区别
|
||||
|
||||
| 特性 | 参数管理(Param) | 系统配置(Config) |
|
||||
|:----|:----|:----|
|
||||
| 用途 | 单个系统参数 | 配置分组 + 配置项 |
|
||||
| 结构 | 扁平(name+code+value) | 两级(config + config_item) |
|
||||
| 典型场景 | 默认密码、功能开关、上传限制 | SMTP 配置、站点信息、第三方服务 |
|
||||
| 取值方式 | `param_service.get_value_by_code()` | `config_service.get_value_by_key()` |
|
||||
| Service 模式 | BaseService 子类 | BaseService 子类 |
|
||||
|
||||
## 开发要点
|
||||
|
||||
```
|
||||
1. BaseService 子类:声明差异点即可,无需手写 CRUD 逻辑
|
||||
2. 唯一性校验:unique_fields 声明后自动校验 name 和 code 唯一性
|
||||
3. 枚举映射:serialize_maps 声明后自动输出 type_name 和 status_name
|
||||
4. 按编码取值:get_value_by_code() 是核心方法,供其他模块调用
|
||||
5. 状态值注意:status 使用 1=正常 2=禁用(非 0/1),与文章模块不同
|
||||
6. 排序规则:page_order_by = (('sort', 'asc'), ('id', 'desc')),排序号越小越靠前
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
参数管理模块是项目中典型的 BaseService 简单模块,核心特点:
|
||||
|
||||
```
|
||||
1. 继承 BaseService:声明 repo/model/page_like_fields/page_eq_fields/unique_fields/serialize_maps 即可
|
||||
2. 模糊 + 等值搜索:name/code 模糊搜索,type/status 等值搜索,BaseService 自动处理
|
||||
3. 唯一性校验:name 和 code 双重唯一性,BaseService 自动校验
|
||||
4. 枚举映射:type 和 status 自动映射为中文名称
|
||||
5. 按编码取值:get_value_by_code() 供其他业务模块调用,实现运行时可调配置
|
||||
6. 软删除 + 状态过滤:get_one 自动过滤已删除和已禁用的参数
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
:::tip 角色管理
|
||||
角色是 RBAC 权限模型的核心,连接用户和菜单(权限)。通过为角色分配菜单权限,再将角色分配给用户,实现灵活的权限管理。角色删除时需清理关联的用户角色和角色菜单数据,并即时失效受影响用户的权限缓存。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/role/
|
||||
├── models.py # 角色模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
└── service.py # 业务逻辑层
|
||||
|
||||
src/modules/role_menu/
|
||||
├── models.py # 角色-菜单关联模型
|
||||
├── repository.py # 关联数据访问
|
||||
└── service.py # 关联业务逻辑
|
||||
```
|
||||
|
||||
## 角色模型
|
||||
|
||||
```python
|
||||
# src/modules/role/models.py
|
||||
# ============================================================
|
||||
# 角色模型
|
||||
# ============================================================
|
||||
class Role(BaseModel):
|
||||
"""角色模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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)
|
||||
```
|
||||
|
||||
## 角色-菜单关联
|
||||
|
||||
角色与菜单通过 `tornado_role_menu` 关联表实现多对多关系:
|
||||
|
||||
```python
|
||||
# src/modules/role_menu/models.py
|
||||
# ============================================================
|
||||
# 角色菜单关联模型
|
||||
# ============================================================
|
||||
class RoleMenu(BaseModel):
|
||||
"""角色菜单关联模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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
|
||||
|
||||
角色模块使用自定义 service 函数,因为涉及唯一性校验(名称+编码双字段)、内置角色保护、关联数据清理等复杂逻辑:
|
||||
|
||||
```python
|
||||
# src/modules/role/service.py 关键片段
|
||||
def add_role(request, data: RoleForm):
|
||||
"""新增角色"""
|
||||
# 唯一性校验(名称 + 编码,编码重复会导致角色识别/鉴权混乱)
|
||||
if role_repo.get_one(name=data.name):
|
||||
return R.failed("角色名称不能重复")
|
||||
if role_repo.get_one(code=data.code):
|
||||
return R.failed("角色编码不能重复")
|
||||
|
||||
role_repo.create(**data.model_dump(exclude={'id'}), create_user=get_realname(request))
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
## 删除与缓存清理
|
||||
|
||||
角色删除是异步操作,需在删除前收集受影响用户 ID,删除后逐一清理权限缓存:
|
||||
|
||||
```python
|
||||
# src/modules/role/service.py
|
||||
|
||||
async def delete_role(role_id):
|
||||
def _delete():
|
||||
id_list = parse_id_list(role_id)
|
||||
|
||||
# 内置角色禁止删除
|
||||
if BUILTIN_ROLE_IDS & set(id_list):
|
||||
return R.failed("内置角色不允许删除"), None
|
||||
|
||||
records = role_repo.filter(Role.id.in_(id_list), Role.is_delete == 0).all()
|
||||
for record in records:
|
||||
record.is_delete = 1
|
||||
|
||||
# 收集受影响用户ID(必须在删除 UserRole 关联之前查)
|
||||
affected_user_ids = [
|
||||
row.user_id for row in self.db.query(UserRole.user_id).filter(UserRole.role_id.in_(id_list)).all()
|
||||
]
|
||||
|
||||
# 清理角色关联
|
||||
self.db.query(UserRole).filter(UserRole.role_id.in_(id_list)).delete(synchronize_session=False)
|
||||
self.db.query(RoleMenu).filter(RoleMenu.role_id.in_(id_list)).delete(synchronize_session=False)
|
||||
|
||||
return None, (id_list, records, affected_user_ids)
|
||||
|
||||
err, result = _delete()
|
||||
if err:
|
||||
return err
|
||||
|
||||
id_list, records, affected_user_ids = result
|
||||
|
||||
# 清理被删角色下所有用户的权限缓存
|
||||
for uid in affected_user_ids:
|
||||
await invalidate_user_perms(uid)
|
||||
|
||||
return R.ok(msg="本次共删除{0}条数据".format(len(records)))
|
||||
```
|
||||
|
||||
:::warning 删除顺序
|
||||
必须在删除 `UserRole` 关联**之前**收集受影响用户 ID,否则删后无法回查。`invalidate_user_perms` 会因查不到关联而失效失败,导致历史权限残留最长 2 小时。
|
||||
:::
|
||||
|
||||
## 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. 双字段唯一性:name + code 均需唯一性校验
|
||||
3. 内置角色保护:BUILTIN_ROLE_IDS 中的角色禁止删除
|
||||
4. 级联清理:删除角色时硬删 user_role / role_menu 关联
|
||||
5. 缓存失效:删除前收集受影响用户,删除后逐一清理权限缓存
|
||||
6. 灵活组合:一个角色可分配多个菜单,一个用户可拥有多个角色
|
||||
```
|
||||
@@ -0,0 +1,312 @@
|
||||
:::tip 用户管理
|
||||
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。与简单 CRUD 模块不同,用户管理使用自定义 service 函数而非继承 BaseService 的类方法。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/user/
|
||||
├── models.py # 用户模型
|
||||
├── schemas.py # 表单验证
|
||||
├── repository.py # 数据访问层
|
||||
└── service.py # 业务逻辑层(自定义函数)
|
||||
|
||||
src/modules/user_role/
|
||||
├── models.py # 用户-角色关联模型
|
||||
├── repository.py # 关联数据访问
|
||||
└── service.py # 关联业务逻辑
|
||||
```
|
||||
|
||||
## 用户模型
|
||||
|
||||
```python
|
||||
# src/modules/user/models.py
|
||||
# ============================================================
|
||||
# 用户模型
|
||||
# ============================================================
|
||||
class User(BaseModel):
|
||||
"""用户模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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。
|
||||
:::
|
||||
|
||||
## 角色关联
|
||||
|
||||
用户与角色通过 `tornado_user_role` 关联表实现多对多关系:
|
||||
|
||||
```python
|
||||
# src/modules/user_role/models.py
|
||||
# ============================================================
|
||||
# 用户角色关联模型
|
||||
# ============================================================
|
||||
class UserRole(BaseModel):
|
||||
"""用户角色关联模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__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/user/service.py 关键片段
|
||||
async def add_user(request, data: UserForm):
|
||||
"""新增用户"""
|
||||
def _add():
|
||||
# 唯一性校验
|
||||
if user_repo.get_one(username=data.username):
|
||||
return R.failed("登录账号不能重复"), None
|
||||
|
||||
formData = data.model_dump(exclude={'id', 'city', 'roles'})
|
||||
|
||||
# 密码加密:未传密码时使用默认密码
|
||||
pwd = data.password or DEFAULT_PASSWORD
|
||||
salt = password.generate_salt()
|
||||
formData['password'] = password.encrypt_password(pwd, salt)
|
||||
formData['salt'] = salt
|
||||
|
||||
# 行政区划处理
|
||||
citys = data.city or []
|
||||
formData['provinceCode'] = str(citys[0]) if len(citys) > 0 and citys[0] else ''
|
||||
# ... 其他字段处理
|
||||
|
||||
user = user_repo.create(**formData, create_user=get_realname(request))
|
||||
user.code = f"{user.id:06d}" # 根据自增ID生成6位用户编码
|
||||
return None, user.id
|
||||
|
||||
err, user_id = _add()
|
||||
if err:
|
||||
return err
|
||||
|
||||
# 创建用户角色数据 + 清理权限缓存
|
||||
if data.roles is not None:
|
||||
await add_user_role(user_id, data.roles)
|
||||
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
## 关联数据富化
|
||||
|
||||
用户列表和详情需要富化部门、职级、岗位、角色等关联数据:
|
||||
|
||||
```python
|
||||
# src/modules/user/service.py
|
||||
def _enrich_user_records(records):
|
||||
"""为用户记录补充关联数据"""
|
||||
dept_map = {d.id: d.name for d in dept_repo.all()}
|
||||
level_map = {l.id: l.name for l in level_repo.all()}
|
||||
position_map = {p.id: p.name for p in position_repo.all()}
|
||||
gender_map = get_dict_map('gender')
|
||||
|
||||
user_ids = [data.get("id") for data in records]
|
||||
role_map = get_user_role_map(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 utils.perm_cache import invalidate_user_perms, invalidate_user_active
|
||||
|
||||
# 角色变更后清理缓存
|
||||
await invalidate_user_perms(user_id)
|
||||
await invalidate_user_active(user_id)
|
||||
|
||||
# 状态变更后清理缓存(禁用即时踢出、启用即时放行)
|
||||
await invalidate_user_perms(id)
|
||||
await invalidate_user_active(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. 复杂业务:自定义 service 函数,异步操作用 asyncio.to_thread 包裹
|
||||
2. 密码安全:bcrypt 双重加盐,工作因子12(约250ms)
|
||||
3. 角色关联:多对多关系,通过 user_role 关联表,删除时硬删关联
|
||||
4. 权限缓存:角色/状态变更后即时清理 Redis 缓存
|
||||
5. 关联富化:列表/详情补充部门、职级、岗位、角色、性别等展示名称
|
||||
6. 行政区划:省/市/区/街道四级编码,自动拼接城市信息
|
||||
7. Excel 导入导出:列定义式导入解析,逐行校验,部分成功返回明细
|
||||
8. 超管保护:ID=1 账号禁止修改/删除/禁用/重置密码
|
||||
```
|
||||
Reference in New Issue
Block a user