签入版本
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# API响应规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目统一使用 `R` 模块封装 API 响应,所有接口返回一致的 JSON 格式,便于前端统一处理。
|
||||
|
||||
:::tip 核心原则
|
||||
- 所有接口必须通过 `R.ok()` 或 `R.failed()` 返回,禁止直接返回 dict
|
||||
- 成功响应 `code=0`,失败响应 `code=1`
|
||||
- 响应体始终包含 `code`、`data`、`msg`、`ok` 四个字段
|
||||
:::
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "错误信息",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
### 分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [...],
|
||||
"count": 100,
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## R 模块使用方法
|
||||
|
||||
### R.ok() 成功响应
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
|
||||
# 基础成功响应
|
||||
return R.ok()
|
||||
|
||||
# 带数据的成功响应
|
||||
return R.ok(data=user_info)
|
||||
|
||||
# 自定义提示消息
|
||||
return R.ok(msg='添加成功')
|
||||
|
||||
# 附加额外字段(如 count)
|
||||
return R.ok(data=result, count=total)
|
||||
```
|
||||
|
||||
### R.failed() 失败响应
|
||||
|
||||
```python
|
||||
# 基础失败响应
|
||||
return R.failed()
|
||||
|
||||
# 自定义错误消息
|
||||
return R.failed('岗位名称不能重复')
|
||||
|
||||
# 自定义错误码和消息
|
||||
return R.failed(msg='未授权', code=401)
|
||||
```
|
||||
|
||||
### R.page() 分页响应
|
||||
|
||||
```python
|
||||
# 分页响应(由 BaseService.get_page 自动调用)
|
||||
return R.page(data=records, total=100, current=1, size=10)
|
||||
```
|
||||
|
||||
## 字段定义
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | int | 状态码:0=成功,1=失败,401=未认证,403=无权限 |
|
||||
| `data` | any/null | 响应数据,失败时为 null |
|
||||
| `msg` | string | 提示消息 |
|
||||
| `ok` | boolean | 是否成功 |
|
||||
|
||||
## 状态码规范
|
||||
|
||||
| 状态码 | 含义 | 使用场景 |
|
||||
|--------|------|---------|
|
||||
| 0 | 成功 | 操作成功 |
|
||||
| 1 | 失败 | 业务逻辑失败(如参数校验、唯一性冲突) |
|
||||
| 401 | 未认证 | token 缺失或过期 |
|
||||
| 403 | 无权限 | 用户无对应权限 |
|
||||
| 404 | 资源不存在 | 请求的资源未找到 |
|
||||
| 422 | 验证错误 | WTForms 参数校验失败 |
|
||||
| 500 | 服务器错误 | 未捕获的异常 |
|
||||
|
||||
:::tip 错误码与 HTTP 状态码
|
||||
业务错误码(code 字段)始终为 0 或 1,HTTP 状态码始终为 200。401/403/422 等仅在全局异常处理器中使用,常规业务错误统一返回 `code=1`。
|
||||
:::
|
||||
|
||||
## 在 Router 中使用
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
|
||||
# 查询详情:service 返回 dict,router 包 R.ok
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
@check_permission('sys:position:detail')
|
||||
def detail(id):
|
||||
from src.modules.system.position.service import get_position_detail
|
||||
result = get_position_detail(id)
|
||||
return R.ok(data=result)
|
||||
|
||||
# 添加记录:service 直接返回 R 对象
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
|
||||
# 删除记录:service 直接返回 R 对象
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
def delete(id):
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
```
|
||||
|
||||
:::tip 响应封装层级
|
||||
- **分页/列表查询**:service 返回 `R.ok()` 或 `R.page()`
|
||||
- **详情查询**:service 返回 dict/None,router 包 `R.ok(data=result)`
|
||||
- **写操作(增/删/改/状态)**:service 直接返回 `R` 对象
|
||||
:::
|
||||
|
||||
## WTForms 验证错误响应
|
||||
|
||||
WTForms 验证错误由 `parse_request_body` 函数自动转换为标准格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "岗位名称不能为空",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
API 响应规范通过 `R` 模块统一封装:`R.ok()` 返回成功、`R.failed()` 返回失败、`R.page()` 返回分页。所有响应保持 `{code, data, msg, ok}` 四字段结构,HTTP 状态码始终为 200。业务层错误统一用 `code=1`,前端只需判断 `ok` 字段即可。
|
||||
@@ -0,0 +1,108 @@
|
||||
# 代码审查清单
|
||||
|
||||
## 概述
|
||||
|
||||
代码审查(Code Review)是保障代码质量的重要环节。本文提供标准化的审查清单,覆盖安全、性能、规范、边界等维度,帮助审查者系统性地检查代码质量。
|
||||
|
||||
:::tip 审查原则
|
||||
- 每次提交必须经过至少一人审查
|
||||
- 审查关注点:正确性 > 安全性 > 可维护性 > 性能
|
||||
- 审查意见应具体、可操作,避免主观评价
|
||||
:::
|
||||
|
||||
## 安全检查
|
||||
|
||||
- [ ] **DEBUG 关闭**:生产配置 `FLASK_DEBUG=False`
|
||||
- [ ] **JWT 安全**:`JWT_SALT` 使用强密码,未硬编码在代码中
|
||||
- [ ] **CORS 配置**:未使用 `origins="*"`,仅列出信任域名
|
||||
- [ ] **SQL 注入**:使用 ORM 参数化查询,禁止字符串拼接 SQL
|
||||
- [ ] **权限校验**:写操作接口有 `@check_permission` 装饰器
|
||||
- [ ] **敏感信息**:日志中不记录密码、token、密钥等敏感信息
|
||||
- [ ] **文件上传**:有文件类型白名单验证,文件名已重命名
|
||||
- [ ] **输入校验**:所有用户输入通过 WTForms Schema 校验
|
||||
- [ ] **.env 安全**:`.env` 文件未提交到 Git(在 `.gitignore` 中)
|
||||
- [ ] **密码存储**:使用 bcrypt 哈希存储,禁止明文
|
||||
|
||||
## 性能检查
|
||||
|
||||
- [ ] **N+1 查询**:关联查询使用 joinedload/subqueryload,避免循环查询
|
||||
- [ ] **索引覆盖**:WHERE/ORDER BY 字段已添加索引
|
||||
- [ ] **分页查询**:大列表接口使用分页,禁止全表查询返回
|
||||
- [ ] **SELECT ***:只查询需要的字段,避免 `SELECT *`
|
||||
- [ ] **缓存使用**:热点数据使用 Redis 缓存
|
||||
- [ ] **批量操作**:批量插入/更新使用 `executemany`,非逐条执行
|
||||
- [ ] **连接池**:数据库连接池大小合理,不超过数据库 max_connections
|
||||
|
||||
## 代码规范检查
|
||||
|
||||
- [ ] **装饰器顺序**:`@blueprint.route` → `@check_permission`
|
||||
- [ ] **命名规范**:文件名 snake_case,类名 PascalCase,函数名 snake_case
|
||||
- [ ] **注释规范**:关键逻辑有注释说明,docstring 描述函数用途
|
||||
- [ ] **导入规范**:导入分组(标准库 → 第三方 → 本地模块),无未使用的导入
|
||||
- [ ] **R 封装**:所有接口通过 `R.ok()`/`R.failed()` 返回,禁止直接返回 dict
|
||||
- [ ] **Repository 规范**:模块级单例模式(`_xxx_repository = XxxRepository()`)
|
||||
- [ ] **Service 规范**:模块级代理函数(`xxx_func = _xxx_service.func`)
|
||||
- [ ] **延迟导入**:Router 层使用延迟导入避免循环引用
|
||||
|
||||
## 边界条件检查
|
||||
|
||||
- [ ] **空值处理**:可选字段支持 None 值,不抛异常
|
||||
- [ ] **空列表**:批量操作传入空列表时安全返回
|
||||
- [ ] **超大输入**:字符串长度限制合理(`max_length`)
|
||||
- [ ] **数值范围**:数值字段有 `min`/`max` 约束
|
||||
- [ ] **ID 有效性**:ID 参数校验 > 0,兼容字符串 ID
|
||||
- [ ] **重复操作**:重复添加/删除同一数据的幂等性
|
||||
- [ ] **并发安全**:并发修改同一记录的处理(乐观锁/唯一性校验)
|
||||
- [ ] **软删除过滤**:查询方法自动过滤 `is_delete = 0`
|
||||
|
||||
## 数据库检查
|
||||
|
||||
- [ ] **模型定义**:继承 `BaseModel` 和 `BaseDB`
|
||||
- [ ] **表名前缀**:`__tablename__ = DB_PREFIX + "表名"`
|
||||
- [ ] **字段注释**:`comment` 参数描述字段用途
|
||||
- [ ] **索引定义**:常用查询字段有索引
|
||||
- [ ] **默认值**:`server_default` 与数据库默认值一致
|
||||
- [ ] **软删除**:不使用物理删除,统一软删除
|
||||
|
||||
## API 接口检查
|
||||
|
||||
- [ ] **HTTP 方法**:查询用 GET,新增用 POST,编辑用 PUT,删除用 DELETE
|
||||
- [ ] **权限标识**:格式 `sys:{module}:{action}`
|
||||
- [ ] **请求参数**:WTForms Schema 定义完整约束
|
||||
- [ ] **响应格式**:统一使用 `R.ok()`/`R.failed()`
|
||||
- [ ] **错误提示**:错误消息清晰、用户友好
|
||||
- [ ] **路由注册**:新模块已在 `src/routes/v1.py` 中注册
|
||||
|
||||
## 前端检查
|
||||
|
||||
- [ ] **API 函数命名**:`getXxxPage`/`addXxx`/`updateXxx`/`deleteXxx`
|
||||
- [ ] **错误处理**:响应拦截器统一处理错误
|
||||
- [ ] **loading 状态**:数据加载时显示 loading
|
||||
- [ ] **确认弹窗**:删除操作有二次确认
|
||||
- [ ] **表单校验**:提交前前端校验必填项
|
||||
|
||||
## 提交检查
|
||||
|
||||
- [ ] **提交信息**:遵循 Conventional Commits 格式
|
||||
- [ ] **变更范围**:每次提交只做一件事
|
||||
- [ ] **测试覆盖**:新功能/修复有对应测试
|
||||
- [ ] **文档更新**:接口变更同步更新文档
|
||||
|
||||
## 审查意见模板
|
||||
|
||||
```
|
||||
[严重] 安全问题:SQL 拼接存在注入风险,应使用 ORM 参数化查询
|
||||
[建议] 性能优化:此处可用 joinedload 避免 N+1 查询
|
||||
[疑问] 这里的 try-except 吞掉了异常,是否需要记录日志?
|
||||
[肯定] 使用 before_delete 钩子统一处理删除前校验,符合规范
|
||||
```
|
||||
|
||||
:::tip 审查态度
|
||||
- 区分「必须修改」和「建议优化」
|
||||
- 给出具体的修改方案,而非只指出问题
|
||||
- 对好的实践给予肯定
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
代码审查清单覆盖安全、性能、规范、边界、数据库、API、前端、提交等 8 个维度。审查时按清单逐项检查,区分严重问题和建议优化。核心关注点:权限校验是否完整、SQL 是否安全、响应格式是否统一。审查意见应具体可操作,帮助团队持续提升代码质量。
|
||||
@@ -0,0 +1,115 @@
|
||||
# 删除规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目统一使用软删除机制,通过 `is_delete` 字段标记记录状态,不物理删除数据。删除操作分为单条删除和批量删除两种方式。
|
||||
|
||||
:::tip 软删除约定
|
||||
- `is_delete = 0`:正常记录(默认值)
|
||||
- `is_delete = 1`:已删除记录
|
||||
- 所有查询方法自动过滤 `is_delete = 0`,业务层无需关心
|
||||
:::
|
||||
|
||||
## 单条删除
|
||||
|
||||
### URL 格式
|
||||
|
||||
```
|
||||
DELETE /api/v1/{module}/delete/{id}
|
||||
```
|
||||
|
||||
### Router 示例
|
||||
|
||||
```python
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
def delete(id):
|
||||
"""根据ID删除单个岗位"""
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
```
|
||||
|
||||
:::tip 装饰器顺序
|
||||
删除接口的装饰器顺序:`@position_blue.route` → `@check_permission`
|
||||
:::
|
||||
|
||||
## 批量删除
|
||||
|
||||
### 请求格式
|
||||
|
||||
```
|
||||
DELETE /api/v1/{module}/batchDelete
|
||||
Content-Type: application/json
|
||||
|
||||
[1, 2, 3]
|
||||
```
|
||||
|
||||
### Router 示例
|
||||
|
||||
```python
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
"""批量删除岗位"""
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 删除流程
|
||||
|
||||
```
|
||||
1. Router 接收请求
|
||||
↓
|
||||
2. parse_id_list(request) 解析 ID 数组
|
||||
↓
|
||||
3. Service.batch_delete(id_list)
|
||||
↓
|
||||
4. before_delete(ids) 钩子校验
|
||||
↓(拦截则返回 R.failed)
|
||||
5. repository.batch_delete(ids) 软删除
|
||||
↓
|
||||
6. 返回 R.ok(msg="本次共删除N条数据")
|
||||
```
|
||||
|
||||
## Repository 层实现
|
||||
|
||||
```python
|
||||
def batch_delete(self, id_list) -> int:
|
||||
"""按ID软删除,返回实际删除条数"""
|
||||
if not id_list:
|
||||
return 0
|
||||
|
||||
records = cls.model.query.filter(
|
||||
cls.model.id.in_(id_list),
|
||||
cls.model.is_delete == 0
|
||||
).all()
|
||||
|
||||
for record in records:
|
||||
record.is_delete = 1
|
||||
|
||||
return len(records)
|
||||
```
|
||||
|
||||
:::tip 返回值说明
|
||||
`batch_delete` 返回实际软删除的条数。如果传入的 ID 中包含已删除或不存在的记录,返回值会小于入参数量。
|
||||
:::
|
||||
|
||||
## 权限标识
|
||||
|
||||
| 操作 | 权限标识 | 说明 |
|
||||
|------|---------|------|
|
||||
| 单条删除 | `sys:{module}:delete` | 删除单条记录 |
|
||||
| 批量删除 | `sys:{module}:batchDelete` | 批量删除记录 |
|
||||
|
||||
## 常见错误提示
|
||||
|
||||
| 提示 | 原因 |
|
||||
|------|------|
|
||||
| "记录ID不存在" | 入参为空或格式错误 |
|
||||
| "记录不存在" | 部分或全部 ID 对应的记录已删除/不存在 |
|
||||
| "存在用户引用该岗位" | before_delete 钩子拦截 |
|
||||
|
||||
## 总结
|
||||
|
||||
删除规范统一使用软删除(`is_delete` 标记),支持单条删除和批量删除(JSON 数组)。Repository 层做数据原语,Service 层统一封装响应。自定义 Service 通过 `before_delete` 钩子添加业务校验。
|
||||
@@ -0,0 +1,171 @@
|
||||
# 文档规范
|
||||
|
||||
## 概述
|
||||
|
||||
良好的文档是项目可维护性的基石。本文规范 API 文档、WTForms 文档、代码注释等文档的编写标准。
|
||||
|
||||
:::tip 文档原则
|
||||
- 文档与代码同步更新
|
||||
- 使用中文编写(项目约定)
|
||||
- 示例代码可直接运行
|
||||
:::
|
||||
|
||||
## API 接口文档
|
||||
|
||||
建议使用 Postman 或 Apifox 等工具维护 API 文档。
|
||||
|
||||
### Router 文档注释
|
||||
|
||||
```python
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
"""
|
||||
新增岗位
|
||||
|
||||
请求参数:
|
||||
- name: 岗位名称(必填,1-150字符)
|
||||
- status: 岗位状态(必填,1-在用 2-停用)
|
||||
- sort: 岗位排序(必填,0-99999)
|
||||
|
||||
:return: 添加结果响应
|
||||
"""
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
```
|
||||
|
||||
:::tip 文档层级
|
||||
- 函数名:简短标题
|
||||
- docstring:详细描述(请求参数、返回值)
|
||||
- WTForms 字段:字段级描述
|
||||
:::
|
||||
|
||||
## WTForms 文档
|
||||
|
||||
WTForms 表单通过字段的 label 参数和 validator 的 message 参数提供文档信息:
|
||||
|
||||
```python
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
```
|
||||
|
||||
:::tip 文档规范
|
||||
- label:字段中文名,用于前端显示
|
||||
- message:校验失败提示,用于返回给用户
|
||||
- 枚举值说明使用 `值-含义` 格式(如 `1-在用 2-停用`)
|
||||
- 有约束条件时在 message 中说明(如 `1-150字符`)
|
||||
:::
|
||||
|
||||
## 代码注释规范
|
||||
|
||||
### 文件头注释
|
||||
|
||||
```python
|
||||
# +======================================================================
|
||||
# | 模块: 岗位业务逻辑层
|
||||
# | 说明: 岗位的增删改查、唯一性校验、状态管理等业务处理
|
||||
# +======================================================================
|
||||
```
|
||||
|
||||
### 类注释
|
||||
|
||||
```python
|
||||
class PositionService(BaseService):
|
||||
"""岗位业务服务类,继承基础服务获得通用 CRUD 能力"""
|
||||
```
|
||||
|
||||
### 方法注释
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def before_delete(cls, ids):
|
||||
"""
|
||||
删除前校验:存在用户引用该岗位时禁止删除
|
||||
|
||||
Args:
|
||||
ids: 待删除的岗位ID列表
|
||||
|
||||
Raises:
|
||||
BizError: 岗位被用户引用时抛出
|
||||
"""
|
||||
for pid in ids:
|
||||
if UserRepository.count_by({'position_id': pid}) > 0:
|
||||
raise BizError(f"岗位ID {pid} 已被用户引用,无法删除")
|
||||
```
|
||||
|
||||
### 行内注释
|
||||
|
||||
```python
|
||||
# 自动软删过滤,调用方无需关心 is_delete
|
||||
query = query.filter(cls.model.is_delete == 0)
|
||||
```
|
||||
|
||||
:::tip 注释原则
|
||||
- 解释"为什么"而非"是什么"
|
||||
- 复杂逻辑必须注释
|
||||
- 简单代码无需注释
|
||||
- 注释与代码同步更新
|
||||
:::
|
||||
|
||||
## 文档目录结构
|
||||
|
||||
```
|
||||
document/ # 补充文档
|
||||
├── 数据库迁移_runbook.md # 数据库迁移操作手册
|
||||
└── djangoadmin.flask.elevue.sql # 数据库结构 SQL
|
||||
|
||||
wiki/ # VitePress 文档站
|
||||
├── zh/ # 中文文档
|
||||
│ ├── 1. 了解项目/ # why.md, struct.md, course.md
|
||||
│ ├── 2. 快速入门/ # volume1/(环境、启动、数据库、Docker)
|
||||
│ ├── 3. 模块开发实战/ # volume2/(Model→Schema→Repo→Service→Router→前端)
|
||||
│ ├── 4. 架构设计/ # volume3/(分层、基类、缓存、安全)
|
||||
│ ├── 5. 开发指南/ # volume4/
|
||||
│ │ ├── 5.1 后端核心功能/ # core/(JWT、RBAC、字典、日志、限流、事务)
|
||||
│ │ ├── 5.2 后端业务模块/ # business/(用户、角色、菜单、文章、任务)
|
||||
│ │ ├── 5.3 后端通用工具/ # tools/(上传、Excel、IP/UA、密码、富文本)
|
||||
│ │ ├── 5.4 前端开发/ # frontend/(组件、路由、Store、构建)
|
||||
│ │ └── 5.5 前后端联调/ # integration/(代理、权限、上传、字典)
|
||||
│ ├── 6. 代码生成器/ # volume5/(CLI、Web UI、模板)
|
||||
│ ├── 7. 运维部署/ # volume6/(Docker、Nginx、Supervisor、监控)
|
||||
│ └── 8. 规范标准/ # volume7/(API 响应、分页、Git、测试、文档)
|
||||
```
|
||||
|
||||
## 前端文档结构
|
||||
|
||||
前端相关文档分布在以下位置:
|
||||
|
||||
| 文档 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端页面规范 | `volume7/fe-component.md` | 页面结构、组件使用、命名规范 |
|
||||
| 前端 API 规范 | `volume7/fe-api.md` | API 文件组织、函数命名、请求规范 |
|
||||
| 公共组件库 | `volume4/frontend/components.md` | 组件列表、Props、Events、用法 |
|
||||
| 前端构建 | `volume4/frontend/build.md` | Vite 配置、环境变量、Nginx 部署 |
|
||||
| API 层开发 | `volume4/frontend/api.md` | API 层开发指南 |
|
||||
| 页面视图开发 | `volume4/frontend/view.md` | 页面视图开发指南 |
|
||||
|
||||
## 文档更新检查清单
|
||||
|
||||
- [ ] 新增/修改 API 接口时,同步更新 Router 注释
|
||||
- [ ] 新增/修改 WTForms 字段时,填写 label 和 message
|
||||
- [ ] 复杂逻辑添加代码注释
|
||||
- [ ] 重大变更更新 wiki 文档
|
||||
|
||||
## 总结
|
||||
|
||||
文档规范覆盖 API 文档(Router 注释)、WTForms 文档(字段 label 和 message)、代码注释(文件头/类/方法/行内)。核心原则:文档与代码同步更新,使用中文编写,示例可直接运行。重大变更同步更新 wiki 文档。
|
||||
@@ -0,0 +1,215 @@
|
||||
# Router层规范
|
||||
|
||||
## 概述
|
||||
|
||||
Router 层是 HTTP 接口层,定义路由和请求处理。每个模块的 Router 文件位于 `src/modules/{group}/{name}/router.py`,使用 Flask `Blueprint` 定义接口。
|
||||
|
||||
:::tip 设计原则
|
||||
- Router 只做请求分发,不含业务逻辑
|
||||
- 通过装饰器控制权限
|
||||
- 委托 Service 处理业务逻辑
|
||||
- 返回 R 封装的响应
|
||||
:::
|
||||
|
||||
## 装饰器顺序
|
||||
|
||||
```python
|
||||
@position_blue.route('/add', methods=['POST']) # 1. 路由定义
|
||||
@check_permission('sys:position:add') # 2. 权限校验
|
||||
def add():
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
```
|
||||
|
||||
:::warning 装饰器顺序规则
|
||||
1. `@blueprint.route()`:路由定义(最外层)
|
||||
2. `@check_permission("权限标识")`:RBAC 权限校验
|
||||
|
||||
顺序不可调换,否则权限校验可能失效。
|
||||
:::
|
||||
|
||||
## 读操作
|
||||
|
||||
```python
|
||||
# 分页查询
|
||||
@position_blue.route('/page', methods=['GET'])
|
||||
@check_permission('sys:position:page')
|
||||
def page():
|
||||
from src.modules.system.position.service import get_position_page
|
||||
return get_position_page()
|
||||
|
||||
# 详情查询
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
@check_permission('sys:position:detail')
|
||||
def detail(id):
|
||||
from src.modules.system.position.service import get_position_detail
|
||||
return R.ok(data=get_position_detail(id))
|
||||
|
||||
# 列表查询(无分页)
|
||||
@position_blue.route('/list', methods=['GET'])
|
||||
@check_permission('sys:position:list')
|
||||
def get_position_list():
|
||||
from src.modules.system.position.service import get_position_list
|
||||
return R.ok(data=get_position_list())
|
||||
```
|
||||
|
||||
## 写操作
|
||||
|
||||
```python
|
||||
# 添加
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
|
||||
# 编辑
|
||||
@position_blue.route('/update', methods=['PUT'])
|
||||
@check_permission('sys:position:update')
|
||||
def update():
|
||||
from src.modules.system.position.service import update_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return update_position(data)
|
||||
|
||||
# 删除
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
def delete(id):
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
|
||||
# 设置状态
|
||||
@position_blue.route('/status', methods=['PUT'])
|
||||
@check_permission('sys:position:status')
|
||||
def status():
|
||||
from src.modules.system.position.service import update_position_status
|
||||
from src.modules.system.position.schema import PositionStatusForm
|
||||
data = parse_request_body(request, PositionStatusForm)
|
||||
return update_position_status(data)
|
||||
|
||||
# 批量删除
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 权限标识规范
|
||||
|
||||
权限标识格式:`sys:{module}:{action}`
|
||||
|
||||
| 操作 | 权限标识 | HTTP 方法 |
|
||||
|------|---------|----------|
|
||||
| 分页查询 | `sys:{module}:page` | GET |
|
||||
| 详情查询 | `sys:{module}:detail` | GET |
|
||||
| 列表查询 | `sys:{module}:list` | GET |
|
||||
| 添加 | `sys:{module}:add` | POST |
|
||||
| 编辑 | `sys:{module}:update` | PUT |
|
||||
| 删除 | `sys:{module}:delete` | DELETE |
|
||||
| 批量删除 | `sys:{module}:batchDelete` | DELETE |
|
||||
| 状态设置 | `sys:{module}:status` | PUT |
|
||||
|
||||
:::tip admin 用户
|
||||
用户 ID 为 1 的管理员账号跳过所有 `check_permission` 检查。
|
||||
:::
|
||||
|
||||
## 完整 Router 文件模板
|
||||
|
||||
```python
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
|
||||
# 创建 Blueprint 实例
|
||||
position_blue = Blueprint('position', __name__, url_prefix='/position')
|
||||
|
||||
|
||||
@position_blue.route('/page', methods=['GET'])
|
||||
@check_permission('sys:position:page')
|
||||
def page():
|
||||
from src.modules.system.position.service import get_position_page
|
||||
return get_position_page()
|
||||
|
||||
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
@check_permission('sys:position:detail')
|
||||
def detail(id):
|
||||
from src.modules.system.position.service import get_position_detail
|
||||
return R.ok(data=get_position_detail(id))
|
||||
|
||||
|
||||
@position_blue.route('/list', methods=['GET'])
|
||||
@check_permission('sys:position:list')
|
||||
def get_position_list():
|
||||
from src.modules.system.position.service import get_position_list
|
||||
return R.ok(data=get_position_list())
|
||||
|
||||
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
|
||||
|
||||
@position_blue.route('/update', methods=['PUT'])
|
||||
@check_permission('sys:position:update')
|
||||
def update():
|
||||
from src.modules.system.position.service import update_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return update_position(data)
|
||||
|
||||
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
def delete(id):
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
|
||||
|
||||
@position_blue.route('/status', methods=['PUT'])
|
||||
@check_permission('sys:position:status')
|
||||
def status():
|
||||
from src.modules.system.position.service import update_position_status
|
||||
from src.modules.system.position.schema import PositionStatusForm
|
||||
data = parse_request_body(request, PositionStatusForm)
|
||||
return update_position_status(data)
|
||||
|
||||
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 路由注册
|
||||
|
||||
在 `src/routes/v1.py` 中注册模块路由:
|
||||
|
||||
```python
|
||||
from src.modules.system.position.router import position_blue
|
||||
|
||||
BLUEPRINTS = [
|
||||
# ... 其他模块
|
||||
position_blue,
|
||||
# ... 其他模块
|
||||
]
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Router 层是 HTTP 接口定义层,只做请求分发和装饰器配置。核心规则:装饰器顺序为 `@blueprint.route()` → `@check_permission`;权限标识格式 `sys:{module}:{action}`;所有逻辑委托 Service 处理。
|
||||
@@ -0,0 +1,305 @@
|
||||
# 前端 API 规范
|
||||
|
||||
## 概述
|
||||
|
||||
前端 API 层封装所有 HTTP 请求,与后端接口一一对应。API 文件位于 `ui/src/api/` 目录,按模块分组,使用封装后的 `VAxios` 发送请求。
|
||||
|
||||
:::tip 设计原则
|
||||
- 每个后端模块对应一个 API 文件
|
||||
- 函数命名统一使用 `名词 + 动词` 格式(与后端保持一致)
|
||||
- 请求路径使用相对路径(`/position/page`),不含 `/api/v1` 前缀(由 Axios baseURL 统一拼接)
|
||||
- 统一通过 `http.request()` 发送请求
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ui/src/api/
|
||||
├── common/ # 公共 API(上传、字典按编码查询、城市、模板下载)
|
||||
│ ├── index.ts
|
||||
│ ├── menu.ts
|
||||
│ ├── user.ts
|
||||
│ └── city.ts
|
||||
├── system/ # 系统模块
|
||||
│ ├── user.ts
|
||||
│ ├── role.ts
|
||||
│ ├── menu.ts
|
||||
│ ├── dept.ts
|
||||
│ ├── position.ts
|
||||
│ ├── level.ts
|
||||
│ ├── dictionary.ts
|
||||
│ ├── loginLog.ts
|
||||
│ ├── operLog.ts
|
||||
│ └── tenant.ts
|
||||
├── data/ # 数据模块
|
||||
│ ├── param.ts
|
||||
│ ├── config.ts
|
||||
│ ├── dictionary.ts
|
||||
│ ├── notice.ts
|
||||
│ └── city.ts
|
||||
├── content/ # 内容模块
|
||||
│ ├── article.ts
|
||||
│ ├── category.ts
|
||||
│ └── link.ts
|
||||
├── monitor/ # 监控模块
|
||||
│ └── job.ts
|
||||
├── file/ # 文件模块
|
||||
│ └── fileTemplate.ts
|
||||
├── tool/ # 工具模块
|
||||
│ ├── generator.ts
|
||||
│ └── example.ts
|
||||
├── setting/ # 设置模块
|
||||
│ ├── profile.ts
|
||||
│ └── web.ts
|
||||
├── region/ # 区域模块
|
||||
│ └── region.ts
|
||||
└── dashboard/ # 仪表盘
|
||||
├── console.ts
|
||||
└── message.ts
|
||||
```
|
||||
|
||||
## 函数命名规范
|
||||
|
||||
| 操作 | 命名格式 | 示例 | HTTP 方法 |
|
||||
|------|---------|------|----------|
|
||||
| 分页查询 | `getXxxList` 或 `getXxxPage` | `getPositionList` | GET |
|
||||
| 全量列表 | `getXxxAllList` | `getPositionAllList` | GET |
|
||||
| 详情查询 | `getXxxDetail` | `getPositionDetail` | GET |
|
||||
| 添加 | `xxxAdd` | `positionAdd` | POST |
|
||||
| 编辑 | `xxxUpdate` | `positionUpdate` | PUT |
|
||||
| 删除 | `xxxDelete` | `positionDelete` | DELETE |
|
||||
| 批量删除 | `xxxBatchDelete` | `positionBatchDelete` | DELETE |
|
||||
| 导入 | `xxxImport` | `levelImport` | POST |
|
||||
| 导出 | `xxxExport` | `levelExport` | GET |
|
||||
|
||||
:::warning 命名风格
|
||||
实际代码中采用 `positionAdd` 而非 `addPosition`(名词在前、动词在后),与后端 Python 函数命名风格一致。
|
||||
:::
|
||||
|
||||
## API 文件标准模板
|
||||
|
||||
以岗位管理(`api/system/position.ts`)为标准:
|
||||
|
||||
```typescript
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
/**
|
||||
* @description: 岗位列表(分页)
|
||||
*/
|
||||
export function getPositionList(params?) {
|
||||
return http.request({
|
||||
url: '/position/page',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部岗位列表(不分页)
|
||||
* @param params 参数
|
||||
* @returns 返回结果
|
||||
*/
|
||||
export function getPositionAllList(params?) {
|
||||
return http.request({
|
||||
url: '/position/list',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 根据ID获取详情
|
||||
*/
|
||||
export function getPositionDetail(positionId) {
|
||||
return http.request({
|
||||
url: '/position/detail/' + positionId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 添加岗位
|
||||
*/
|
||||
export function positionAdd(data: any) {
|
||||
return http.request({
|
||||
url: '/position/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 更新岗位
|
||||
*/
|
||||
export function positionUpdate(data: any) {
|
||||
return http.request({
|
||||
url: '/position/update',
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 删除岗位
|
||||
*/
|
||||
export function positionDelete(positionId) {
|
||||
return http.request({
|
||||
url: '/position/delete/' + positionId,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 批量删除岗位
|
||||
*/
|
||||
export function positionBatchDelete(data: any) {
|
||||
return http.request({
|
||||
url: '/position/batchDelete',
|
||||
method: 'DELETE',
|
||||
data,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例:用户模块
|
||||
|
||||
```typescript
|
||||
// api/system/user.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export interface BasicResponseModel<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface BasicPageParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** @description: 获取用户列表 */
|
||||
export function getUserList(params) {
|
||||
return http.request({ url: '/user/page', method: 'get', params });
|
||||
}
|
||||
|
||||
/** @description: 根据ID获取详情 */
|
||||
export function getUserDetail(userId) {
|
||||
return http.request({ url: '/user/detail/' + userId, method: 'get' });
|
||||
}
|
||||
|
||||
/** @description: 添加用户 */
|
||||
export function userAdd(data: any) {
|
||||
return http.request({ url: '/user/add', method: 'POST', data });
|
||||
}
|
||||
|
||||
/** @description: 更新用户 */
|
||||
export function userUpdate(data: any) {
|
||||
return http.request({ url: '/user/update', method: 'PUT', data });
|
||||
}
|
||||
|
||||
/** @description: 删除用户 */
|
||||
export function userDelete(userId) {
|
||||
return http.request({ url: '/user/delete/' + userId, method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** @description: 批量删除用户 */
|
||||
export function userBatchDelete(data: any) {
|
||||
return http.request({ url: '/user/batchDelete', method: 'DELETE', data });
|
||||
}
|
||||
|
||||
/** @description: 重置密码 */
|
||||
export function resetPwd(data: any) {
|
||||
return http.request({ url: '/user/resetPwd', method: 'PUT', data });
|
||||
}
|
||||
|
||||
/** @description: 导出用户 */
|
||||
export function userExport(params) {
|
||||
return http.request({ url: '/user/export', method: 'GET' });
|
||||
}
|
||||
```
|
||||
|
||||
## 请求路径规则
|
||||
|
||||
URL 使用相对路径,不含 `/api/v1` 前缀(由 Axios 实例的 `baseURL` 统一拼接):
|
||||
|
||||
| 后端路由 | 前端 URL | 说明 |
|
||||
|----------|----------|------|
|
||||
| `GET /api/v1/position/page` | `/position/page` | baseURL 已含 `/api/v1` |
|
||||
| `GET /api/v1/position/detail/{id}` | `/position/detail/` + id | 路径参数拼接 |
|
||||
| `POST /api/v1/position/add` | `/position/add` | - |
|
||||
| `PUT /api/v1/position/update` | `/position/update` | - |
|
||||
| `DELETE /api/v1/position/delete/{id}` | `/position/delete/` + id | - |
|
||||
| `DELETE /api/v1/position/batchDelete` | `/position/batchDelete` | body 传 ID 数组 |
|
||||
|
||||
## params 与 data 区别
|
||||
|
||||
| 参数 | 适用方法 | 说明 |
|
||||
|------|----------|------|
|
||||
| `params` | GET | query 参数,拼接到 URL 后面 |
|
||||
| `data` | POST / PUT / DELETE | body 参数,放在请求体中 |
|
||||
|
||||
```typescript
|
||||
// GET 请求:params 拼到 URL
|
||||
http.request({ url: '/position/page', method: 'GET', params: { pageNo: 1, pageSize: 10 } });
|
||||
// 实际请求: /api/v1/position/page?pageNo=1&pageSize=10
|
||||
|
||||
// POST 请求:data 放 body
|
||||
http.request({ url: '/position/add', method: 'POST', data: { name: '测试', status: 1 } });
|
||||
// 实际请求: /api/v1/position/add body: {"name":"测试","status":1}
|
||||
```
|
||||
|
||||
## 公共 API
|
||||
|
||||
公共 API 位于 `api/common/index.ts`,包含跨模块复用的接口:
|
||||
|
||||
```typescript
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
/** 本地文件上传 */
|
||||
export function upload(data) {
|
||||
return http.request({ url: '/upload/uploadFile', method: 'post', data });
|
||||
}
|
||||
|
||||
/** 根据字典编码查询字典项 */
|
||||
export function getDictItemByCode(code) {
|
||||
return http.request({ url: '/dict/item/getDictItemList/' + code, method: 'GET' });
|
||||
}
|
||||
|
||||
/** 下载模板 */
|
||||
export function getTemplateByCode(code: any) {
|
||||
return http.request({ url: '/file/template/getTemplateByCode/' + code, method: 'GET' });
|
||||
}
|
||||
```
|
||||
|
||||
## 响应格式
|
||||
|
||||
后端统一返回 `{code, data, msg, ok}` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { "records": [...], "total": 100 },
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
前端 Axios 拦截器自动处理:
|
||||
- `ok: true` → 返回 `res.data`
|
||||
- `ok: false` → 弹出 `res.msg` 错误提示
|
||||
- `code: 401` → token 失效,跳转登录页
|
||||
|
||||
页面中无需手动判断,直接使用返回值:
|
||||
|
||||
```typescript
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getPositionList({ ...formParams, ...res });
|
||||
return result; // BasicTable 自动处理分页数据
|
||||
};
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
前端 API 规范:每个后端模块对应一个 API 文件,函数命名使用 `名词+动词` 格式(`positionAdd` / `positionDelete`)。URL 使用相对路径(不含 `/api/v1` 前缀),GET 用 `params`,POST/PUT/DELETE 用 `data`。统一通过 `http.request()` 发送请求,响应拦截器自动处理错误。
|
||||
@@ -0,0 +1,391 @@
|
||||
# 前端页面规范
|
||||
|
||||
## 概述
|
||||
|
||||
前端基于 Vue3 + ElementPlus + 自定义组件库构建。页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套,分页由 `BasicTable` 内置处理,无需单独引入分页组件。
|
||||
|
||||
:::tip 技术栈
|
||||
- Vue3(Composition API + `<script setup>`)
|
||||
- ElementPlus 2.x
|
||||
- 自定义组件(BasicTable / BasicForm / TableAction / PageWrapper)
|
||||
- Pinia 状态管理
|
||||
- Vue Router 路由管理
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ui/src/
|
||||
├── api/ # API 接口定义
|
||||
│ ├── data/ # 数据模块 API(param、dict、config、notice 等)
|
||||
│ │ ├── param.ts
|
||||
│ │ ├── dictionary.ts
|
||||
│ │ └── ...
|
||||
│ ├── system/ # 系统模块 API(user、role、dept、position、level)
|
||||
│ │ ├── user.ts
|
||||
│ │ ├── role.ts
|
||||
│ │ └── ...
|
||||
│ ├── content/ # 内容模块 API(article、category)
|
||||
│ ├── common/ # 公共 API(city、upload、dict 按编码查询)
|
||||
│ └── monitor/ # 监控模块 API(job、loginLog、operLog)
|
||||
├── components/ # 公共组件(详见组件库文档)
|
||||
├── views/ # 页面视图(按模块分目录)
|
||||
│ ├── data/
|
||||
│ │ ├── param/ # 参数管理
|
||||
│ │ │ ├── index.vue # 列表页
|
||||
│ │ │ ├── edit.vue # 编辑弹窗
|
||||
│ │ │ ├── columns.ts # 表格列定义
|
||||
│ │ │ └── querySchemas.ts # 搜索表单配置
|
||||
│ │ ├── dict/
|
||||
│ │ └── config/
|
||||
│ ├── system/
|
||||
│ │ ├── user/
|
||||
│ │ ├── role/
|
||||
│ │ ├── dept/
|
||||
│ │ ├── position/
|
||||
│ │ └── level/
|
||||
│ ├── content/
|
||||
│ │ ├── article/
|
||||
│ │ └── category/
|
||||
│ └── monitor/
|
||||
│ ├── job/
|
||||
│ ├── loginLog/
|
||||
│ └── operLog/
|
||||
├── store/ # Pinia 状态管理
|
||||
├── router/ # 路由配置
|
||||
├── utils/ # 工具函数
|
||||
├── hooks/ # 组合式函数
|
||||
├── styles/ # 全局样式
|
||||
├── plugins/ # 插件注册(ElementPlus、自定义组件、指令)
|
||||
└── main.ts # 入口文件
|
||||
```
|
||||
|
||||
## 文件组织规范
|
||||
|
||||
每个业务模块统一包含以下文件:
|
||||
|
||||
| 文件 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `index.vue` | 列表页(搜索 + 表格 + 操作) | `views/data/param/index.vue` |
|
||||
| `edit.vue` | 编辑弹窗(新增/编辑共用) | `views/data/param/edit.vue` |
|
||||
| `columns.ts` | 表格列定义 | `views/data/param/columns.ts` |
|
||||
| `querySchemas.ts` | 搜索表单配置 | `views/data/param/querySchemas.ts` |
|
||||
|
||||
## 页面标准结构
|
||||
|
||||
以参数管理(`views/data/param/index.vue`)为标准模板:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<!-- 搜索表单 -->
|
||||
<el-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card :bordered="false" class="proCard">
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:param:add']">
|
||||
<el-icon><PlusOutlined /></el-icon>
|
||||
添加参数
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleDelete()"
|
||||
:disabled="!selectionData.length"
|
||||
v-perm="['sys:param:batchDelete']"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗(懒加载) -->
|
||||
<editDialog
|
||||
v-if="editVisible"
|
||||
:paramId="paramId"
|
||||
v-model:visible="editVisible"
|
||||
@success="reloadTable('noRefresh')"
|
||||
/>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
```
|
||||
|
||||
:::warning 注意
|
||||
- 分页由 `BasicTable` 内置处理,无需引入 `<pagination>` 组件
|
||||
- `:request="loadDataTable"` 自动管理分页参数(pageNo / pageSize)和 loading 状态
|
||||
- `tableRef.value.reload({ pageNo: 1 })` 用于搜索/刷新时重置到第一页
|
||||
:::
|
||||
|
||||
## 表格列定义(columns.ts)
|
||||
|
||||
```typescript
|
||||
// views/data/param/columns.ts
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
export const columns = [
|
||||
{ type: 'selection' }, // 多选列
|
||||
{ label: 'ID', prop: 'id', width: 50 }, // 固定列
|
||||
{ label: '参数名称', prop: 'name', minWidth: 200 },
|
||||
{ label: '参数编码', prop: 'code', minWidth: 250 },
|
||||
{ label: '参数值', prop: 'value', minWidth: 200 },
|
||||
{
|
||||
label: '参数类型',
|
||||
prop: 'type',
|
||||
minWidth: 100,
|
||||
render(record) {
|
||||
return h(ElTag,
|
||||
{ type: record.row.type == 0 ? 'primary' : 'warning' },
|
||||
{ default: () => (record.row.type == 0 ? '系统' : '业务') }
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'status',
|
||||
minWidth: 100,
|
||||
render(record) {
|
||||
return h(ElTag,
|
||||
{ type: record.row.status == 1 ? 'success' : 'danger' },
|
||||
{ default: () => (record.row.status == 1 ? '正常' : '禁用') }
|
||||
);
|
||||
},
|
||||
},
|
||||
{ label: '创建时间', prop: 'createTime', width: 180 },
|
||||
];
|
||||
```
|
||||
|
||||
### render 函数自定义渲染
|
||||
|
||||
```typescript
|
||||
// 状态标签
|
||||
render(record) {
|
||||
return h(ElTag,
|
||||
{ type: record.row.status == 1 ? 'success' : 'danger' },
|
||||
{ default: () => record.row.statusText } // 使用后端 serialize_maps 返回的 xxxText
|
||||
);
|
||||
}
|
||||
|
||||
// 链接
|
||||
render(record) {
|
||||
return h('a',
|
||||
{ href: record.row.url, target: '_blank', class: 'text-blue-500' },
|
||||
{ default: () => record.row.url }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 搜索表单配置(querySchemas.ts)
|
||||
|
||||
```typescript
|
||||
// views/data/param/querySchemas.ts
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
label: '参数名称',
|
||||
componentProps: { placeholder: '请输入参数名称' },
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
component: 'Select',
|
||||
label: '参数类型',
|
||||
componentProps: {
|
||||
placeholder: '请选择参数类型',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '系统', value: '0' },
|
||||
{ label: '业务', value: '1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '正常', value: '1' },
|
||||
{ label: '禁用', value: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 操作栏(TableAction)
|
||||
|
||||
```typescript
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
label: '操作',
|
||||
prop: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '编辑',
|
||||
icon: 'Edit',
|
||||
type: 'warning',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: ['sys:param:update'],
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'Delete',
|
||||
type: 'danger',
|
||||
onClick: handleDelete.bind(null, record),
|
||||
auth: ['sys:param:delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API 接口定义
|
||||
|
||||
```typescript
|
||||
// api/data/param.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export function getParamList(params?) {
|
||||
return http.request({ url: '/param/page', method: 'GET', params });
|
||||
}
|
||||
|
||||
export function getParamDetail(id) {
|
||||
return http.request({ url: '/param/detail/' + id, method: 'get' });
|
||||
}
|
||||
|
||||
export function paramAdd(data: any) {
|
||||
return http.request({ url: '/param/add', method: 'POST', data });
|
||||
}
|
||||
|
||||
export function paramUpdate(data: any) {
|
||||
return http.request({ url: '/param/update', method: 'PUT', data });
|
||||
}
|
||||
|
||||
export function paramDelete(id) {
|
||||
return http.request({ url: '/param/delete/' + id, method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function paramBatchDelete(data: any) {
|
||||
return http.request({ url: '/param/batchDelete', method: 'DELETE', data });
|
||||
}
|
||||
```
|
||||
|
||||
## 数据加载与刷新
|
||||
|
||||
```typescript
|
||||
// 加载数据(BasicTable 的 :request 回调,自动传入分页参数)
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getParamList({ ...formParams, ...res });
|
||||
return result; // 返回 { items, total } 或 R.ok(data=列表, count=总数)
|
||||
};
|
||||
|
||||
// 刷新表格
|
||||
function reloadTable(noRefresh = '') {
|
||||
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
|
||||
// 搜索提交
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) {
|
||||
formParams[key] = values[key];
|
||||
}
|
||||
reloadTable(); // 重置到第一页
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
function handleReset() {
|
||||
for (const key in formParams) {
|
||||
formParams[key] = '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 编辑弹窗(edit.vue)
|
||||
|
||||
编辑弹窗通过 `defineAsyncComponent` 懒加载,使用 `v-model:visible` 控制显示:
|
||||
|
||||
```vue
|
||||
<!-- index.vue 中引用 -->
|
||||
<editDialog
|
||||
v-if="editVisible"
|
||||
:paramId="paramId"
|
||||
v-model:visible="editVisible"
|
||||
@success="reloadTable('noRefresh')"
|
||||
/>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 懒加载编辑组件
|
||||
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
|
||||
|
||||
// 新增
|
||||
const handleAdd = async () => {
|
||||
paramId.value = 0;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = async (record: Recordable) => {
|
||||
paramId.value = record.row.id;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
```
|
||||
|
||||
## 删除(单条 + 批量)
|
||||
|
||||
```typescript
|
||||
async function handleDelete(record: Recordable) {
|
||||
let ids = [];
|
||||
if (!record) {
|
||||
// 批量删除:取多选行的 ID
|
||||
ids = selectionData.value.map(({ id }) => id);
|
||||
}
|
||||
await confirm('确定要删除?');
|
||||
record ? await paramDelete(record.row.id) : await paramBatchDelete(ids);
|
||||
message('删除成功');
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
// 多选变化
|
||||
function onSelectionChange(value) {
|
||||
selectionData.value = value;
|
||||
}
|
||||
```
|
||||
|
||||
## 方法命名规范
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `handle` | 事件处理 | `handleAdd`、`handleEdit`、`handleDelete`、`handleSubmit`、`handleReset` |
|
||||
| `load` | 数据加载 | `loadDataTable` |
|
||||
| `reload` | 刷新数据 | `reloadTable` |
|
||||
| `on` | 组件事件回调 | `onSelectionChange`、`onSuccess`、`onError` |
|
||||
|
||||
## 总结
|
||||
|
||||
前端页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套。分页由 `BasicTable` 内置处理,无需单独引入分页组件。每个模块包含 `index.vue`(列表页)、`edit.vue`(编辑弹窗)、`columns.ts`(列定义)、`querySchemas.ts`(搜索配置)四个文件。API 接口按模块分文件定义在 `api/` 目录下。
|
||||
@@ -0,0 +1,192 @@
|
||||
# Git提交规范
|
||||
|
||||
## 概述
|
||||
|
||||
使用 Conventional Commits 规范管理提交信息,确保提交历史清晰可读,便于自动生成变更日志和语义化版本管理。
|
||||
|
||||
:::tip Conventional Commits 格式
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
:::
|
||||
|
||||
## 提交类型(type)
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新功能 | `feat(user): 添加用户导入功能` |
|
||||
| `fix` | 修复 bug | `fix(auth): 修复 token 过期后未刷新的问题` |
|
||||
| `docs` | 文档变更 | `docs: 更新部署文档` |
|
||||
| `style` | 代码格式(不影响逻辑) | `style: 格式化代码缩进` |
|
||||
| `refactor` | 重构(非新功能非修复) | `refactor(repo): 统一查询方法封装` |
|
||||
| `perf` | 性能优化 | `perf(query): 优化分页查询性能` |
|
||||
| `test` | 测试相关 | `test(unit): 添加岗位模块单元测试` |
|
||||
| `chore` | 构建/工具变更 | `chore: 更新依赖版本` |
|
||||
| `ci` | CI/CD 变更 | `ci: 添加 GitHub Actions 配置` |
|
||||
| `revert` | 回滚提交 | `revert: 回滚 feat(user) 提交` |
|
||||
|
||||
## 范围(scope)
|
||||
|
||||
可选,用于说明影响的模块或组件:
|
||||
|
||||
```
|
||||
feat(user): 用户模块
|
||||
fix(auth): 认证模块
|
||||
refactor(repo): Repository 层
|
||||
feat(api): API 层
|
||||
docs(deploy): 部署文档
|
||||
```
|
||||
|
||||
## 提交信息示例
|
||||
|
||||
### 简单提交
|
||||
|
||||
```
|
||||
feat(position): 添加岗位状态切换接口
|
||||
fix(captcha): 修复验证码过期时间计算错误
|
||||
docs: 更新 README 安装说明
|
||||
```
|
||||
|
||||
### 带正文的提交
|
||||
|
||||
```
|
||||
fix(auth): 修复 token 刷新后旧 token 仍有效的问题
|
||||
|
||||
旧 token 在刷新后未加入黑名单,导致短时间内可同时使用新旧两个 token。
|
||||
修复方案:刷新时将旧 token 加入 Redis 黑名单。
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### 破坏性变更
|
||||
|
||||
```
|
||||
feat(api)!: 统一响应格式,移除 data.code 字段
|
||||
|
||||
BREAKING CHANGE: 响应格式从 {data: {code, data}} 变为 {code, data, msg, ok}
|
||||
前端需同步更新响应处理逻辑。
|
||||
```
|
||||
|
||||
:::warning 破坏性变更
|
||||
破坏性变更使用 `!` 后缀标记,并在正文或 footer 中说明 `BREAKING CHANGE`。此类变更需在版本号中体现(主版本号 +1)。
|
||||
:::
|
||||
|
||||
## 分支策略
|
||||
|
||||
| 分支 | 用途 | 命名格式 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `main` | 主分支(生产) | `main` | 始终保持可发布状态 |
|
||||
| `master` | 开发主分支 | `master` | 日常开发合并目标 |
|
||||
| `feature/*` | 功能分支 | `feature/功能名` | 从 master 拉取 |
|
||||
| `fix/*` | 修复分支 | `fix/问题描述` | 从 master 拉取 |
|
||||
| `hotfix/*` | 紧急修复 | `hotfix/问题描述` | 从 main 拉取 |
|
||||
|
||||
### 分支工作流
|
||||
|
||||
```
|
||||
1. 从 master 创建功能分支
|
||||
git checkout -b feature/position-import master
|
||||
|
||||
2. 开发完成后提交
|
||||
git commit -m "feat(position): 添加岗位 Excel 导入功能"
|
||||
|
||||
3. 合并到 master
|
||||
git checkout master
|
||||
git merge feature/position-import
|
||||
|
||||
4. 推送到远程
|
||||
git push origin master
|
||||
|
||||
5. 生产发布时合并到 main
|
||||
git checkout main
|
||||
git merge master
|
||||
git tag v1.2.0
|
||||
```
|
||||
|
||||
## 版本标签
|
||||
|
||||
使用语义化版本号(Semantic Versioning):
|
||||
|
||||
```
|
||||
v主版本号.次版本号.修订号
|
||||
v1.2.3
|
||||
|
||||
主版本号:破坏性变更
|
||||
次版本号:新功能(向后兼容)
|
||||
修订号:Bug 修复
|
||||
```
|
||||
|
||||
```bash
|
||||
# 创建标签
|
||||
git tag -a v1.2.0 -m "feat: 添加岗位导入功能"
|
||||
|
||||
# 推送标签
|
||||
git push origin v1.2.0
|
||||
|
||||
# 查看标签
|
||||
git tag -l
|
||||
```
|
||||
|
||||
## .gitignore 配置
|
||||
|
||||
```gitignore
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# 上传文件
|
||||
uploads/
|
||||
|
||||
# 日志
|
||||
logs/
|
||||
|
||||
# 数据库
|
||||
*.sqlite3
|
||||
```
|
||||
|
||||
:::tip .env 安全
|
||||
`.env` 文件包含敏感信息(数据库密码、JWT 密钥等),必须在 `.gitignore` 中排除。各环境的 `.env` 独立管理,不提交到仓库。
|
||||
:::
|
||||
|
||||
## 常用 Git 命令
|
||||
|
||||
```bash
|
||||
# 查看提交历史
|
||||
git log --oneline -20
|
||||
|
||||
# 查看某次提交的变更
|
||||
git show <commit-hash>
|
||||
|
||||
# 撤销工作区修改
|
||||
git checkout -- <file>
|
||||
|
||||
# 撤销暂存
|
||||
git reset HEAD <file>
|
||||
|
||||
# 暂存当前工作
|
||||
git stash
|
||||
git stash pop
|
||||
|
||||
# 查看分支图
|
||||
git log --graph --oneline --all
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Git 提交规范采用 Conventional Commits 格式:`type(scope): subject`。常用类型包括 feat/fix/docs/refactor/test/chore。分支策略:main(生产)、master(开发)、feature/fix/hotfix(临时分支)。版本使用语义化标签(v1.2.0)。`.env` 等敏感文件必须排除在仓库之外。
|
||||
@@ -0,0 +1,141 @@
|
||||
# 分页规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目统一使用 `pageNo` / `pageSize` 作为分页参数,通过 `BaseService.get_page()` → `Repository.page()` → `BaseRepository.find_page()` 链路完成分页查询,返回标准分页结构。
|
||||
|
||||
:::tip 分页约定
|
||||
- 前端传递 `pageNo`(页码,从 1 开始)和 `pageSize`(每页条数)
|
||||
- 后端返回分页数据字典,包含 `items` / `total` / `page` / `pageSize` / `pages` 等字段
|
||||
- 默认每页 10 条(`PAGE_SIZE = 10`)
|
||||
:::
|
||||
|
||||
## 分页流程
|
||||
|
||||
```
|
||||
前端请求 GET /api/v1/position/page?pageNo=1&pageSize=10&name=工程师
|
||||
↓
|
||||
Router 层:R.ok(data=position.get_position_page(request.args))
|
||||
↓
|
||||
Service 层:BaseService.get_page(params)
|
||||
↓
|
||||
Repository 层:PositionRepository.page(params)
|
||||
↓
|
||||
BaseRepository.find_page(page, page_size, filters, like_filters, order_by)
|
||||
↓
|
||||
返回 (items, total, pages)
|
||||
↓
|
||||
build_page_data 构建分页数据字典
|
||||
```
|
||||
|
||||
## Repository 层实现
|
||||
|
||||
```python
|
||||
# src/modules/system/position/repository.py
|
||||
from src.common.base_repository import BaseRepository
|
||||
from src.common.constants.common import PAGE_SIZE
|
||||
from src.modules.system.position.models import Position
|
||||
|
||||
|
||||
class PositionRepository(BaseRepository):
|
||||
model = Position
|
||||
|
||||
@classmethod
|
||||
def page(cls, params: dict):
|
||||
"""
|
||||
岗位分页查询
|
||||
|
||||
Args:
|
||||
params: 查询参数字典
|
||||
- pageNo: 当前页码
|
||||
- pageSize: 每页记录数
|
||||
- name: 岗位名称(模糊匹配)
|
||||
- status: 状态(精确匹配)
|
||||
|
||||
Returns:
|
||||
tuple: (items, total, pages)
|
||||
"""
|
||||
return cls.find_page(
|
||||
page=int(params.get('pageNo', 1)),
|
||||
page_size=int(params.get('pageSize', PAGE_SIZE)),
|
||||
filters={'status': params.get('status')},
|
||||
like_filters={'name': params.get('name')},
|
||||
order_by='sort',
|
||||
)
|
||||
```
|
||||
|
||||
## Service 层调用
|
||||
|
||||
```python
|
||||
# src/common/base_service.py
|
||||
from src.common.utils.model_utils import build_page_data
|
||||
|
||||
class BaseService:
|
||||
@classmethod
|
||||
def get_page(cls, params: dict) -> dict:
|
||||
"""分页查询"""
|
||||
page = int(params.get("pageNo", 1))
|
||||
page_size = int(params.get("pageSize", PAGE_SIZE))
|
||||
items, total, pages = cls.repository.page(params)
|
||||
return build_page_data(
|
||||
items, total, page, page_size, pages,
|
||||
enrich=cls._enrich_page_records,
|
||||
)
|
||||
```
|
||||
|
||||
## Router 层调用
|
||||
|
||||
```python
|
||||
# src/modules/system/position/router.py
|
||||
@position_blue.route('/page', methods=["GET"])
|
||||
@check_permission("sys:position:page")
|
||||
def page():
|
||||
"""查询岗位分页数据"""
|
||||
return R.ok(data=position.get_position_page(request.args))
|
||||
```
|
||||
|
||||
## 分页参数说明
|
||||
|
||||
| 参数 | 类型 | 说明 | 默认值 |
|
||||
|:---|:---|:---|:---|
|
||||
| `pageNo` | int | 当前页码(从 1 开始) | 1 |
|
||||
| `pageSize` | int | 每页记录数 | 10 |
|
||||
|
||||
## 筛选参数说明
|
||||
|
||||
| 筛选类型 | Repository 参数 | 说明 | SQL 效果 |
|
||||
|:---|:---|:---|:---|
|
||||
| 等值匹配 | `filters={'status': 1}` | 精确匹配 | `WHERE status = 1` |
|
||||
| 模糊匹配 | `like_filters={'name': '工程师'}` | 模糊匹配 | `WHERE name LIKE '%工程师%'` |
|
||||
| 范围匹配 | `range_filters={'create_time': (start, end)}` | 范围匹配 | `WHERE create_time BETWEEN start AND end` |
|
||||
|
||||
:::tip 空值跳过
|
||||
所有筛选条件自动跳过 `None` 和空字符串 `''`,前端不传某参数时不会影响查询结果。
|
||||
:::
|
||||
|
||||
## 前端调用示例
|
||||
|
||||
```typescript
|
||||
// ui/src/api/system/position/index.ts
|
||||
export const getPositionPageApi = (params: any) => {
|
||||
return http.request('get', '/position/page', { params });
|
||||
};
|
||||
|
||||
// 使用
|
||||
const { data } = await getPositionPageApi({ pageNo: 1, pageSize: 10, name: '工程师' });
|
||||
// data.items = [...]
|
||||
// data.total = 100
|
||||
// data.pages = 10
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **分页参数统一使用 `pageNo` / `pageSize`**:前端和后端保持一致
|
||||
2. **Repository 的 `page(params)` 方法必须实现**:Service 的 `get_page` 依赖此方法
|
||||
3. **使用 `find_page` 构建查询**:传入 `filters` / `like_filters` / `range_filters` / `order_by`
|
||||
4. **空值自动跳过**:前端不传的参数不会影响查询条件
|
||||
5. **软删除自动过滤**:`find_page` 自动添加 `is_delete = 0` 条件
|
||||
|
||||
## 总结
|
||||
|
||||
分页查询通过 `Service.get_page(params)` → `Repository.page(params)` → `BaseRepository.find_page(...)` 链路完成。筛选条件通过 `filters`(等值)、`like_filters`(模糊)、`range_filters`(范围)三类参数传入,空值自动跳过,软删除自动过滤。
|
||||
@@ -0,0 +1,190 @@
|
||||
# WTForms Schema规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目使用 WTForms 进行请求数据校验。所有请求体参数通过 WTForms 表单类定义,框架在调用 Router 前自动完成解析和验证。
|
||||
|
||||
:::tip 核心原则
|
||||
- 请求体参数必须声明为 WTForms 表单类(Form 类)
|
||||
- 字段约束使用 `validators` 定义
|
||||
- 自定义校验使用 `validate_` 方法
|
||||
- 唯一性校验在 Schema 中查询数据库
|
||||
:::
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 命名格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 创建/编辑表单 | `{模块}Form` | `PositionForm` |
|
||||
| 状态更新表单 | `{模块}StatusForm` | `PositionStatusForm` |
|
||||
| 查询参数表单 | `{模块}QueryForm` | `PositionQueryForm` |
|
||||
|
||||
:::tip 继承 BaseForm
|
||||
创建/编辑表单继承 `src.common.forms.base_form.BaseForm`,自动获得 `id` 字段(编辑时必填)。
|
||||
:::
|
||||
|
||||
## PositionForm 示例
|
||||
|
||||
以岗位模块为例,`src/modules/system/position/schema.py` 完整内容:
|
||||
|
||||
```python
|
||||
from wtforms import StringField, IntegerField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange
|
||||
|
||||
from src.common.forms.base_form import BaseForm
|
||||
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
|
||||
|
||||
class PositionStatusForm(BaseForm):
|
||||
"""岗位状态更新表单"""
|
||||
id = IntegerField('岗位ID', validators=[
|
||||
DataRequired(message='岗位ID不能为空'),
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
```
|
||||
|
||||
:::tip BaseForm 基类
|
||||
`BaseForm`(定义在 `src/common/forms/base_form.py`)提供 `id` 字段和数据清洗功能,自动将前端传入的空字符串 `''`/`'null'`/`'undefined'` 转为 `None`,避免 WTForms 解析报错。
|
||||
:::
|
||||
|
||||
## Validator 约束
|
||||
|
||||
| Validator | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `DataRequired` | any | 必填字段 | `DataRequired(message='不能为空')` |
|
||||
| `Length` | str | 字符串长度 | `Length(min=1, max=150)` |
|
||||
| `NumberRange` | int/float | 数值范围 | `NumberRange(min=0, max=99999)` |
|
||||
| `Optional` | any | 可选字段 | `Optional()` |
|
||||
| `Email` | str | 邮箱格式 | `Email(message='邮箱格式不正确')` |
|
||||
| `URL` | str | URL 格式 | `URL(message='URL格式不正确')` |
|
||||
| `Regexp` | str | 正则表达式 | `Regexp(r'^[a-zA-Z]+$')` |
|
||||
|
||||
:::tip Validator 说明
|
||||
- `DataRequired`:必填字段,不允许空值
|
||||
- `Length`:字符串长度限制
|
||||
- `NumberRange`:数值范围限制
|
||||
- `Optional`:可选字段
|
||||
:::
|
||||
|
||||
## 自定义校验器
|
||||
|
||||
### 唯一性校验
|
||||
|
||||
```python
|
||||
from wtforms import ValidationError
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
|
||||
def validate_name(self, field):
|
||||
"""岗位名称唯一性校验"""
|
||||
exclude_id = self.id.data if self.id.data else None
|
||||
if PositionRepository.exists_by([Position.name == field.data], exclude_id):
|
||||
raise ValidationError('岗位名称不能重复')
|
||||
```
|
||||
|
||||
### 格式校验
|
||||
|
||||
```python
|
||||
from wtforms import ValidationError
|
||||
import re
|
||||
|
||||
|
||||
class UserForm(BaseForm):
|
||||
"""用户创建/编辑表单"""
|
||||
username = StringField('用户名', validators=[
|
||||
DataRequired(message='用户名不能为空'),
|
||||
Length(min=3, max=50, message='用户名长度必须在3-50个字符之间')
|
||||
])
|
||||
email = StringField('邮箱', validators=[
|
||||
DataRequired(message='邮箱不能为空'),
|
||||
Length(max=100, message='邮箱长度不能超过100个字符')
|
||||
])
|
||||
phone = StringField('手机号', validators=[
|
||||
Optional(),
|
||||
Length(max=20, message='手机号长度不能超过20个字符')
|
||||
])
|
||||
|
||||
def validate_username(self, field):
|
||||
"""用户名只允许字母、数字、下划线"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', field.data):
|
||||
raise ValidationError('用户名只允许字母、数字、下划线')
|
||||
|
||||
def validate_email(self, field):
|
||||
"""邮箱格式校验"""
|
||||
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', field.data):
|
||||
raise ValidationError('邮箱格式不正确')
|
||||
|
||||
def validate_phone(self, field):
|
||||
"""手机号格式校验(可选字段,有值时校验)"""
|
||||
if field.data and field.data != '':
|
||||
if not re.match(r'^1[3-9]\d{9}$', field.data):
|
||||
raise ValidationError('手机号格式不正确')
|
||||
```
|
||||
|
||||
:::tip validator 规则
|
||||
- 方法名必须为 `validate_{field_name}`
|
||||
- 校验失败抛出 `ValidationError(错误消息)`
|
||||
- 可选字段需判断 `None` 值
|
||||
:::
|
||||
|
||||
## 在 Router 中使用
|
||||
|
||||
```python
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
"""
|
||||
新增岗位
|
||||
"""
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
```
|
||||
|
||||
:::tip 数据访问
|
||||
Service 层通过 `data['field']` 直接访问字段。WTForms 在进入 Router 之前已完成校验,Service 无需重复验证。
|
||||
:::
|
||||
|
||||
## 错误响应
|
||||
|
||||
WTForms 校验失败时,由框架自动转换:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "岗位名称不能为空",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
WTForms Schema 规范要求:表单类命名 `{Module}Form`,继承 `BaseForm`;字段约束用 `validators` 定义长度/范围;自定义校验用 `validate_{field_name}` 方法抛 `ValidationError`;唯一性校验通过 `Repository.exists_by(filters, exclude_id)` 实现。框架自动解析和验证,Service 通过 `form.data['field']` 访问已校验的数据。
|
||||
@@ -0,0 +1,112 @@
|
||||
# Repository 层规范
|
||||
|
||||
## 概述
|
||||
|
||||
Repository 层是数据访问层,封装所有数据库操作。各模块 Repository 继承 `BaseRepository`,获得通用 CRUD 能力,子类按需添加自定义查询。
|
||||
|
||||
:::tip 设计原则
|
||||
- Repository 只做数据访问原语,不处理 R 封装/序列化/业务逻辑
|
||||
- 所有方法均为 `@classmethod`,无需创建实例
|
||||
- 软删除由基类自动处理,子类无需关心
|
||||
- 查询方法自动跳过空值条件
|
||||
:::
|
||||
|
||||
## 标准模块结构
|
||||
|
||||
```python
|
||||
# src/modules/system/position/repository.py
|
||||
from src.common.base_repository import BaseRepository
|
||||
from src.common.constants.common import PAGE_SIZE
|
||||
from src.modules.system.position.models import Position
|
||||
|
||||
|
||||
class PositionRepository(BaseRepository):
|
||||
"""岗位数据仓储"""
|
||||
model = Position
|
||||
|
||||
@classmethod
|
||||
def page(cls, params: dict):
|
||||
"""岗位分页查询"""
|
||||
return cls.find_page(
|
||||
page=int(params.get('pageNo', 1)),
|
||||
page_size=int(params.get('pageSize', PAGE_SIZE)),
|
||||
filters={'status': params.get('status')},
|
||||
like_filters={'name': params.get('name')},
|
||||
order_by='sort',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_enabled(cls, params=None):
|
||||
"""查询启用的岗位列表"""
|
||||
params = params or {}
|
||||
return cls.find_list(
|
||||
filters={'status': 1},
|
||||
like_filters={'name': params.get('name')},
|
||||
order_by='sort',
|
||||
)
|
||||
```
|
||||
|
||||
## 必须实现的方法
|
||||
|
||||
| 方法 | 说明 | 何时需要 |
|
||||
|:---|:---|:---|
|
||||
| `page(params)` | 分页查询 | 所有模块(Service 的 `get_page` 依赖此方法) |
|
||||
| `list_enabled(params)` | 无分页列表查询 | 需要下拉选择的模块(Service 的 `get_list` 依赖此方法) |
|
||||
|
||||
## BaseRepository 通用方法
|
||||
|
||||
| 方法 | 说明 | 自动过滤软删除 |
|
||||
|:---|:---|:---|
|
||||
| `get_by_id(obj_id)` | 按主键查询单条 | 是 |
|
||||
| `get_by_ids(ids)` | 按主键列表批量查询 | 是 |
|
||||
| `first_by(filters)` | 按等值条件查询单条 | 是 |
|
||||
| `exists_by(filters, exclude_id)` | 检查是否存在(唯一性校验) | 是 |
|
||||
| `count_by(filters)` | 按等值条件统计记录数 | 是 |
|
||||
| `exists(obj_id)` | 检查记录是否存在 | 是 |
|
||||
| `count_children(parent_id)` | 统计子记录数 | 是 |
|
||||
| `find_page(...)` | 统一分页查询 | 是 |
|
||||
| `find_list(...)` | 统一不分页查询 | 是 |
|
||||
| `create(**kwargs)` | 创建并保存新实例 | - |
|
||||
| `update_by_id(obj_id, data)` | 按主键更新字段 | - |
|
||||
| `update_status(obj_id, status)` | 单独更新状态字段 | - |
|
||||
| `delete_by_id(obj_id)` | 软删除单条记录 | - |
|
||||
| `batch_delete(ids)` | 批量软删除 | - |
|
||||
| `batch_restore(ids)` | 批量恢复已删除记录 | - |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```python
|
||||
# 唯一性校验
|
||||
if PositionRepository.exists_by({'name': '高级工程师'}, exclude_id=5):
|
||||
raise ValidationError('岗位名称已存在')
|
||||
|
||||
# 按 ID 查询
|
||||
position = PositionRepository.get_by_id(1)
|
||||
|
||||
# 条件查询
|
||||
user = UserRepository.first_by({'username': 'admin'})
|
||||
|
||||
# 统计引用
|
||||
count = UserRepository.count_by({'position_id': 1})
|
||||
|
||||
# 分页查询
|
||||
items, total, pages = PositionRepository.find_page(
|
||||
page=1, page_size=10,
|
||||
filters={'status': 1},
|
||||
like_filters={'name': '工程师'},
|
||||
order_by='sort',
|
||||
)
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **所有方法均为 classmethod**:无需创建实例,直接通过类调用
|
||||
2. **必须实现 `page(params)`**:Service 的 `get_page` 依赖此方法
|
||||
3. **按需实现 `list_enabled(params)`**:需要下拉选择的模块实现此方法
|
||||
4. **`find_page` / `find_list` 自动过滤软删除**:且跳过空值查询条件
|
||||
5. **`exists_by(filters, exclude_id)` 用于唯一性校验**:支持排除自身 ID
|
||||
6. **Repository 层不做响应封装**:返回 SQLAlchemy 查询结果
|
||||
|
||||
## 总结
|
||||
|
||||
Repository 层继承 `BaseRepository` 获得通用 CRUD 能力,所有方法均为 `classmethod`。子类只需声明 `model` 并实现 `page()` / `list_enabled()` 方法。`find_page` / `find_list` 自动过滤软删除且跳过空值条件,`exists_by` 用于唯一性校验。
|
||||
@@ -0,0 +1,198 @@
|
||||
# Router层规范
|
||||
|
||||
## 概述
|
||||
|
||||
Router 层是 HTTP 接口层,负责定义路由、权限装饰器和请求参数绑定。每个业务模块对应一个 router 文件,所有业务逻辑委托给 Service 层处理。
|
||||
|
||||
:::tip 设计原则
|
||||
- Router 层不含业务逻辑,仅做路由定义、权限校验和参数绑定
|
||||
- 装饰器顺序固定为:路由 → 权限
|
||||
- Service 函数使用延迟导入,避免循环引用
|
||||
:::
|
||||
|
||||
## 完整示例
|
||||
|
||||
以岗位模块为例,`src/modules/system/position/router.py` 完整内容:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
|
||||
# ============================================================
|
||||
# 创建 Blueprint 实例
|
||||
# ============================================================
|
||||
position_blue = Blueprint('position', __name__, url_prefix='/position')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询分页数据
|
||||
# ============================================================
|
||||
@position_blue.route('/page', methods=['GET'])
|
||||
@check_permission('sys:position:page')
|
||||
def page():
|
||||
"""获取岗位分页列表"""
|
||||
from src.modules.system.position.service import get_position_page
|
||||
return get_position_page()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询岗位详情
|
||||
# ============================================================
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
@check_permission('sys:position:detail')
|
||||
def detail(id):
|
||||
"""根据岗位ID查询详细信息"""
|
||||
from src.modules.system.position.service import get_position_detail
|
||||
return R.ok(data=get_position_detail(id))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 获取岗位列表
|
||||
# ============================================================
|
||||
@position_blue.route('/list', methods=['GET'])
|
||||
@check_permission('sys:position:list')
|
||||
def get_position_list():
|
||||
"""获取所有岗位列表(无分页,用于下拉选择)"""
|
||||
from src.modules.system.position.service import get_position_list
|
||||
return R.ok(data=get_position_list())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 添加岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
"""新增岗位"""
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 更新岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/update', methods=['PUT'])
|
||||
@check_permission('sys:position:update')
|
||||
def update():
|
||||
"""更新岗位信息"""
|
||||
from src.modules.system.position.service import update_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return update_position(data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 删除岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
def delete(id):
|
||||
"""根据ID删除单个岗位"""
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置岗位状态
|
||||
# ============================================================
|
||||
@position_blue.route('/status', methods=['PUT'])
|
||||
@check_permission('sys:position:status')
|
||||
def status():
|
||||
"""修改岗位状态(启用/禁用)"""
|
||||
from src.modules.system.position.service import update_position_status
|
||||
from src.modules.system.position.schema import PositionStatusForm
|
||||
data = parse_request_body(request, PositionStatusForm)
|
||||
return update_position_status(data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 批量删除岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
"""批量删除岗位"""
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 装饰器说明
|
||||
|
||||
| 装饰器 | 作用 | 位置 |
|
||||
|:----|:----|:----|
|
||||
| `@position_blue.route()` | 定义 HTTP 方法和路径 | 最外层 |
|
||||
| `@check_permission('sys:position:page')` | RBAC 权限校验 | 路由装饰器之后 |
|
||||
|
||||
**装饰器顺序必须严格遵守**:路由 → 权限。顺序错误会导致功能异常。
|
||||
|
||||
## 权限字符串命名规范
|
||||
|
||||
```
|
||||
sys:{module}:{action}
|
||||
```
|
||||
|
||||
| 动作 | HTTP 方法 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `page` | GET | 分页查询 |
|
||||
| `detail` | GET | 详情查询 |
|
||||
| `list` | GET | 列表查询 |
|
||||
| `add` | POST | 新增 |
|
||||
| `update` | PUT | 更新 |
|
||||
| `delete` | DELETE | 单条删除 |
|
||||
| `batchDelete` | DELETE | 批量删除 |
|
||||
| `status` | PUT | 状态变更 |
|
||||
|
||||
## 请求参数解析
|
||||
|
||||
```python
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
|
||||
# 解析请求体
|
||||
data = parse_request_body(request, PositionForm)
|
||||
|
||||
# 解析ID列表
|
||||
id_list = parse_id_list(request)
|
||||
```
|
||||
|
||||
| 函数 | 作用 |
|
||||
|:----|:----|
|
||||
| `parse_request_body(request, FormClass)` | 从请求体解析 JSON 并校验 |
|
||||
| `parse_id_list(request)` | 从请求体解析 ID 数组 |
|
||||
|
||||
## 响应封装
|
||||
|
||||
- **Service 返回 R 对象的**(如 `add_position`、`update_position`):直接 `return`
|
||||
- **Service 返回原始数据的**(如 `get_position_detail`):用 `R.ok(data=...)` 包装
|
||||
|
||||
## 模块注册
|
||||
|
||||
在 `src/routes/v1.py` 中注册模块:
|
||||
|
||||
```python
|
||||
from src.modules.system.position.router import position_blue
|
||||
|
||||
BLUEPRINTS = [
|
||||
# ... 其他模块
|
||||
position_blue,
|
||||
# ... 其他模块
|
||||
]
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **Router 层不含业务逻辑**:仅做路由定义、权限校验和参数绑定
|
||||
2. **装饰器顺序**:路由 → 权限
|
||||
3. **使用 `parse_request_body` 解析请求体**:自动执行 WTForms 校验
|
||||
4. **使用 `parse_id_list` 解析批量操作的 ID 数组**
|
||||
5. **Service 函数使用延迟导入**:避免循环引用
|
||||
6. **权限字符串与代码完全一致**:确保权限校验正确
|
||||
|
||||
## 总结
|
||||
|
||||
Router 层是 HTTP 请求的入口,负责路由定义、权限校验和参数绑定,不含业务逻辑。装饰器顺序固定为路由 → 权限,使用 `parse_request_body` 解析请求体,使用 `parse_id_list` 解析批量操作的 ID 数组。Service 函数使用延迟导入,避免循环引用。
|
||||
@@ -0,0 +1,182 @@
|
||||
# Service 层规范
|
||||
|
||||
## 概述
|
||||
|
||||
Service 层是业务逻辑层,各模块 Service 继承 `BaseService` 获得通用 CRUD 能力。子类只需声明差异点(`repository` / `form` / `model_name`),即可获得完整的增删改查接口。
|
||||
|
||||
:::tip 设计模式
|
||||
BaseService 采用模板方法模式:基类定义流程骨架,子类通过声明差异点和覆盖钩子定制行为。所有方法均为 `@classmethod`。
|
||||
:::
|
||||
|
||||
## 标准模块结构
|
||||
|
||||
```python
|
||||
from src.common.base_service import BaseService
|
||||
from src.core.exceptions import BizError
|
||||
from src.modules.system.position.schema import PositionForm, PositionStatusForm
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
from src.modules.system.user.repository import UserRepository
|
||||
|
||||
|
||||
class PositionService(BaseService):
|
||||
"""
|
||||
岗位服务类
|
||||
|
||||
继承 BaseService 获得标准 CRUD 能力,并扩展删除前用户引用校验。
|
||||
"""
|
||||
repository = PositionRepository
|
||||
form = PositionForm
|
||||
status_form = PositionStatusForm
|
||||
model_name = "岗位"
|
||||
|
||||
@classmethod
|
||||
def before_delete(cls, ids):
|
||||
"""删除前校验:存在用户引用该岗位时禁止删除"""
|
||||
for pid in ids:
|
||||
if UserRepository.count_by({'position_id': pid}) > 0:
|
||||
raise BizError(f"岗位ID {pid} 已被用户引用,无法删除")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模块级代理函数:保持 router 调用兼容
|
||||
# ============================================================
|
||||
|
||||
get_position_page = PositionService.get_page
|
||||
get_position_detail = PositionService.get_detail
|
||||
get_info = PositionService.get_info
|
||||
add_position = PositionService.add
|
||||
update_position = PositionService.update
|
||||
delete_positions = PositionService.remove
|
||||
update_position_status = PositionService.update_status
|
||||
get_position_list = PositionService.get_list
|
||||
```
|
||||
|
||||
## 必须声明的属性
|
||||
|
||||
| 属性 | 类型 | 说明 | 示例 |
|
||||
|:---|:---|:---|:---|
|
||||
| `repository` | Repository 类 | 数据访问层类 | `PositionRepository` |
|
||||
| `form` | Form 类 | 新增/更新表单校验类 | `PositionForm` |
|
||||
| `status_form` | Form 类 | 状态更新表单校验类(可选) | `PositionStatusForm` |
|
||||
| `model_name` | str | 业务对象名称,用于错误提示 | `"岗位"` |
|
||||
| `serialize_maps` | dict | 枚举显示名映射(可选) | `{'type': 'operation_type'}` |
|
||||
|
||||
## 基类提供的通用方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|:---|:---|
|
||||
| `get_page(params)` | 分页查询(委托 `repository.page`) |
|
||||
| `get_detail(id)` | 查询详情(返回字典) |
|
||||
| `get_info(id)` | 查询模型对象(内部使用) |
|
||||
| `add(data)` | 新增记录(含表单校验 + 钩子) |
|
||||
| `update(data)` | 更新记录(含表单校验 + 存在性校验 + 钩子) |
|
||||
| `remove(ids)` | 批量软删除(逗号分隔 ID 字符串) |
|
||||
| `update_status(data)` | 更新状态(含状态表单校验) |
|
||||
| `get_list(params)` | 无分页列表查询(委托 `repository.list_enabled`) |
|
||||
|
||||
## 可覆写钩子方法
|
||||
|
||||
| 钩子 | 触发时机 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `before_add(form)` | 新增操作前 | 可返回额外写入字段字典 |
|
||||
| `after_add(obj)` | 新增操作后 | 用于缓存清理等 |
|
||||
| `before_update(form, obj)` | 更新操作前 | 可返回额外更新字段字典 |
|
||||
| `after_update(obj)` | 更新操作后 | 用于缓存清理等 |
|
||||
| `before_delete(ids)` | 删除操作前 | 抛出 BizError 中止删除 |
|
||||
| `after_delete(count)` | 删除操作后 | 记录审计日志等 |
|
||||
| `_enrich_page_records(records)` | 分页富化时 | 追加关联数据(基类自动应用 serialize_maps) |
|
||||
| `_detail_dict(obj)` | 详情序列化时 | 自定义返回字段(基类自动应用 serialize_maps) |
|
||||
|
||||
## 模块级代理函数
|
||||
|
||||
```python
|
||||
# 将 Service 类方法代理为模块级函数,方便路由层直接调用
|
||||
get_position_page = PositionService.get_page
|
||||
get_position_detail = PositionService.get_detail
|
||||
get_info = PositionService.get_info
|
||||
add_position = PositionService.add
|
||||
update_position = PositionService.update
|
||||
delete_positions = PositionService.remove
|
||||
update_position_status = PositionService.update_status
|
||||
get_position_list = PositionService.get_list
|
||||
```
|
||||
|
||||
:::tip 为什么需要代理函数?
|
||||
Service 的所有方法都是 `@classmethod`,但 Router 层习惯通过模块级函数调用。代理函数将类方法暴露为模块级函数,保持 Router 层调用简洁。
|
||||
:::
|
||||
|
||||
## 复杂模块示例(User)
|
||||
|
||||
复杂模块可覆写 `add` / `update` 等方法,实现头像保存、密码加密、角色关联等定制逻辑:
|
||||
|
||||
```python
|
||||
# src/modules/system/user/service.py
|
||||
class UserService(BaseService):
|
||||
repository = UserRepository
|
||||
form = UserForm
|
||||
status_form = UserStatusForm
|
||||
model_name = "用户"
|
||||
|
||||
@classmethod
|
||||
def add(cls, data: dict) -> None:
|
||||
"""新增用户(覆写基类方法)"""
|
||||
form = cls._validate_form(data)
|
||||
|
||||
# 头像保存
|
||||
avatar = form.data.get("avatar")
|
||||
if avatar:
|
||||
form.avatar.data = save_file(avatar, "user")
|
||||
|
||||
# 密码加密
|
||||
pwd = form.data.get("password") or DEFAULT_PASSWORD
|
||||
salt = generate_salt()
|
||||
|
||||
# 行政区划校验
|
||||
city = form.data.get("city", [])
|
||||
if len(city) != 4:
|
||||
raise BizError("请选择完整的所在城市(省/市/区/街道)")
|
||||
roles = form.data.get("roles")
|
||||
|
||||
# 清理表单数据
|
||||
create_data = cls._clean_form_data(form)
|
||||
create_data["password"] = encrypt_password(pwd, salt)
|
||||
create_data["salt"] = salt
|
||||
create_data["create_user"] = get_realname()
|
||||
_apply_city(create_data, city)
|
||||
|
||||
# 创建用户
|
||||
user = cls.repository.create(**create_data)
|
||||
user.code = f"{user.id:06d}"
|
||||
|
||||
# 关联角色
|
||||
_add_user_role(user.id, roles)
|
||||
# 清除权限缓存
|
||||
invalidate_user_cache(user.id)
|
||||
|
||||
@classmethod
|
||||
def _enrich_page_records(cls, records):
|
||||
"""分页富化:补充部门、职级、岗位、角色等关联数据"""
|
||||
return _enrich_user_records(records)
|
||||
|
||||
@classmethod
|
||||
def _detail_dict(cls, obj):
|
||||
"""详情序列化:剔除密码字段,补充关联数据"""
|
||||
data = model_to_dict(obj)
|
||||
if data:
|
||||
data.pop("password", None)
|
||||
return _enrich_user_detail(data)
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **必须声明 `repository`**:基类所有数据访问方法依赖此属性
|
||||
2. **声明 `form` 和 `status_form`**:用于自动表单校验
|
||||
3. **声明 `model_name`**:用于错误提示信息
|
||||
4. **删除前校验用 `before_delete` 钩子**:抛出 BizError 中止操作
|
||||
5. **必须创建模块级代理函数**:供 Router 层调用
|
||||
6. **Service 层不直接读取 request**:参数通过函数传入(dict 类型)
|
||||
7. **业务失败通过 `raise BizError` 表达**:不返回 R 对象
|
||||
|
||||
## 总结
|
||||
|
||||
Service 层继承 `BaseService` 获得通用 CRUD 能力,只需声明差异点(`repository` / `form` / `model_name`)并覆写钩子方法。所有方法均为 `@classmethod`,必须创建模块级代理函数供 Router 层调用。入参为数据字典,出参为纯数据,业务失败通过 `raise BizError` 表达。
|
||||
@@ -0,0 +1,361 @@
|
||||
# 测试规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目采用三层测试体系:契约快照测试、单元测试、集成测试。测试使用 pytest 框架,分层覆盖不同维度的质量保障。
|
||||
|
||||
:::tip 三层测试体系
|
||||
1. 契约测试(Contract):验证路由、权限、表名等契约不变
|
||||
2. 单元测试(Unit):验证单个函数/方法的逻辑正确性
|
||||
3. 集成测试(Integration):验证模块间协作、真实数据库操作
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── contract/ # 契约快照测试
|
||||
│ ├── golden/ # 快照基线文件(JSON)
|
||||
│ │ ├── routes.json
|
||||
│ │ ├── permissions.json
|
||||
│ │ └── tables.json
|
||||
│ ├── helpers.py # 测试辅助函数
|
||||
│ ├── test_routes_snapshot.py # 路由路径快照
|
||||
│ ├── test_permission_snapshot.py # 权限标识快照
|
||||
│ ├── test_tablename_snapshot.py # 表名快照
|
||||
│ └── test_schema_sql_consistency.py # 模型与 SQL 一致性
|
||||
├── unit/ # 单元测试
|
||||
│ ├── core/ # 核心模块测试
|
||||
│ │ ├── test_jwt.py
|
||||
│ │ ├── test_pagination.py
|
||||
│ │ ├── test_response.py
|
||||
│ │ └── ...
|
||||
│ ├── middleware/ # 中间件测试
|
||||
│ │ ├── test_operation_log.py
|
||||
│ │ └── ...
|
||||
│ ├── modules/ # 业务模块测试
|
||||
│ │ ├── conftest.py # service 层公共 fixture
|
||||
│ │ ├── test_upload_service.py
|
||||
│ │ ├── test_base_service_page.py
|
||||
│ │ └── ...
|
||||
│ ├── utils/ # 工具函数测试
|
||||
│ │ ├── test_ip_trusted_proxy.py
|
||||
│ │ ├── test_ip2region.py
|
||||
│ │ └── ...
|
||||
│ └── scripts/ # 脚本测试
|
||||
│ └── test_migrate_db.py
|
||||
├── integration/ # 集成测试(默认跳过)
|
||||
│ └── api/
|
||||
│ ├── test_smoke.py
|
||||
│ └── test_transaction.py
|
||||
├── test_bugfixes.py # Bug 回归测试
|
||||
├── test_rich_text.py # 富文本工具测试
|
||||
└── test_middleware_smoke.py # 中间件冒烟测试
|
||||
```
|
||||
|
||||
## pytest 配置
|
||||
|
||||
```ini
|
||||
# pytest.ini
|
||||
[pytest]
|
||||
minversion = 7.0
|
||||
addopts = -ra --strict-markers -m "not integration"
|
||||
testpaths = tests
|
||||
pythonpath = src
|
||||
|
||||
markers =
|
||||
contract: 契约快照测试(路由/权限串/表名),守护前端与数据契约
|
||||
integration: 需要真实 MySQL/Redis 的集成测试,默认跳过
|
||||
```
|
||||
|
||||
:::tip pythonpath
|
||||
`pythonpath = src` 让测试文件可以直接 `from src.modules.xxx import ...` 和 `from src.core.xxx import ...`,无需手动设置 PYTHONPATH。
|
||||
:::
|
||||
|
||||
## 契约快照测试
|
||||
|
||||
验证路由路径、权限标识、数据库表名等契约不变。快照文件存储在 `tests/contract/golden/` 目录。
|
||||
|
||||
### 路由快照
|
||||
|
||||
```python
|
||||
# tests/contract/test_routes_snapshot.py
|
||||
"""路由契约快照:守护全部 API 路径与 HTTP 方法不变。"""
|
||||
import pytest
|
||||
from tests.contract.helpers import collect_routes, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_routes_snapshot():
|
||||
compare_snapshot("routes.json", collect_routes())
|
||||
```
|
||||
|
||||
### 权限快照
|
||||
|
||||
```python
|
||||
# tests/contract/test_permission_snapshot.py
|
||||
"""权限契约快照:守护全部 @check_permission 权限串不变。"""
|
||||
import pytest
|
||||
from tests.contract.helpers import collect_permissions, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_permissions_snapshot():
|
||||
compare_snapshot("permissions.json", collect_permissions())
|
||||
```
|
||||
|
||||
### 辅助函数
|
||||
|
||||
```python
|
||||
# tests/contract/helpers.py
|
||||
import json
|
||||
import os
|
||||
|
||||
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
|
||||
UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
|
||||
|
||||
|
||||
def collect_routes():
|
||||
"""通过 Flask URL rules 收集全部 (METHOD /path),去重排序。"""
|
||||
from src.app import create_app
|
||||
app = create_app()
|
||||
routes = []
|
||||
with app.app_context():
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint != 'static':
|
||||
for method in rule.methods:
|
||||
if method not in ('HEAD', 'OPTIONS'):
|
||||
routes.append(f"{method} {rule.rule}")
|
||||
return sorted(routes)
|
||||
|
||||
|
||||
def collect_permissions():
|
||||
"""静态扫描源码中的 @check_permission("sys:...") 权限串。"""
|
||||
import glob
|
||||
import re
|
||||
perms = set()
|
||||
PERMISSION_RE = re.compile(r'@check_permission\(\s*["\'](sys:[^"\']+)["\']')
|
||||
for root in ("src/modules",):
|
||||
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
perms.update(PERMISSION_RE.findall(f.read()))
|
||||
return sorted(perms)
|
||||
|
||||
|
||||
def compare_snapshot(name: str, actual: list):
|
||||
"""与 golden 对比;UPDATE_GOLDEN=1 时重新生成并跳过断言。"""
|
||||
path = os.path.join(GOLDEN_DIR, name)
|
||||
if UPDATE_GOLDEN:
|
||||
os.makedirs(GOLDEN_DIR, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(actual, f, ensure_ascii=False, indent=2)
|
||||
return
|
||||
with open(path, encoding="utf-8") as f:
|
||||
golden = json.load(f)
|
||||
assert actual == golden, (
|
||||
f"契约快照 [{name}] 不一致!\n"
|
||||
f"新增: {sorted(set(actual) - set(golden))}\n"
|
||||
f"缺失: {sorted(set(golden) - set(actual))}"
|
||||
)
|
||||
```
|
||||
|
||||
### 更新快照
|
||||
|
||||
```bash
|
||||
# 更新全部快照基线
|
||||
UPDATE_GOLDEN=1 pytest tests/contract -m contract
|
||||
|
||||
# 更新后运行一次验证
|
||||
pytest tests/contract -m contract
|
||||
```
|
||||
|
||||
## 单元测试
|
||||
|
||||
验证单个函数/方法的逻辑正确性,不依赖外部服务。
|
||||
|
||||
### 测试命名规范
|
||||
|
||||
| 类型 | 文件命名 | 函数命名 |
|
||||
|------|---------|---------|
|
||||
| 契约测试 | `test_*.py` | `test_*` |
|
||||
| 单元测试 | `test_*.py` | `test_*` |
|
||||
| 集成测试 | `test_*.py` | `test_*` |
|
||||
|
||||
:::tip 函数命名规则
|
||||
测试函数名应清晰描述被测行为:
|
||||
- `test_upload_file_rejects_svg`:验证上传文件拒绝 SVG
|
||||
- `test_is_private_ip_handles_malformed_ip`:验证畸形 IP 不抛异常
|
||||
- `test_batch_delete_soft_deletes`:验证批量删除执行软删除
|
||||
:::
|
||||
|
||||
### monkeypatch 隔离依赖
|
||||
|
||||
使用 `monkeypatch` 替换模块级变量,隔离测试环境:
|
||||
|
||||
```python
|
||||
# tests/unit/modules/test_upload_service.py
|
||||
import json
|
||||
from src.modules.upload import service as upload_service
|
||||
from src.modules.upload.service import _save_upload, upload_file
|
||||
|
||||
|
||||
class _FakeUpload:
|
||||
"""模拟文件上传对象。"""
|
||||
def __init__(self, filename, data=b''):
|
||||
self.filename = filename
|
||||
self._data = data
|
||||
|
||||
def read(self, size=65536):
|
||||
data = self._data
|
||||
self._data = b''
|
||||
return data
|
||||
|
||||
|
||||
def test_upload_file_rejects_svg(monkeypatch):
|
||||
"""SVG 被 BANNED_UPLOAD_EXTS 硬拒绝"""
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg')
|
||||
result = upload_file(_FakeUpload('logo.svg', b'<svg/>'))
|
||||
body = json.loads(result.get_data(as_text=True))
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_save_upload_rejects_svg_even_if_config_allows():
|
||||
"""兜底硬拒绝:即使配置放行 .svg,落盘前一律拒绝"""
|
||||
content = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
result = _save_upload(content, 'a.svg', '.svg')
|
||||
body = json.loads(result.get_data(as_text=True))
|
||||
assert body['ok'] is False
|
||||
assert 'SVG' in body['msg']
|
||||
```
|
||||
|
||||
### 畸形输入防御测试
|
||||
|
||||
```python
|
||||
# tests/unit/utils/test_ip2region.py
|
||||
from src.common.utils.ip2region import _is_private_ip, get_ip_location, get_ip_region
|
||||
|
||||
|
||||
def test_is_private_ip_handles_malformed_ip():
|
||||
"""畸形 IP 不再抛 ValueError,按非内网处理"""
|
||||
assert _is_private_ip('172.abc') is False
|
||||
assert _is_private_ip('172.') is False
|
||||
|
||||
|
||||
def test_get_ip_location_malformed_ip_degrades_to_unknown():
|
||||
"""畸形 IP 不抛异常,降级返回"未知" """
|
||||
assert get_ip_location('172.abc') == '未知'
|
||||
|
||||
|
||||
def test_is_private_ip_private_ranges():
|
||||
assert _is_private_ip('172.16.5.5') is True
|
||||
assert _is_private_ip('172.31.5.5') is True
|
||||
assert _is_private_ip('172.32.5.5') is False
|
||||
assert _is_private_ip('10.1.2.3') is True
|
||||
assert _is_private_ip('192.168.1.1') is True
|
||||
assert _is_private_ip('8.8.8.8') is False
|
||||
```
|
||||
|
||||
### service 层公共 fixture
|
||||
|
||||
```python
|
||||
# tests/unit/modules/conftest.py
|
||||
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
|
||||
import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request():
|
||||
"""模拟 Flask Request,仅提供 service 层用到的字段。"""
|
||||
class FR:
|
||||
args = {}
|
||||
form = {}
|
||||
json = {}
|
||||
return FR()
|
||||
```
|
||||
|
||||
## 集成测试
|
||||
|
||||
验证模块间协作和真实数据库操作(需真实 MySQL/Redis,默认跳过):
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPositionIntegration:
|
||||
"""岗位模块集成测试"""
|
||||
|
||||
def test_crud_flow(self, client, auth_headers):
|
||||
"""验证完整的增删改查流程"""
|
||||
# 添加
|
||||
resp = client.post('/api/v1/position/add', json={
|
||||
'name': '集成测试岗位', 'status': 1, 'sort': 1
|
||||
}, headers=auth_headers)
|
||||
assert resp.json['ok'] is True
|
||||
|
||||
# 查询
|
||||
resp = client.get('/api/v1/position/page?pageNo=1&pageSize=10', headers=auth_headers)
|
||||
assert resp.json['ok'] is True
|
||||
assert resp.json['count'] > 0
|
||||
```
|
||||
|
||||
:::warning 集成测试环境
|
||||
集成测试需要真实的 MySQL 和 Redis 实例,默认跳过。运行前确保 `.env` 配置正确,且数据库中已有基础数据。
|
||||
:::
|
||||
|
||||
## 运行命令
|
||||
|
||||
```bash
|
||||
# 全部测试(跳过集成测试)
|
||||
pytest
|
||||
make test
|
||||
|
||||
# 契约测试
|
||||
pytest tests/contract -m contract
|
||||
make test-contract
|
||||
|
||||
# 单元测试
|
||||
pytest tests/unit
|
||||
make test-unit
|
||||
|
||||
# 集成测试(需真实 MySQL/Redis)
|
||||
pytest tests/integration -m integration
|
||||
make test-integration
|
||||
|
||||
# 运行指定文件
|
||||
pytest tests/unit/modules/test_upload_service.py
|
||||
|
||||
# 运行指定函数
|
||||
pytest tests/unit/modules/test_upload_service.py::test_upload_file_rejects_svg
|
||||
|
||||
# 显示详细输出
|
||||
pytest -v
|
||||
|
||||
# 显示 print 输出
|
||||
pytest -s
|
||||
```
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
```bash
|
||||
# 安装覆盖率插件
|
||||
pip install pytest-cov
|
||||
|
||||
# 运行测试并生成覆盖率报告
|
||||
pytest --cov=src --cov-report=html
|
||||
|
||||
# 查看报告
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
:::tip 覆盖率目标
|
||||
- Repository 层:80%+
|
||||
- Service 层:70%+
|
||||
- Router 层:由契约测试保障路由正确性
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
测试规范采用三层体系:契约快照测试保障路由/权限不变、单元测试验证单函数逻辑、集成测试验证模块协作。测试文件命名 `test_*.py`,函数命名 `test_*`。pytest 标记区分测试类型,默认跳过集成测试。契约快照通过 `UPDATE_GOLDEN=1` 环境变量更新基线。
|
||||
@@ -0,0 +1,147 @@
|
||||
# WTForms Schema 规范
|
||||
|
||||
## 概述
|
||||
|
||||
Schema 层负责请求数据的校验。项目使用 `WTForms` 定义表单类,通过 `validators` 约束字段格式,框架在接收请求体时自动执行校验。
|
||||
|
||||
:::tip 设计原则
|
||||
- 创建/编辑共用一个表单类,继承 `BaseForm`
|
||||
- 特殊操作单独建类(如状态更新、排序调整)
|
||||
- `message` 必填,用于返回给前端的错误信息
|
||||
- 唯一性校验通过自定义 `validate_` 方法实现
|
||||
:::
|
||||
|
||||
## BaseForm 继承
|
||||
|
||||
以岗位模块为例,`src/modules/system/position/schema.py` 完整内容:
|
||||
|
||||
```python
|
||||
from wtforms import StringField, IntegerField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange
|
||||
|
||||
from src.common.forms.base_form import BaseForm
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位表单类
|
||||
# ============================================================
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置状态表单类
|
||||
# ============================================================
|
||||
class PositionStatusForm(BaseForm):
|
||||
"""岗位状态更新表单"""
|
||||
id = IntegerField('岗位ID', validators=[
|
||||
DataRequired(message='岗位ID不能为空'),
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
```
|
||||
|
||||
## Validator 选择表
|
||||
|
||||
| Validator | 作用 | 示例 |
|
||||
|:----------|:-----|:-----|
|
||||
| `DataRequired` | 必填字段,不允许空值 | `DataRequired(message='不能为空')` |
|
||||
| `Length` | 字符串长度限制 | `Length(min=1, max=150)` |
|
||||
| `NumberRange` | 数值范围限制 | `NumberRange(min=1, max=2)` |
|
||||
| `Optional` | 可选字段 | `Optional()` |
|
||||
| `Email` | 邮箱格式 | `Email(message='邮箱格式不正确')` |
|
||||
| `URL` | URL 格式 | `URL(message='URL格式不正确')` |
|
||||
| `Regexp` | 正则表达式 | `Regexp(r'^[a-zA-Z]+$')` |
|
||||
|
||||
## 自定义校验器
|
||||
|
||||
### 唯一性校验
|
||||
|
||||
```python
|
||||
from wtforms import ValidationError
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
|
||||
def validate_name(self, field):
|
||||
"""自定义校验:检查岗位名称是否已存在"""
|
||||
exclude_id = self.id.data if self.id.data else None
|
||||
if PositionRepository.exists_by([Position.name == field.data], exclude_id):
|
||||
raise ValidationError('岗位名称已存在')
|
||||
```
|
||||
|
||||
### 跨字段校验
|
||||
|
||||
```python
|
||||
class DateRangeForm(BaseForm):
|
||||
"""日期范围表单"""
|
||||
start_date = DateField('开始日期', validators=[DataRequired()])
|
||||
end_date = DateField('结束日期', validators=[DataRequired()])
|
||||
|
||||
def validate_end_date(self, field):
|
||||
"""自定义校验:结束日期必须大于开始日期"""
|
||||
if field.data < self.start_date.data:
|
||||
raise ValidationError('结束日期必须大于开始日期')
|
||||
```
|
||||
|
||||
## 在 Router 中使用
|
||||
|
||||
```python
|
||||
from flask import request
|
||||
from src.common.utils.request import parse_request_body
|
||||
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
def add():
|
||||
"""新增岗位"""
|
||||
from src.modules.system.position.service import add_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return add_position(data)
|
||||
```
|
||||
|
||||
## 错误响应格式
|
||||
|
||||
校验失败时,框架自动返回标准错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "岗位名称不能为空",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,利用其内置的可选 `id` 字段
|
||||
2. **特殊操作单独建类**:如状态更新、排序调整等,只包含必要的字段
|
||||
3. **合理设置 Validator**:`DataRequired` / `Length` / `NumberRange` 等,让框架自动校验
|
||||
4. **message 必填**:用于返回给前端的错误信息,方便调试
|
||||
5. **唯一性校验**:通过自定义 `validate_` 方法实现,使用 Repository 层的 `exists_by` 方法
|
||||
6. **字段名与前端一致**:确保前后端字段名对应
|
||||
|
||||
## 总结
|
||||
|
||||
WTForms Schema 通过 `BaseForm` 继承公共字段(id 等),使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。
|
||||
Reference in New Issue
Block a user