签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -0,0 +1,162 @@
# 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": {
"records": [ ... ],
"total": 100,
"size": 10,
"current": 1,
"pages": 10
},
"msg": "操作成功",
"ok": true
}
```
## R 模块使用方法
### R.ok() 成功响应
```python
from core 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 | 验证错误 | Pydantic 参数校验失败 |
| 500 | 服务器错误 | 未捕获的异常 |
:::tip 错误码与 HTTP 状态码
业务错误码(code 字段)始终为 0 或 1,HTTP 状态码始终为 200。401/403/422 等仅在全局异常处理器中使用,常规业务错误统一返回 `code=1`。
:::
## 在 Endpoint 中使用
```python
from core import R
# 查询详情:service 返回 dict,endpoint 包 R.ok
@router.get('/detail/{id}')
def detail(request: Request, id):
result = position_service.get_detail(id)
return R.ok(data=result)
# 添加记录:service 直接返回 R 对象
@router.post('/add')
def add(request: Request, data: PositionForm):
return position_service.add(request, data)
# 删除记录:service 直接返回 R 对象
@router.delete('/delete/{id}')
def delete(request: Request, id):
return position_service.delete(id)
```
:::tip 响应封装层级
- **分页/列表查询**:service 返回 `R.ok()` 或 `R.page()`
- **详情查询**:service 返回 dict/None,endpoint 包 `R.ok(data=result)`
- **写操作(增/删/改/状态)**:service 直接返回 `R` 对象
:::
## Pydantic 验证错误响应
Pydantic 验证错误由全局 `RequestValidationError` 处理器自动转换为标准格式:
```json
{
"code": 1,
"data": null,
"msg": "name: 字段不能为空 / status: 输入值超出范围",
"ok": false
}
```
## 总结
API 响应规范通过 `R` 模块统一封装:`R.ok()` 返回成功、`R.failed()` 返回失败、`R.page()` 返回分页。所有响应保持 `{code, data, msg, ok}` 四字段结构,HTTP 状态码始终为 200。业务层错误统一用 `code=1`,前端只需判断 `ok` 字段即可。
@@ -0,0 +1,109 @@
# 代码审查清单
## 概述
代码审查(Code Review)是保障代码质量的重要环节。本文提供标准化的审查清单,覆盖安全、性能、规范、边界等维度,帮助审查者系统性地检查代码质量。
:::tip 审查原则
- 每次提交必须经过至少一人审查
- 审查关注点:正确性 > 安全性 > 可维护性 > 性能
- 审查意见应具体、可操作,避免主观评价
:::
## 安全检查
- [ ] **DEBUG 关闭**:生产配置 `FASTAPI_DEBUG=False`
- [ ] **JWT 安全**:`JWT_SALT` 使用强密码,未硬编码在代码中
- [ ] **CORS 配置**:未使用 `allow_origins=["*"]`,仅列出信任域名
- [ ] **SQL 注入**:使用 ORM 参数化查询,禁止字符串拼接 SQL
- [ ] **权限校验**:写操作接口有 `@permission_required` 和 `@check_demo` 装饰器
- [ ] **敏感信息**:日志中不记录密码、token、密钥等敏感信息
- [ ] **文件上传**:有文件类型白名单验证,文件名已重命名
- [ ] **输入校验**:所有用户输入通过 Pydantic Schema 校验
- [ ] **.env 安全**:`.env` 文件未提交到 Git(在 `.gitignore` 中)
- [ ] **密码存储**:使用 bcrypt 哈希存储,禁止明文
## 性能检查
- [ ] **N+1 查询**:关联查询使用 joinedload/subqueryload,避免循环查询
- [ ] **索引覆盖**:WHERE/ORDER BY 字段已添加索引
- [ ] **分页查询**:大列表接口使用分页,禁止全表查询返回
- [ ] **SELECT ***:只查询需要的字段,避免 `SELECT *`
- [ ] **缓存使用**:热点数据使用 Redis 缓存
- [ ] **同步/异步**:`async def` 端点中未直接调用同步 DB 查询
- [ ] **批量操作**:批量插入/更新使用 `executemany`,非逐条执行
- [ ] **连接池**:数据库连接池大小合理,不超过数据库 max_connections
## 代码规范检查
- [ ] **装饰器顺序**:`@router` → `@permission_required` → `@check_demo`
- [ ] **命名规范**:文件名 snake_case,类名 PascalCase,函数名 snake_case
- [ ] **注释规范**:关键逻辑有注释说明,docstring 描述函数用途
- [ ] **导入规范**:导入分组(标准库 → 第三方 → 本地模块),无未使用的导入
- [ ] **R 封装**:所有接口通过 `R.ok()`/`R.failed()` 返回,禁止直接返回 dict
- [ ] **删除规范**:使用 `batch_delete_with_r` 静态方法,禁止直接调用 `repo.batch_delete`
- [ ] **Repository 单例**:模块级单例模式(`xxx_repo = XxxRepository(Model)`)
- [ ] **Service 单例**:模块级单例模式(`xxx_service = XxxService()`)
## 边界条件检查
- [ ] **空值处理**:可选字段支持 None 值,不抛异常
- [ ] **空列表**:批量操作传入空列表时安全返回
- [ ] **超大输入**:字符串长度限制合理(`max_length`)
- [ ] **数值范围**:数值字段有 `ge`/`le`/`gt`/`lt` 约束
- [ ] **ID 有效性**:ID 参数校验 > 0,兼容字符串 ID
- [ ] **重复操作**:重复添加/删除同一数据的幂等性
- [ ] **并发安全**:并发修改同一记录的处理(乐观锁/唯一性校验)
- [ ] **软删除过滤**:查询方法自动过滤 `is_delete = 0`
## 数据库检查
- [ ] **模型定义**:继承 `base_model` 和 `base_db`
- [ ] **表名前缀**:`__tablename__ = DB_PREFIX + "表名"`
- [ ] **字段注释**:`comment` 参数描述字段用途
- [ ] **索引定义**:常用查询字段有索引
- [ ] **默认值**:`server_default` 与数据库默认值一致
- [ ] **软删除**:不使用物理删除,统一软删除
## API 接口检查
- [ ] **HTTP 方法**:查询用 GET,新增用 POST,编辑用 PUT,删除用 DELETE
- [ ] **权限标识**:格式 `sys:{module}:{action}`
- [ ] **请求参数**:Pydantic Schema 定义完整约束
- [ ] **响应格式**:统一使用 `R.ok()`/`R.failed()`/`R.page()`
- [ ] **错误提示**:错误消息清晰、用户友好
- [ ] **路由注册**:新模块已在 `api/v1/router.py` 中注册
## 前端检查
- [ ] **API 函数命名**:`getXxxPage`/`addXxx`/`updateXxx`/`deleteXxx`
- [ ] **错误处理**:响应拦截器统一处理错误
- [ ] **loading 状态**:数据加载时显示 loading
- [ ] **确认弹窗**:删除操作有二次确认
- [ ] **表单校验**:提交前前端校验必填项
## 提交检查
- [ ] **提交信息**:遵循 Conventional Commits 格式
- [ ] **变更范围**:每次提交只做一件事
- [ ] **测试覆盖**:新功能/修复有对应测试
- [ ] **文档更新**:接口变更同步更新文档
## 审查意见模板
```
[严重] 安全问题:SQL 拼接存在注入风险,应使用 ORM 参数化查询
[建议] 性能优化:此处可用 joinedload 避免 N+1 查询
[疑问] 这里的 try-except 吞掉了异常,是否需要记录日志?
[肯定] 使用 batch_delete_with_r 统一封装删除响应,符合规范
```
:::tip 审查态度
- 区分「必须修改」和「建议优化」
- 给出具体的修改方案,而非只指出问题
- 对好的实践给予肯定
:::
## 总结
代码审查清单覆盖安全、性能、规范、边界、数据库、API、前端、提交等 8 个维度。审查时按清单逐项检查,区分严重问题和建议优化。核心关注点:权限校验是否完整、SQL 是否安全、同步/异步是否正确、响应格式是否统一。审查意见应具体可操作,帮助团队持续提升代码质量。
@@ -0,0 +1,165 @@
# 删除规范
## 概述
统一使用软删除机制,通过 `is_delete` 字段标记记录状态,不物理删除数据。删除操作分为单条删除和批量删除两种方式。
:::tip 软删除约定
- `is_delete = 0`:正常记录(默认值)
- `is_delete = 1`:已删除记录
- 所有查询方法自动过滤 `is_delete = 0`,业务层无需关心
:::
## 单条删除
### URL 格式
```
DELETE /api/v1/{module}/delete/{id}
```
支持逗号分隔的多 ID 删除:
```
DELETE /api/v1/position/delete/1,2,3
```
### Endpoint 示例
```python
@router.delete('/delete/{id}', summary='删除岗位')
@permission_required("sys:position:delete")
@check_demo
def delete(request: Request, id):
"""根据ID删除单个岗位"""
return position_service.delete(id)
```
:::tip 装饰器顺序
删除接口的装饰器顺序:`@router.delete` → `@permission_required` → `@check_demo`
:::
## 批量删除
### 请求格式
```
DELETE /api/v1/{module}/batchDelete
Content-Type: application/json
[1, 2, 3]
```
### Endpoint 示例
```python
@router.delete('/batchDelete', summary='批量删除岗位')
@permission_required("sys:position:batchDelete")
@check_demo
async def batch_delete(request: Request):
"""批量删除岗位"""
return await position_service.batch_delete(request)
```
:::warning async def 规则
批量删除端点必须为 `async def`,因为 `parse_batch_ids(request)` 需要异步读取请求体。
:::
## batch_delete_with_r 模式
### 原理
Repository 层的 `batch_delete` 只做数据原语(软删除 + 返回条数),Service 层通过 `BaseService.batch_delete_with_r()` 静态方法统一封装 R 响应。
```python
@staticmethod
def batch_delete_with_r(repo: BaseRepository, ids) -> R:
"""按逗号分隔ID软删除并返回标准R响应"""
id_list = parse_id_list(str(ids))
if not id_list:
return R.failed("记录ID不存在")
count = repo.batch_delete(ids)
if count != len(id_list):
return R.failed("记录不存在")
return R.ok(msg="本次共删除{0}条数据".format(count))
```
### 自定义 Service 复用
```python
class PositionService(BaseService[Position]):
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
```
:::tip _before_delete 钩子
- 返回 `None`:放行,继续删除
- 返回字符串:拦截,返回 `R.failed(字符串)`
- 用于检查子级引用、业务约束等
:::
## 删除流程
```
1. Endpoint 接收请求
↓
2. Service.delete(ids) 或 Service.batch_delete(request)
↓
3. _before_delete(ids) 钩子校验
↓(拦截则返回 R.failed)
4. batch_delete_with_r(repo, ids)
↓
5. repo.batch_delete(ids) 软删除
↓
6. 返回 R.ok(msg="本次共删除N条数据")
```
## Repository 层实现
```python
def batch_delete(self, ids_str) -> int:
"""按ID软删除,返回实际删除条数"""
id_list = parse_id_list(str(ids_str))
if not id_list:
return 0
records = self.db.query(self.model).filter(
self.model.id.in_(id_list), self._soft_col() == 0
).all()
for record in records:
setattr(record, self.soft_delete_col, 1)
return len(records)
```
:::tip 返回值说明
`batch_delete` 返回实际软删除的条数。如果传入的 ID 中包含已删除或不存在的记录,返回值会小于入参数量,此时 `batch_delete_with_r` 会返回失败提示。
:::
## 权限标识
| 操作 | 权限标识 | 说明 |
|------|---------|------|
| 单条删除 | `sys:{module}:delete` | 删除单条记录 |
| 批量删除 | `sys:{module}:batchDelete` | 批量删除记录 |
## 常见错误提示
| 提示 | 原因 |
|------|------|
| "记录ID不存在" | 入参为空或格式错误 |
| "记录不存在" | 部分或全部 ID 对应的记录已删除/不存在 |
| "存在用户引用该岗位" | _before_delete 钩子拦截 |
## 总结
删除规范统一使用软删除(`is_delete` 标记),支持单条删除(逗号分隔 ID)和批量删除(JSON 数组)。Repository 层做数据原语,Service 层通过 `batch_delete_with_r` 统一封装响应。自定义 Service 通过 `_before_delete` 钩子添加业务校验。批量删除端点必须为 `async def`。
@@ -0,0 +1,177 @@
# 文档规范
## 概述
良好的文档是项目可维护性的基石。本文规范 API 文档、Pydantic 文档、代码注释等文档的编写标准。
:::tip 文档原则
- 文档与代码同步更新
- 使用中文编写(项目约定)
- 示例代码可直接运行
:::
## API 接口文档
FastAPI 自动生成 Swagger 文档,访问地址:
```
http://127.0.0.1:8031/docs # Swagger UI
http://127.0.0.1:8031/redoc # ReDoc
http://127.0.0.1:8031/openapi.json # OpenAPI JSON
```
### Endpoint 文档注释
```python
@router.post('/add', summary='添加岗位')
@permission_required("sys:position:add")
@check_demo
async def add(request: Request, data: PositionForm):
"""
新增岗位
- **name**: 岗位名称(必填,1-150字符)
- **status**: 岗位状态(必填,1-在用 2-停用)
- **sort**: 岗位排序(必填,0-99999)
:param request: FastAPI请求对象
:param data: 岗位表单数据
:return: 添加结果响应
"""
return await position_service.add(request, data)
```
:::tip 文档层级
- `summary`:简短标题(显示在接口列表)
- docstring:详细描述(显示在接口详情)
- Pydantic `Field(description=...)`:字段级描述
:::
## Pydantic 文档
Pydantic 模型通过 `Field` 的 `description` 参数自动生成字段文档:
```python
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
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="岗位排序"
)
```
:::tip description 规范
- 必须填写 `description`,用于 Swagger 文档展示
- 枚举值说明使用 `值-含义` 格式(如 `1-在用 2-停用`)
- 有约束条件时在描述中说明(如 `1-150字符`)
:::
## 代码注释规范
### 文件头注释
```python
# +======================================================================
# | 模块: 岗位业务逻辑层
# | 说明: 岗位的增删改查、唯一性校验、状态管理等业务处理
# +======================================================================
```
### 类注释
```python
class PositionService(BaseService[Position]):
"""岗位业务服务类,继承基础服务获得通用 CRUD 能力"""
```
### 方法注释
```python
def _before_delete(self, ids) -> Optional[str]:
"""删除前校验:存在用户引用该岗位时禁止删除,避免用户岗位悬空
Args:
ids: 逗号分隔的ID字符串
Returns:
None: 放行删除
str: 拦截删除,返回错误提示
"""
```
### 行内注释
```python
# 自动软删过滤,调用方无需关心 is_delete
query = query.filter(self._soft_col() == 0)
```
:::tip 注释原则
- 解释"为什么"而非"是什么"
- 复杂逻辑必须注释
- 简单代码无需注释
- 注释与代码同步更新
:::
## 文档目录结构
```
document/ # 补充文档
├── 数据库迁移_runbook.md # 数据库迁移操作手册
└── djangoadmin.fastapi.elevue.sql # 数据库结构 SQL
wiki/ # VitePress 文档站
├── zh/ # 中文文档
│ ├── 1. 了解项目/ # why.md, struct.md, course.md
│ ├── 2. 快速入门/ # volume1/(环境、启动、数据库、Docker)
│ ├── 3. 模块开发实战/ # volume2/(Model→Schema→Repo→Service→Endpoint→前端)
│ ├── 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 接口时,同步更新 Swagger 注释
- [ ] 新增/修改 Pydantic 字段时,填写 `description`
- [ ] 复杂逻辑添加代码注释
- [ ] 重大变更更新 wiki 文档
## 总结
文档规范覆盖 API 文档(Swagger 自动注释)、Pydantic 文档(Field description)、代码注释(文件头/类/方法/行内)。核心原则:文档与代码同步更新,使用中文编写,示例可直接运行。重大变更同步更新 wiki 文档。
@@ -0,0 +1,201 @@
# Endpoint层规范
## 概述
Endpoint 层是 HTTP 接口层,定义路由和请求处理。每个模块的 Endpoint 文件位于 `src/api/v1/endpoints/{name}.py`,使用 FastAPI `APIRouter` 定义接口。
:::tip 设计原则
- Endpoint 只做请求分发,不含业务逻辑
- 通过装饰器控制权限和演示模式
- 委托 Service 处理业务逻辑
- 返回 R 封装的响应
:::
## 装饰器顺序
```python
@router.post('/add') # 1. 路由定义
@permission_required("sys:position:add") # 2. 权限校验
@check_demo # 3. 演示模式拦截
def add(request: Request, data: PositionForm):
return position_service.add(request, data)
```
:::warning 装饰器顺序规则
1. `@router.get/post/put/delete`:路由定义(最外层)
2. `@permission_required("权限标识")`:RBAC 权限校验
3. `@check_demo`:演示环境写操作拦截(仅写接口需要)
顺序不可调换,否则权限校验或演示拦截可能失效。
:::
## 读操作(无需 @check_demo)
```python
# 分页查询
@router.get('/page', summary='查询分页数据')
@permission_required("sys:position:page")
def page(request: Request):
return position_service.get_page(request)
# 详情查询
@router.get('/detail/{id}', summary='查询岗位详情')
@permission_required("sys:position:detail")
def detail(request: Request, id):
return R.ok(data=position_service.get_detail(id))
# 列表查询(无分页)
@router.get('/list', summary='获取岗位列表')
@permission_required("sys:position:list")
def get_position_list(request: Request):
return R.ok(data=position_service.get_position_list(request))
```
## 写操作(需要 @check_demo)
```python
# 添加
@router.post('/add', summary='添加岗位')
@permission_required("sys:position:add")
@check_demo
def add(request: Request, data: PositionForm):
return position_service.add(request, data)
# 编辑
@router.put('/update', summary='更新岗位')
@permission_required("sys:position:update")
@check_demo
def update(request: Request, data: PositionForm):
return position_service.update(request, data)
# 删除
@router.delete('/delete/{id}', summary='删除岗位')
@permission_required("sys:position:delete")
@check_demo
def delete(request: Request, id):
return position_service.delete(id)
# 设置状态
@router.put('/status', summary='设置岗位状态')
@permission_required("sys:position:status")
@check_demo
def status(request: Request, data: PositionStatusForm):
return position_service.update_status(request, data)
# 批量删除(必须 async def)
@router.delete('/batchDelete', summary='批量删除岗位')
@permission_required("sys:position:batchDelete")
@check_demo
async def batch_delete(request: Request):
return await position_service.batch_delete(request)
```
:::warning 批量删除必须 async
`batch_delete` 端点必须为 `async def`,因为 `parse_batch_ids(request)` 需要异步读取请求体。其他端点使用同步 `def` 即可。
:::
## 同步/异步选择
| 场景 | 端点类型 | 原因 |
|------|---------|------|
| 纯 CRUD(增删改查) | `def` | Service 为同步,FastAPI 自动线程池执行 |
| 批量删除 | `async def` | 需 `await parse_batch_ids(request)` |
| 含 Redis/文件 IO | `async def` | Service 为异步,需 await |
## 权限标识规范
权限标识格式:`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 的管理员账号跳过所有 `permission_required` 检查。
:::
## 完整 Endpoint 文件模板
```python
from fastapi import APIRouter, Request
from modules.system.position.schemas import PositionForm, PositionStatusForm
from core.access_decorators import check_demo, permission_required
from modules.system.position.service import position_service
from core import R
router = APIRouter()
@router.get('/page', summary='查询分页数据')
@permission_required("sys:position:page")
def page(request: Request):
return position_service.get_page(request)
@router.get('/detail/{id}', summary='查询详情')
@permission_required("sys:position:detail")
def detail(request: Request, id):
return R.ok(data=position_service.get_detail(id))
@router.get('/list', summary='获取列表')
@permission_required("sys:position:list")
def get_list(request: Request):
return R.ok(data=position_service.get_position_list(request))
@router.post('/add', summary='添加')
@permission_required("sys:position:add")
@check_demo
def add(request: Request, data: PositionForm):
return position_service.add(request, data)
@router.put('/update', summary='更新')
@permission_required("sys:position:update")
@check_demo
def update(request: Request, data: PositionForm):
return position_service.update(request, data)
@router.delete('/delete/{id}', summary='删除')
@permission_required("sys:position:delete")
@check_demo
def delete(request: Request, id):
return position_service.delete(id)
@router.put('/status', summary='设置状态')
@permission_required("sys:position:status")
@check_demo
def status(request: Request, data: PositionStatusForm):
return position_service.update_status(request, data)
@router.delete('/batchDelete', summary='批量删除')
@permission_required("sys:position:batchDelete")
@check_demo
async def batch_delete(request: Request):
return await position_service.batch_delete(request)
```
## 路由注册
在 `src/api/v1/router.py` 中注册模块路由:
```python
from api.v1.endpoints import position
v1.include_router(position.router, prefix="/position", tags=["岗位管理"])
```
## 总结
Endpoint 层是 HTTP 接口定义层,只做请求分发和装饰器配置。核心规则:装饰器顺序为 `@router` → `@permission_required` → `@check_demo`;读操作用同步 `def`,批量删除用 `async def`;权限标识格式 `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,160 @@
# 分页规范
## 概述
统一使用 `pageNo`/`pageSize` 作为分页参数,`BaseService.get_page()` 封装分页逻辑,返回标准分页结构。
:::tip 分页约定
- 前端传递 `pageNo`(页码,从 1 开始)和 `pageSize`(每页条数)
- 后端返回 `{records, total, size, current, pages}` 结构
- 默认每页 10 条(`PAGE_SIZE = 10`)
:::
## 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `pageNo` | int | 否 | 1 | 页码,从 1 开始 |
| `pageSize` | int | 否 | 10 | 每页条数 |
### 请求示例
```bash
# GET 请求
GET /api/v1/position/page?pageNo=1&pageSize=10
# 带筛选条件
GET /api/v1/position/page?pageNo=1&pageSize=10&name=开发&status=1
```
## 响应格式
```json
{
"code": 0,
"data": {
"records": [
{
"id": 1,
"name": "开发工程师",
"status": 1,
"statusText": "在用",
"sort": 1,
"createTime": "2026-01-01 10:00:00"
}
],
"total": 50,
"size": 10,
"current": 1,
"pages": 5
},
"msg": "操作成功",
"ok": true
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `records` | array | 当前页数据列表 |
| `total` | int | 总记录数 |
| `size` | int | 每页条数 |
| `current` | int | 当前页码 |
| `pages` | int | 总页数 |
## BaseService 实现
### get_page 方法
```python
def get_page(self, request) -> R:
"""分页查询"""
page, limit = parse_pagination(request)
query = self._build_page_query(request)
items, total = self.repo.paginate(page, limit, query)
# 超页自动回退第 1 页
if page > 1 and not items:
page = 1
items, total = self.repo.paginate(page, limit, query)
result = [self._serialize(item) for item in items]
return R.page(result, total, page, limit)
```
:::tip 超页回退
当请求的页码超过总页数时(如删除数据后前端未刷新),自动回退到第 1 页,避免返回空数据。
:::
### 分页查询构建
```python
def _build_page_query(self, request):
"""组装分页查询:声明字段过滤 + 排序"""
query = self.repo.filter()
query = self._apply_page_filters(query, request)
for field_name, direction in self.page_order_by:
column = getattr(self.model, field_name)
query = query.order_by(column.asc() if direction == 'asc' else column.desc())
return query
```
### 字段过滤声明
```python
class PositionService(BaseService[Position]):
# 模糊查询字段:name LIKE %值%
page_like_fields = ('name',)
# 等值查询字段:status = 值
page_eq_fields = ('status',)
# 排序规则
page_order_by = (('sort', 'asc'),)
:::tip 过滤字段说明
- `page_like_fields`:从 query_params 取同名参数,自动应用 `LIKE %值%`,空值跳过
- `page_eq_fields`:从 query_params 取同名参数,自动应用 `=值`,空值跳过
- 两种字段均自动跳过 None/空字符串,无需手动判断
:::
## Repository 分页实现
```python
def paginate(self, page_no: int = 1, page_size: int = PAGE_SIZE, query=None):
"""分页查询,返回 (items, total)"""
q = query if query is not None else self.db.query(self.model)
q = q.filter(self._soft_col() == 0) # 自动软删过滤
total = q.count()
items = q.limit(page_size).offset((page_no - 1) * page_size).all()
return items, total
```
## Endpoint 示例
```python
@router.get('/page', summary='查询分页数据')
@permission_required("sys:position:page")
def page(request: Request):
"""获取岗位分页列表"""
return position_service.get_page(request)
```
:::tip 权限标识
分页接口权限标识格式:`sys:{模块名}:page`
:::
## 自定义分页过滤
子类可覆盖 `_apply_page_filters` 添加固定过滤条件:
```python
def _apply_page_filters(self, query, request):
"""添加固定过滤:只显示启用状态的数据"""
query = super()._apply_page_filters(query, request)
query = query.filter(self.model.status == 1)
return query
```
## 总结
分页规范统一使用 `pageNo`/`pageSize` 参数,通过 `BaseService.get_page()` 自动处理分页逻辑。子类只需声明 `page_like_fields`/`page_eq_fields`/`page_order_by` 即可获得完整的分页能力。响应使用 `R.page()` 封装标准分页结构,前端可直接使用 `records`/`total`/`pages` 渲染列表和分页组件。
@@ -0,0 +1,189 @@
# Pydantic Schema规范
## 概述
使用 Pydantic v2 进行请求数据校验。所有请求体参数通过 Pydantic `BaseModel` 定义,FastAPI 在调用 Endpoint 前自动完成解析和验证。
:::tip 核心原则
- 请求体参数必须声明为 Pydantic 模型(Form 类)
- 字段约束使用 `Field(...)` 定义
- 自定义校验使用 `@field_validator`
- 唯一性校验在 Schema 中查询数据库
:::
## 命名规范
| 类型 | 命名格式 | 示例 |
|------|---------|------|
| 创建/编辑表单 | `{模块}Form` | `PositionForm` |
| 状态更新表单 | `{模块}StatusForm` | `PositionStatusForm` |
| 查询参数表单 | `{模块}QueryForm` | `PositionQueryForm` |
:::tip 继承 BaseForm
创建/编辑表单继承 `core.base_schemas.BaseForm`,自动获得 `id: Optional[int]` 字段(编辑时必填)。
:::
## PositionForm 示例
以岗位模块为例,`src/modules/system/position/schemas.py` 完整内容:
```python
from typing import Optional
from pydantic import BaseModel, Field
from core.base_schemas import BaseForm
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
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-停用")
```
:::tip BaseForm 基类
`BaseForm`(定义在 `src/core/base_schemas.py`)提供 `id: Optional[int]` 字段和 `empty_str_to_none` 验证器,自动将前端传入的空字符串 `''`/`'null'`/`'undefined'` 转为 `None`,避免 Pydantic 整数解析报错。
:::
## Field 约束
| 约束 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `min_length` | str | 最小长度 | `Field(..., min_length=1)` |
| `max_length` | str | 最大长度 | `Field(..., max_length=150)` |
| `ge` | int/float | 大于等于 | `Field(..., ge=0)` |
| `le` | int/float | 小于等于 | `Field(..., le=99999)` |
| `gt` | int/float | 大于 | `Field(..., gt=0)` |
| `lt` | int/float | 小于 | `Field(..., lt=100)` |
| `description` | any | 字段描述(Swagger 文档) | `Field(..., description="名称")` |
:::tip Field 第一个参数
- `...`:必填字段
- `None`:可选字段
- 具体值:默认值
:::
## field_validator 自定义校验
```python
from pydantic import BaseModel, Field, field_validator
class UserForm(BaseForm):
"""用户创建/编辑表单"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
email: str = Field(..., max_length=100, description="邮箱")
phone: Optional[str] = Field(None, max_length=20, description="手机号")
@field_validator('username')
@classmethod
def validate_username(cls, v):
"""用户名只允许字母、数字、下划线"""
import re
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('用户名只允许字母、数字、下划线')
return v
@field_validator('email')
@classmethod
def validate_email(cls, v):
"""邮箱格式校验"""
import re
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', v):
raise ValueError('邮箱格式不正确')
return v
@field_validator('phone')
@classmethod
def validate_phone(cls, v):
"""手机号格式校验(可选字段,有值时校验)"""
if v is not None and v != '':
import re
if not re.match(r'^1[3-9]\d{9}$', v):
raise ValueError('手机号格式不正确')
return v
```
:::tip validator 规则
- 使用 `@field_validator('field_name')` 装饰器(Pydantic v2 语法)
- 必须加 `@classmethod`
- 校验失败抛出 `ValueError(错误消息)`
- 可选字段需判断 `None` 值
:::
## 唯一性校验
在 Schema 的 `field_validator` 中查询数据库:
```python
from pydantic import BaseModel, Field, field_validator
from core.database import get_db_session
from modules.system.position.models import Position
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name: str = Field(..., min_length=1, max_length=150, description="岗位名称")
@field_validator('name')
@classmethod
def validate_name_unique(cls, v, info):
"""岗位名称唯一性校验"""
db = get_db_session()
query = db.query(Position).filter(
Position.name == v,
Position.is_delete == 0
)
# 编辑时排除自身
if info.data.get('id'):
query = query.filter(Position.id != info.data['id'])
if query.first():
raise ValueError('岗位名称不能重复')
return v
```
:::tip 唯一性校验位置
简单模块的唯一性校验可在 Schema 中完成。复杂模块(如用户、角色)的唯一性校验在 Service 层的 `_check_unique` 方法中统一处理,通过 `unique_fields` 字典声明。
:::
## 在 Endpoint 中使用
```python
@router.post('/add', summary='添加岗位')
@permission_required("sys:position:add")
@check_demo
async def add(request: Request, data: PositionForm):
"""
新增岗位
:param request: FastAPI请求对象
:param data: 岗位表单数据(Pydantic 自动校验)
"""
return await position_service.add(request, data)
```
:::tip 数据访问
Service 层通过 `data.field` 直接访问字段,或通过 `data.model_dump()` 获取完整字典。Pydantic 在进入 Endpoint 之前已完成校验,Service 无需重复验证。
:::
## 错误响应
Pydantic 校验失败时,由全局 `RequestValidationError` 处理器自动转换:
```json
{
"code": 1,
"data": null,
"msg": "name: 字段不能为空 / status: 输入值超出范围",
"ok": false
}
```
## 总结
Pydantic Schema 规范要求:表单类命名 `{Module}Form`,继承 `BaseForm`;字段约束用 `Field(...)` 定义长度/范围;自定义校验用 `@field_validator` 抛 `ValueError`;唯一性校验在 Schema 或 Service 的 `unique_fields` 中完成。FastAPI 自动解析和验证,Service 直接使用 `data.field` 访问已校验的数据。
@@ -0,0 +1,170 @@
# Repository层规范
## 概述
Repository 层是数据访问层,封装所有数据库操作。各模块 Repository 继承 `BaseRepository[Model]`,获得通用 CRUD 能力,子类按需添加自定义查询。
:::tip 设计原则
- Repository 只做数据访问原语,不处理 R 封装/序列化/业务逻辑
- 软删除由基类自动处理,子类无需关心
- 查询方法自动跳过 None 值条件
:::
## BaseRepository 继承
以岗位模块为例,`src/modules/system/position/repository.py` 完整内容:
```python
"""modules/system/position 数据访问层。"""
from modules.system.position.models import Position
from core.base_repository import BaseRepository
class PositionRepository(BaseRepository[Position]):
"""岗位数据仓库,继承基础仓库获得通用 CRUD 能力"""
pass
# 模块级单例
position_repo = PositionRepository(Position)
```
:::tip 单例模式
每个模块在文件末尾创建 Repository 实例(如 `position_repo`),供 Service 层使用。避免每次请求创建新实例。基类构造函数接收模型类:`PositionRepository(Position)`。
:::
## 查询方法选择表
| 方法 | 软删过滤 | 自动跳过 None | 适用场景 |
|------|---------|--------------|---------|
| `get_by_id(id)` | 是 | - | 按 ID 查询单条 |
| `get_by_ids(ids)` | 是 | - | 按 ID 列表批量查询 |
| `get_one(**filters)` | 是 | 是 | 按等值条件查单条 |
| `get_all(**filters)` | 是 | 是 | 按等值条件查多条 |
| `count(**filters)` | 是 | 是 | 统计记录数 |
| `exists(obj_id)` | 是 | - | 检查记录是否存在 |
| `exists_by_field(field, value, exclude_id)` | 是 | - | 唯一性校验 |
| `filter_by(**kwargs)` | 是 | 是 | 等值查询,返回 Query |
| `filter(*criterion)` | **否** | - | 原始查询,复杂条件 |
| `paginate(page, size, query)` | 是 | - | 分页查询 |
:::warning filter 与 filter_by 的区别
- `filter_by(**kwargs)`:等值查询,自动软删过滤,跳过 None 值,返回 Query
- `filter(*criterion)`:原始查询,**不自动软删过滤**,用于 `in_`/`!=`/`and_`/区间等复杂条件,需调用方自行追加 `is_delete == 0`
:::
## 常用查询示例
### 等值查询
```python
# 查询单条:自动软删过滤,跳过 None
user = user_repo.get_one(username='admin', status=1)
# 查询多条:自动软删过滤
positions = position_repo.get_all(status=1)
# 统计数量
count = position_repo.count(status=1)
# 唯一性校验
exists = position_repo.exists_by_field('name', '开发工程师', exclude_id=5)
```
### 复杂条件查询
```python
# 使用 filter(需自行追加 is_delete 条件)
users = user_repo.filter(
User.dept_id.in_([1, 2, 3]),
User.is_delete == 0
).all()
# 区间查询
articles = article_repo.filter(
Article.create_time >= start_date,
Article.create_time <= end_date,
Article.is_delete == 0
).all()
# 使用 filter_by + 排序
positions = position_repo.filter_by(status=1).order_by(
Position.sort.asc()
).all()
```
### 分页查询
```python
# 自定义 query 分页
query = position_repo.filter_by(status=1).order_by(Position.sort.asc())
items, total = position_repo.paginate(page_no=1, page_size=10, query=query)
```
## 写操作
### 创建
```python
# 创建记录(不提交事务,由中间件统一管理)
position_repo.create(name='开发工程师', status=1, sort=1)
```
### 更新
```python
# 按 ID 更新,返回受影响行数
count = position_repo.update(obj_id=1, data={'name': '高级开发工程师', 'sort': 2})
```
### 删除
```python
# 软删除,返回实际删除条数
count = position_repo.batch_delete("1,2,3")
```
:::tip 删除的 R 封装
Repository 的 `batch_delete` 只做数据原语,R 响应由 Service 层 `BaseService.batch_delete_with_r(repo, ids)` 统一产出。禁止在 Repository 层直接返回 R 对象。
:::
## 自定义查询方法
子类可添加模块特有的查询方法:
```python
class UserRepository(BaseRepository[User]):
"""用户数据仓库"""
def get_by_username(self, username: str):
"""根据用户名查询"""
return self.get_one(username=username)
def get_users_by_dept(self, dept_id: int):
"""查询部门下所有用户"""
return self.get_all(dept_id=dept_id)
def get_users_with_role(self, role_id: int):
"""查询拥有指定角色的用户"""
return self.filter(
User.is_delete == 0
).join(
UserRole, UserRole.user_id == User.id
).filter(
UserRole.role_id == role_id
).all()
```
## 数据清洗
`create` 和 `update` 方法内置 `_clean_model_data()`,自动将 camelCase 键转为 snake_case,并仅保留模型真实列字段:
```python
# 传入 camelCase 键,自动转换
position_repo.create(positionName='开发', status=1)
# 内部自动转为 position_name → 查找模型列 → 忽略不存在的字段
```
## 总结
Repository 层通过继承 `BaseRepository[Model]` 获得完整的 CRUD 能力。查询方法分两类:等值便捷方法(`get_one`/`get_all`/`filter_by` 等,自动软删过滤)和原始查询出口(`filter`,需自行处理软删)。写操作由 Repository 做数据原语,R 响应由 Service 层统一封装。模块级单例模式避免重复创建实例。
@@ -0,0 +1,187 @@
# Service层规范
## 概述
Service 层是业务逻辑层,各模块 Service 继承 `BaseService[Model]` 获得通用 CRUD 能力。子类只需声明差异点(repo、model、过滤字段、排序、唯一性等),即可获得完整的增删改查接口。
:::tip 设计模式
BaseService 采用模板方法模式:基类定义流程骨架,子类通过声明差异点和覆盖钩子定制行为。
:::
## PositionService 示例
以岗位模块为例,`src/modules/system/position/service.py` 完整内容:
```python
from typing import Optional
from core.base_service import BaseService
from modules.system.position.models import Position
from modules.system.position.repository import position_repo
from modules.system.user.models import User
from modules.system.user.repository import user_repo
from utils.request import parse_id_list
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()
```
## 差异点声明
| 属性 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `repo` | BaseRepository | 数据仓库实例 | `position_repo` |
| `model` | Model | ORM 模型类 | `Position` |
| `page_like_fields` | tuple | 分页模糊查询字段 | `('name',)` |
| `page_eq_fields` | tuple | 分页等值查询字段 | `('status',)` |
| `page_order_by` | tuple | 分页排序规则 | `(('sort', 'asc'),)` |
| `unique_fields` | dict | 唯一性校验字段 | `{'name': '名称重复'}` |
| `serialize_maps` | dict | 枚举显示名映射 | `{'status': {1: '在用', 2: '停用'}}` |
| `file_fields` | tuple | 文件字段 | `('cover', 'image')` |
| `rich_text_fields` | tuple | 富文本字段 | `('content',)` |
:::tip 单例模式
每个模块在文件末尾创建 Service 实例(如 `position_service`),供 Endpoint 层使用。
:::
## 可覆盖钩子
### 查询钩子
```python
def _apply_page_filters(self, query, request):
"""组装分页过滤条件"""
# 调用基类默认实现(处理 page_like_fields / page_eq_fields)
query = super()._apply_page_filters(query, request)
# 追加固定过滤
query = query.filter(self.model.status == 1)
return query
def _serialize(self, item) -> Dict[str, Any]:
"""列表行序列化"""
data = item.to_dict()
# 自定义序列化逻辑
return data
def _serialize_detail(self, obj) -> Dict[str, Any]:
"""详情序列化"""
data = self._serialize(obj)
# 详情特有字段
return data
```
### 写操作钩子
```python
def _before_add(self, request, data) -> None:
"""新增前扩展(如密码哈希)"""
def _before_update(self, request, data) -> None:
"""编辑前扩展"""
def _before_delete(self, ids) -> Optional[str]:
"""删除前校验:返回 None 放行,返回字符串拦截"""
def _build_create_fields(self, request, data) -> Dict[str, Any]:
"""组装新增字段"""
fields = super()._build_create_fields(request, data)
# 追加自定义字段
return fields
def _build_update_fields(self, request, data) -> Dict[str, Any]:
"""组装更新字段"""
fields = super()._build_update_fields(request, data)
# 追加自定义字段
return fields
```
## batch_delete_with_r 静态方法
```python
@staticmethod
def batch_delete_with_r(repo: BaseRepository, ids) -> R:
"""按逗号分隔ID软删除并返回标准R响应"""
id_list = parse_id_list(str(ids))
if not id_list:
return R.failed("记录ID不存在")
count = repo.batch_delete(ids)
if count != len(id_list):
return R.failed("记录不存在")
return R.ok(msg="本次共删除{0}条数据".format(count))
```
:::tip 复用规则
自定义 Service 的删除方法应复用 `BaseService.batch_delete_with_r(repo, ids)`,保证删除响应文案全仓一致。禁止直接调用 `repo.batch_delete` 后自行封装 R。
:::
## 同步/异步约定
| Service 方法 | 同步/异步 | 原因 |
|-------------|---------|------|
| `get_page` / `get_detail` / `add` / `update` / `delete` | 同步 `def` | 纯 DB 操作 |
| `batch_delete` | 异步 `async def` | 需 `await parse_batch_ids(request)` |
| 含 Redis 缓存的方法 | 异步 `async def` | Redis IO 需 await |
:::warning 禁止混用
- 同步 `def` Service 中禁止直接调用异步方法
- 异步 `async def` Endpoint 中禁止直接调用同步 DB 查询(用 `asyncio.to_thread` 包裹)
:::
## serialize_maps 枚举映射
```python
class ArticleService(BaseService[Article]):
# 字典编码方式(推荐)
serialize_maps = {
'source': 'article_source', # 从数据字典取 {value: label}
}
# 字面字典方式(历史兼容)
serialize_maps = {
'status': {1: '在用', 2: '停用'},
}
```
:::tip 序列化结果
`serialize_maps` 中的字段会在序列化时自动补一个 `{field}Text` 字段。例如 `source=1` 会生成 `sourceText="原创"`。
:::
## 总结
Service 层通过继承 `BaseService[Model]` 获得通用 CRUD 能力。子类声明差异点(repo/model/page_like_fields/page_eq_fields/page_order_by/unique_fields/serialize_maps),覆盖钩子(_apply_page_filters/_before_delete/_serialize 等)定制业务行为。删除操作统一复用 `batch_delete_with_r` 静态方法。模块级单例模式供 Endpoint 调用。
@@ -0,0 +1,404 @@
# 测试规范
## 概述
项目采用三层测试体系:契约快照测试、单元测试、集成测试。测试使用 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 modules.xxx import ...` 和 `from 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
"""权限契约快照:守护全部 @permission_required 权限串不变。"""
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():
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
from core.app import create_app
app = create_app()
spec = app.openapi()
return sorted(
f"{method.upper()} {path}"
for path, methods in spec["paths"].items()
for method in methods
)
def collect_permissions():
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
import glob
import re
perms = set()
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
for root in ("src/api/v1/endpoints", "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 asyncio
import json
from modules.upload import service as upload_service
from modules.upload.service import _save_upload, upload_file
class _FakeUpload:
"""模拟异步分块读取的 UploadFile。"""
def __init__(self, filename, data=b''):
self.filename = filename
self._data = data
async 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 = asyncio.run(upload_file(_FakeUpload('logo.svg', b'<svg/>')))
body = json.loads(result.body)
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.body)
assert body['ok'] is False
assert 'SVG' in body['msg']
```
### 畸形输入防御测试
```python
# tests/unit/utils/test_ip2region.py
from 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
```
### 代理白名单测试
```python
# tests/unit/utils/test_ip_trusted_proxy.py
import types
from utils import ip as ip_mod
from utils.ip import get_client_ip
def _make_request(client_host, xff=None):
"""构造模拟 Request 对象。"""
req = types.SimpleNamespace()
req.client = types.SimpleNamespace(host=client_host)
req.headers = {'X-Forwarded-For': xff} if xff else {}
return req
def test_proxy_count_zero_ignores_xff(monkeypatch):
"""TRUSTED_PROXY_COUNT=0:不信任代理头"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_not_in_whitelist_rejects_xff(monkeypatch):
"""直连对端不在白名单 → XFF 被忽略"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_in_whitelist_trusts_xff(monkeypatch):
"""单层代理:对端在白名单 → 信任 XFF"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4'
def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch):
"""客户端伪造条目只会在 XFF 左侧"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4'
```
### service 层公共 fixture
```python
# tests/unit/modules/conftest.py
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
import types
import pytest
@pytest.fixture
def fake_request():
"""模拟 FastAPI Request,仅提供 service 层用到的字段。"""
class FR:
state = types.SimpleNamespace(user_id=1, username="tester", realname="测试员")
query_params = {}
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()['data']['total'] > 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%+
- Endpoint 层:由契约测试保障路由正确性
:::
## 总结
测试规范采用三层体系:契约快照测试保障路由/权限不变、单元测试验证单函数逻辑、集成测试验证模块协作。测试文件命名 `test_*.py`,函数命名 `test_*`。pytest 标记区分测试类型,默认跳过集成测试。契约快照通过 `UPDATE_GOLDEN=1` 环境变量更新基线。