签入版本

This commit is contained in:
2026-09-16 13:28:20 +08:00
parent 6cd5e80de0
commit c97d87847f
410 changed files with 68726 additions and 0 deletions
@@ -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 账号禁止修改/删除/禁用/重置密码
```