签入版本
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
:::tip 本章概要
|
||||
登录 → Token 获取 → 权限校验 → Token 刷新 → 退出登录的完整认证链路实战说明。
|
||||
:::
|
||||
|
||||
# 认证流程实战
|
||||
|
||||
本章将登录 → Token 获取 → 权限校验 → Token 刷新 → 退出登录的完整认证链路串联说明,帮助开发者理解端到端的认证机制。
|
||||
|
||||
## 认证架构总览
|
||||
|
||||
```
|
||||
┌──────────┐ ①获取验证码 ┌──────────┐
|
||||
│ │ ──────────────────> │ │
|
||||
│ 前端 │ │ 后端 │
|
||||
│ Vue3 │ ②登录(账号+密码+验证码) │ FastAPI │
|
||||
│ │ ──────────────────> │ │
|
||||
│ │ ③返回 Token │ │
|
||||
│ │ <────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ ④携带 Token 请求 │ │
|
||||
│ │ ──────────────────> │ │
|
||||
│ │ ⑤返回业务数据 │ │
|
||||
│ │ <────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ ⑥刷新 Token │ │
|
||||
│ │ ──────────────────> │ │
|
||||
│ │ ⑦返回新 Token │ │
|
||||
│ │ <────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ ⑧退出登录 │ │
|
||||
│ │ ──────────────────> │ │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 第 1 步:获取验证码
|
||||
|
||||
登录前需先获取图形验证码,防止暴力破解。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/captcha
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"captcha": "data:image/png;base64,iVBORw0KGgo...",
|
||||
"key": "captcha:a3f8b2c1d4e5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `captcha`:Base64 编码的验证码图片,前端直接赋值给 `<img>` 的 `src`
|
||||
- `key`:验证码唯一标识,登录时需回传
|
||||
|
||||
### 前端代码示例
|
||||
|
||||
```typescript
|
||||
// 获取验证码
|
||||
const { data } = await getCaptchaApi();
|
||||
captchaImg.value = data.captcha;
|
||||
captchaKey.value = data.key;
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
```python
|
||||
# src/api/v1/endpoints/auth.py
|
||||
@router.get('/captcha')
|
||||
async def captcha(request: Request):
|
||||
result = await generate_captcha(request)
|
||||
id_key = result['idKey']
|
||||
data = result['data']
|
||||
img_str = "data:image/png;base64," + data
|
||||
return R.ok({"captcha": img_str, "key": id_key})
|
||||
```
|
||||
|
||||
验证码通过 Redis 存储,Key 格式为 `captcha:{uuid}`,默认有效期 300 秒。
|
||||
|
||||
## 第 2 步:用户登录
|
||||
|
||||
携带账号、密码、验证码和 Key 发起登录请求。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
POST /api/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"code": "a3Kp",
|
||||
"key": "captcha:a3f8b2c1d4e5"
|
||||
}
|
||||
```
|
||||
|
||||
### 登录表单字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|:----|:----|:----|:----|
|
||||
| `username` | str | 是 | 登录账号(1-20 字符) |
|
||||
| `password` | str | 是 | 登录密码(6-128 字符) |
|
||||
| `code` | str | 是 | 验证码(长度由 CAPTCHA_LENGTH 配置) |
|
||||
| `key` | str | 是 | 验证码 Key |
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"must_change_password": false
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `access_token`:JWT 令牌,后续请求需携带
|
||||
- `must_change_password`:是否需要修改密码(检测到默认密码时为 `true`)
|
||||
|
||||
### 失败响应
|
||||
|
||||
```json
|
||||
// 验证码错误
|
||||
{"code": 1, "msg": "验证码不正确", "data": null, "ok": false}
|
||||
|
||||
// 账号或密码错误
|
||||
{"code": 1, "msg": "用户名或密码不正确", "data": null, "ok": false}
|
||||
|
||||
// 账号被锁定
|
||||
{"code": 1, "msg": "登录失败次数过多,请280秒后再试", "data": null, "ok": false}
|
||||
```
|
||||
|
||||
### 登录流程详解
|
||||
|
||||
```
|
||||
1. 检查是否被锁定(IP+用户名维度)
|
||||
└─ 锁定中 → 返回剩余等待秒数
|
||||
|
||||
2. 验证码校验(Lua 原子消费,防重放)
|
||||
└─ 不正确 → 记录失败次数
|
||||
|
||||
3. 查询用户(同步 DB 查询放入线程池)
|
||||
└─ 不存在 → 记录失败次数
|
||||
|
||||
4. 比对密码(bcrypt CPU 密集,放入线程池)
|
||||
└─ 不匹配 → 记录失败次数
|
||||
|
||||
5. 登录成功
|
||||
├─ 清除失败记录
|
||||
├─ 检测是否为默认密码
|
||||
├─ 生成 JWT Token
|
||||
└─ 返回 Token
|
||||
```
|
||||
|
||||
### 防暴力破解机制
|
||||
|
||||
| 维度 | 策略 | 配置项 |
|
||||
|:----|:----|:----|
|
||||
| 失败计数 | IP+用户名维度,Redis 原子递增 | `LOGIN_FAIL_MAX=5` |
|
||||
| 锁定机制 | 达到上限后锁定 | `LOGIN_LOCK_SECONDS=300` |
|
||||
| 验证码防刷 | 窗口期内限制获取次数 | `CAPTCHA_RATE_LIMIT=10` |
|
||||
| 验证码消费 | Lua 原子消费,防重放 | - |
|
||||
|
||||
## 第 3 步:携带 Token 请求业务接口
|
||||
|
||||
登录成功后,前端将 Token 存入 localStorage,后续所有请求自动携带。
|
||||
|
||||
### 请求头格式
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
```
|
||||
|
||||
### 前端自动携带
|
||||
|
||||
```typescript
|
||||
// 请求拦截器(自动添加 Token)
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### Token 解析流程
|
||||
|
||||
```
|
||||
请求进入 → login_required 依赖
|
||||
├─ 提取 Authorization 头
|
||||
├─ 解析 JWT Token
|
||||
│ ├─ 验证签名(HS256 + JWT_SALT)
|
||||
│ ├─ 验证过期时间
|
||||
│ └─ 检查黑名单(Redis)
|
||||
├─ 注入 request.state
|
||||
│ ├─ user_id
|
||||
│ ├─ username
|
||||
│ └─ realname
|
||||
└─ 业务代码通过 get_user_id(request) 获取用户信息
|
||||
```
|
||||
|
||||
## 第 4 步:权限校验
|
||||
|
||||
每个业务接口通过 `@permission_required` 装饰器校验权限。
|
||||
|
||||
### 权限校验流程
|
||||
|
||||
```
|
||||
请求到达 → permission_required("sys:user:add")
|
||||
├─ 用户 ID = 1(admin)→ 跳过权限校验,直接放行
|
||||
└─ 其他用户
|
||||
├─ 查询用户角色
|
||||
├─ 查询角色权限列表
|
||||
├─ 匹配权限字符串
|
||||
│ ├─ 匹配 → 放行
|
||||
│ └─ 不匹配 → 返回 "权限不足"
|
||||
└─ 结果缓存(Redis,菜单变更时失效)
|
||||
```
|
||||
|
||||
### 权限字符串格式
|
||||
|
||||
```
|
||||
sys:{module}:{action}
|
||||
```
|
||||
|
||||
示例:`sys:user:add`、`sys:role:page`、`sys:article:delete`
|
||||
|
||||
## 第 5 步:Token 刷新
|
||||
|
||||
Token 支持无感刷新,刷新时执行令牌轮换(旧 Token 立即失效)。
|
||||
|
||||
### 刷新请求
|
||||
|
||||
```
|
||||
POST /api/v1/refreshToken
|
||||
Authorization: Bearer <old_token>
|
||||
```
|
||||
|
||||
### 刷新流程
|
||||
|
||||
```
|
||||
1. 验证原令牌有效性
|
||||
2. 检查是否已注销(黑名单)
|
||||
3. 移除 JWT 标准字段(exp/iat/nbf),保留业务数据
|
||||
4. 生成新令牌
|
||||
5. 旧令牌加入黑名单
|
||||
6. 返回新令牌
|
||||
```
|
||||
|
||||
### 前端无感刷新
|
||||
|
||||
```typescript
|
||||
// 响应拦截器:检测 Token 过期
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
if (error.response?.status === 401) {
|
||||
// 尝试刷新 Token
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
// 用新 Token 重试原请求
|
||||
error.config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return axios(error.config);
|
||||
}
|
||||
// 刷新失败,跳转登录页
|
||||
router.push('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 第 6 步:退出登录
|
||||
|
||||
退出时将当前 Token 加入 Redis 黑名单,使其立即失效。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/v1/logout
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "注销成功",
|
||||
"data": null,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 退出流程
|
||||
|
||||
```
|
||||
1. 提取当前 Token
|
||||
2. 计算 Token 的 SHA256 指纹
|
||||
3. 存入 Redis 黑名单(TTL = Token 剩余有效期)
|
||||
4. 记录退出日志
|
||||
5. 返回成功
|
||||
```
|
||||
|
||||
### Token 黑名单机制
|
||||
|
||||
- **存储方式**:Redis,Key 格式 `token:blacklist:{sha256_fingerprint}`
|
||||
- **过期策略**:TTL 与 Token 剩余有效期一致,自动清理
|
||||
- **故障降级**:Redis 不可用时 fail-open 放行(避免全站不可用)
|
||||
|
||||
## 完整时序图
|
||||
|
||||
```
|
||||
前端 后端 Redis 数据库
|
||||
│ │ │ │
|
||||
│ ① GET /captcha │ │ │
|
||||
│ ────────────────────> │ 生成验证码 │ │
|
||||
│ │ ────────────────────> │ 存储 captcha key │
|
||||
│ 返回验证码图片+key │ <──────────────────── │ │
|
||||
│ <──────────────────── │ │ │
|
||||
│ │ │ │
|
||||
│ ② POST /login │ │ │
|
||||
│ (username+pwd+code) │ │ │
|
||||
│ ────────────────────> │ ① 检查锁定 │ │
|
||||
│ │ ────────────────────> │ 查询 lock key │
|
||||
│ │ <──────────────────── │ │
|
||||
│ │ ② 验证码校验 │ │
|
||||
│ │ ────────────────────> │ Lua 原子消费 │
|
||||
│ │ <──────────────────── │ │
|
||||
│ │ ③ 查询用户 │ │
|
||||
│ │ ─────────────────────────────────────────> │
|
||||
│ │ <───────────────────────────────────────── │
|
||||
│ │ ④ 比对密码 │ │
|
||||
│ │ ⑤ 生成 JWT Token │ │
|
||||
│ 返回 Token │ │ │
|
||||
│ <──────────────────── │ │ │
|
||||
│ │ │ │
|
||||
│ ③ GET /api/v1/user/page │ │
|
||||
│ Authorization: Bearer xxx │ │
|
||||
│ ────────────────────> │ 解析 Token │ │
|
||||
│ │ 检查黑名单 │ │
|
||||
│ │ ────────────────────> │ 查询 blacklist │
|
||||
│ │ <──────────────────── │ │
|
||||
│ │ 校验权限 │ │
|
||||
│ │ 查询业务数据 │ │
|
||||
│ │ ─────────────────────────────────────────> │
|
||||
│ 返回业务数据 │ <───────────────────────────────────────── │
|
||||
│ <──────────────────── │ │ │
|
||||
│ │ │ │
|
||||
│ ④ GET /logout │ │ │
|
||||
│ ────────────────────> │ Token 加入黑名单 │ │
|
||||
│ │ ────────────────────> │ SET blacklist │
|
||||
│ 返回注销成功 │ <──────────────────── │ │
|
||||
│ <──────────────────── │ │ │
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `JWT_SALT` | 无(必填) | JWT 签名密钥,至少 32 字节 |
|
||||
| `JWT_EXPIRE_MINUTES` | `20` | Token 过期时间(分钟) |
|
||||
| `LOGIN_FAIL_MAX` | `5` | 登录失败次数上限 |
|
||||
| `LOGIN_LOCK_SECONDS` | `300` | 登录锁定时间(秒) |
|
||||
| `CAPTCHA_LENGTH` | `6` | 验证码长度 |
|
||||
| `CAPTCHA_EXPIRE` | `300` | 验证码有效期(秒) |
|
||||
|
||||
## 总结
|
||||
|
||||
认证流程覆盖了从获取验证码到退出登录的完整链路,核心安全机制包括:
|
||||
- bcrypt 密码哈希 + 随机盐
|
||||
- JWT HS256 签名 + 过期校验
|
||||
- Redis 验证码防重放
|
||||
- IP+用户名维度的登录锁定
|
||||
- Token 黑名单即时失效
|
||||
- Redis 不可用时 fail-open 降级
|
||||
@@ -0,0 +1,173 @@
|
||||
:::tip 验证码
|
||||
验证码是防止自动化攻击的重要安全手段。使用 Pillow 生成图片验证码,存储在 Redis 中,支持防刷限流和万能验证码(演示模式)。源码位于 `src/utils/captcha.py`。
|
||||
:::
|
||||
|
||||
## 验证码流程
|
||||
|
||||
```
|
||||
1. 前端请求 GET /api/v1/captcha
|
||||
2. 后端生成随机字符 → 绘制图片(干扰线+噪点)→ Base64 编码
|
||||
3. 字符存入 Redis(key=UUID,TTL=300秒)
|
||||
4. 返回 {key, captcha} 给前端
|
||||
5. 前端展示图片,用户输入验证码
|
||||
6. 登录时后端从 Redis 取出比对,验证成功后自动删除(防重放)
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `CAPTCHA_LENGTH` | `6` | 验证码字符长度 |
|
||||
| `CAPTCHA_EXPIRE` | `300` | 过期时间(秒) |
|
||||
| `CAPTCHA_RATE_WINDOW` | `60` | 防刷窗口期(秒) |
|
||||
| `CAPTCHA_RATE_LIMIT` | `10` | 窗口期内最大获取次数 |
|
||||
| `CAPTCHA_CHARS` | `ABCDEFGHJKLMNPQRSTUVWXYZ2346789` | 字符集(去除易混淆字符) |
|
||||
| `CAPTCHA_FONT_PATH` | `static/fonts/Vera.ttf` | 字体路径 |
|
||||
| `CAPTCHA_IMAGE_WIDTH` | `160` | 图片宽度 |
|
||||
| `CAPTCHA_IMAGE_HEIGHT` | `50` | 图片高度 |
|
||||
| `CAPTCHA_FONT_SIZE` | `28` | 字体大小 |
|
||||
| `CAPTCHA_NOISE_LINES` | `6` | 干扰线数量 |
|
||||
| `CAPTCHA_NOISE_DOTS` | `30` | 噪点数量 |
|
||||
| `CAPTCHA_BYPASS_CODE` | `618618` | 万能验证码(仅演示模式生效) |
|
||||
|
||||
## 接口说明
|
||||
|
||||
### 获取验证码
|
||||
|
||||
```
|
||||
GET /api/v1/captcha
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"key": "54e66743-0617-46fd-9b7e-cc1044ac1ccd",
|
||||
"captcha": "data:image/png;base64,iVBORw0KGgo..."
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 验证码校验
|
||||
|
||||
验证码校验在登录流程中自动完成,核心逻辑在 `src/utils/captcha.py`:
|
||||
|
||||
```python
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
# 验证成功后自动删除,防止重放攻击
|
||||
is_valid = await verify_captcha(key, code)
|
||||
```
|
||||
|
||||
验证码校验采用 Lua 脚本实现原子消费,防止并发重放和暴力破解:
|
||||
|
||||
```python
|
||||
# Lua 脚本:Redis 单线程原子执行 get -> del -> compare
|
||||
_CAPTCHA_VERIFY_LUA = """
|
||||
local stored = redis.call('get', KEYS[1])
|
||||
if not stored then return 0 end
|
||||
-- 消费验证码:一次性使用,失败也立即失效(防暴力破解/无限试错)
|
||||
redis.call('del', KEYS[1])
|
||||
local function norm(s)
|
||||
return string.upper(tostring(s)):gsub('^%s+', ''):gsub('%s+$', '')
|
||||
end
|
||||
if norm(stored) == norm(ARGV[1]) then return 1 end
|
||||
return 0
|
||||
"""
|
||||
|
||||
async def verify_captcha(id_key: str, user_input: str) -> bool:
|
||||
"""验证验证码(原子消费:验证码无论对错一次性使用)"""
|
||||
if not id_key or not user_input:
|
||||
return False
|
||||
redis = get_redis()
|
||||
|
||||
# 演示模式万能验证码
|
||||
if FASTAPI_DEBUG and CAPTCHA_BYPASS_CODE and user_input == CAPTCHA_BYPASS_CODE:
|
||||
return True
|
||||
|
||||
return bool(await redis.eval(_CAPTCHA_VERIFY_LUA, 1, id_key, user_input))
|
||||
```
|
||||
|
||||
:::warning 原子消费
|
||||
验证码无论对错都会被删除(Lua 脚本内 get + del + compare 原子执行),同一 idKey 无法再次尝试。这既防止了重放攻击,也防止了暴力穷举。
|
||||
:::
|
||||
|
||||
## 防刷机制
|
||||
|
||||
验证码获取接口内置限流保护,使用 `incr_with_expire` Lua 脚本实现原子计数:
|
||||
|
||||
```python
|
||||
async def generate_captcha(request):
|
||||
redis = get_redis()
|
||||
|
||||
# 防刷:按 IP 维度限流
|
||||
client_ip = get_client_ip(request)
|
||||
rate_key = f"captcha:rate:{client_ip}"
|
||||
count = await redis.get(rate_key)
|
||||
|
||||
if count and int(count) >= CAPTCHA_RATE_LIMIT:
|
||||
ttl = await redis.ttl(rate_key)
|
||||
remaining = ttl if ttl and ttl > 0 else CAPTCHA_RATE_WINDOW
|
||||
raise BusinessException(msg=f"请求过于频繁,请等待 {remaining} 秒后再试")
|
||||
|
||||
# 生成验证码 ...
|
||||
image, code = _generate_image(CAPTCHA_LENGTH)
|
||||
|
||||
# 存储到 Redis
|
||||
id_key = secrets.token_urlsafe(24)
|
||||
await redis.set(id_key, code, ex=CAPTCHA_EXPIRE)
|
||||
|
||||
# 原子递增并设置过期(Lua 脚本,防止 incr 与 expire 之间崩溃导致 key 永久残留)
|
||||
from core.redis_client import incr_with_expire
|
||||
await incr_with_expire(redis, rate_key, CAPTCHA_RATE_WINDOW)
|
||||
```
|
||||
|
||||
限流参数:
|
||||
- 按 IP 维度限流
|
||||
- 窗口期内(默认60秒)最多获取10次
|
||||
- 超限后返回 `BusinessException` 提示剩余等待秒数
|
||||
- 防止恶意刷取验证码消耗服务器资源
|
||||
|
||||
## 万能验证码
|
||||
|
||||
在演示模式(`FASTAPI_DEMO=True`)下,万能验证码 `618618` 可绕过正常验证码校验,方便演示环境登录。
|
||||
|
||||
:::warning 安全提示
|
||||
生产环境务必确保 `FASTAPI_DEMO=False`,否则万能验证码将失效。
|
||||
:::
|
||||
|
||||
## 前端集成
|
||||
|
||||
```javascript
|
||||
// 获取验证码
|
||||
async function getCaptcha() {
|
||||
const res = await axios.get('/api/v1/captcha')
|
||||
captchaKey.value = res.data.data.key
|
||||
captchaImg.value = res.data.data.captcha
|
||||
}
|
||||
|
||||
// 登录时提交
|
||||
const loginData = {
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
code: userInput.value,
|
||||
key: captchaKey.value
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
的验证码方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 图片验证码:Pillow 生成,支持干扰线、噪点、自定义字体
|
||||
2. Redis 存储:TTL 自动过期,Lua 原子消费防重放
|
||||
3. 防刷限流:IP 维度 + incr_with_expire 原子计数,防止恶意刷取
|
||||
4. 万能验证码:演示模式下可用,生产环境自动禁用
|
||||
5. 字符集优化:去除易混淆字符(0/O、1/I/L 等)
|
||||
6. 安全随机数:使用 secrets 模块生成验证码和唯一 ID
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
:::tip 系统配置
|
||||
系统配置模块用于管理动态的键值对配置项,与数据字典类似但更偏向系统级参数。支持分组管理,可通过接口动态修改,无需重启服务。模块位于 `src/modules/configuration/`,端点位于 `src/api/v1/endpoints/config.py` 和 `config_item.py`。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/configuration/
|
||||
├── config/ # 配置分组
|
||||
│ ├── models.py
|
||||
│ ├── schemas.py
|
||||
│ ├── repository.py
|
||||
│ └── service.py
|
||||
└── config_item/ # 配置项(KV)
|
||||
├── models.py
|
||||
├── schemas.py
|
||||
├── repository.py
|
||||
└── service.py
|
||||
```
|
||||
|
||||
## 配置模型
|
||||
|
||||
### 配置分组(Config)
|
||||
|
||||
```python
|
||||
# src/modules/configuration/config/models.py
|
||||
# ============================================================
|
||||
# 配置模型
|
||||
# ============================================================
|
||||
class Config(base_model, base_db):
|
||||
"""配置模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__tablename__ = DB_PREFIX + "config"
|
||||
__table_comment__ = "系统配置表"
|
||||
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 配置名称
|
||||
name = Column(String(150), nullable=False, index=True, comment="配置名称")
|
||||
# 配置编码
|
||||
code = Column(String(150), nullable=False, index=True, comment="配置编码")
|
||||
# 配置排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="配置排序")
|
||||
# 配置备注
|
||||
note = Column(String(255), nullable=True, comment="配置备注")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
# ============================================================
|
||||
def __str__(self):
|
||||
"""返回配置ID作为字符串表示"""
|
||||
return "配置{}".format(self.id)
|
||||
```
|
||||
|
||||
### 配置项(ConfigItem)
|
||||
|
||||
```python
|
||||
# src/modules/configuration/config_item/models.py
|
||||
# ============================================================
|
||||
# 配置项模型
|
||||
# ============================================================
|
||||
class ConfigItem(base_model, base_db):
|
||||
"""配置项模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__tablename__ = DB_PREFIX + "config_item"
|
||||
__table_comment__ = "配置项表"
|
||||
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 配置项名称
|
||||
name = Column(String(150), nullable=False, index=True, comment="配置项名称")
|
||||
# 配置项编码
|
||||
code = Column(String(150), nullable=False, comment="配置项编码")
|
||||
# 配置项值
|
||||
value = Column(String(1000), nullable=True, comment="配置项值")
|
||||
# 配置选项
|
||||
options = Column(Text, nullable=True, comment="配置选项")
|
||||
# 配置ID
|
||||
config_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="配置ID")
|
||||
# 配置类型
|
||||
type = Column(String(150), nullable=False, comment="配置类型")
|
||||
# 配置状态:1-正常 2-停用
|
||||
status = Column(Integer, default=1, server_default=text('1'), index=True, comment="配置状态:1-正常 2-停用")
|
||||
# 配置项排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="配置项顺序")
|
||||
# 配置项备注
|
||||
note = Column(String(255), nullable=True, comment="配置项备注")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
# ============================================================
|
||||
def __str__(self):
|
||||
"""返回配置项ID作为字符串表示"""
|
||||
return "配置项{}".format(self.id)
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
系统配置适用于以下场景:
|
||||
|
||||
```
|
||||
1. 网站基本信息(站点名称、Logo、版权信息)
|
||||
2. 邮件配置(SMTP 服务器、端口、账号)
|
||||
3. 短信配置(API Key、模板 ID)
|
||||
4. 存储配置(OSS Bucket、上传限制)
|
||||
5. 业务参数(默认密码、分页大小等)
|
||||
```
|
||||
|
||||
## 接口说明
|
||||
|
||||
### 获取配置项列表
|
||||
|
||||
```
|
||||
GET /api/v1/config/item/list?configId=1
|
||||
```
|
||||
|
||||
### 获取配置项详情
|
||||
|
||||
```
|
||||
GET /api/v1/config/item/detail/{id}
|
||||
```
|
||||
|
||||
### 新增/编辑配置项
|
||||
|
||||
```
|
||||
POST /api/v1/config/item/add
|
||||
PUT /api/v1/config/item/update
|
||||
```
|
||||
|
||||
## 与数据字典的区别
|
||||
|
||||
| 特性 | 数据字典 | 系统配置 |
|
||||
|------|---------|---------|
|
||||
| 用途 | 枚举值(下拉选项) | 系统参数(配置值) |
|
||||
| 值类型 | 简单值(字符串) | 可存储较长的配置内容 |
|
||||
| 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 |
|
||||
| 前端用法 | el-select 选项 | 配置表单 |
|
||||
|
||||
## 总结
|
||||
|
||||
系统配置模块具备以下特点:
|
||||
|
||||
```
|
||||
1. KV 结构:配置分组 + 配置项,灵活管理各类参数
|
||||
2. 动态修改:通过接口修改,无需重启服务
|
||||
3. 分组管理:按功能模块分组,清晰组织配置项
|
||||
4. 与字典互补:字典管理枚举值,配置管理系统参数
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
:::tip CORS跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。中间件位于 `src/middleware/cors.py`。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) |
|
||||
| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 |
|
||||
| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) |
|
||||
|
||||
## 配置示例
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 允许所有源(开发环境)
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# 允许指定源(生产环境)
|
||||
CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com
|
||||
|
||||
# 信任一层反向代理(Nginx)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
:::warning 生产环境
|
||||
生产环境务必配置 `CORS_ALLOWED_ORIGINS` 为具体域名,避免允许所有源带来的安全风险。
|
||||
:::
|
||||
|
||||
## CORS 中间件注册
|
||||
|
||||
```python
|
||||
# src/middleware/cors.py
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from core.config.app import CORS_ALLOWED_ORIGINS
|
||||
|
||||
def register_cors(app):
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
origins = [o.strip() for o in CORS_ALLOWED_ORIGINS.split(',') if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
else:
|
||||
# 未配置时允许所有源但不携带凭证
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
## 中间件顺序
|
||||
|
||||
CORS 中间件在 `core/app.py` 中注册为最外层:
|
||||
|
||||
```python
|
||||
# 最后注册→最外层:确保限流等直接返回的响应也带 CORS 头
|
||||
register_cors(app)
|
||||
```
|
||||
|
||||
中间件执行顺序(从外到内):
|
||||
|
||||
```
|
||||
CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑
|
||||
```
|
||||
|
||||
## 代理头处理
|
||||
|
||||
当应用部署在 Nginx 等反向代理后,需要配置代理信任:
|
||||
|
||||
```bash
|
||||
# 信任一层代理
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
`TRUSTED_PROXY_COUNT` 说明:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `0` | 不信任 X-Forwarded-For,使用 request.client.host |
|
||||
| `1` | 信任一层反向代理 |
|
||||
| `2` | 信任两层代理 |
|
||||
|
||||
:::tip 安全提示
|
||||
仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
CORS 跨域配置具备以下特点:
|
||||
|
||||
```
|
||||
1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源
|
||||
2. 开发/生产分离:开发环境允许所有源,生产环境指定域名
|
||||
3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数
|
||||
4. 安全默认:未配置时允许所有源但不携带凭证
|
||||
5. 最外层注册:确保所有响应都带 CORS 头
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
:::tip 演示模式
|
||||
演示模式用于在线体验环境,开启后所有写操作(新增、编辑、删除等)将被拦截,返回"演示环境,暂无操作权限"提示。通过 `FASTAPI_DEMO` 环境变量控制。装饰器位于 `src/core/access_decorators.py`。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `FASTAPI_DEMO` | `False` | 是否开启演示模式 |
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 开启演示模式
|
||||
FASTAPI_DEMO=True
|
||||
|
||||
# 关闭演示模式(生产环境)
|
||||
FASTAPI_DEMO=False
|
||||
```
|
||||
|
||||
## check_demo 装饰器
|
||||
|
||||
`src/core/access_decorators.py` 中的 `@check_demo` 装饰器用于拦截写操作:
|
||||
|
||||
```python
|
||||
from core.access_decorators import check_demo
|
||||
|
||||
@router.post('/add')
|
||||
@permission_required("sys:level:add")
|
||||
@check_demo
|
||||
async def add(request: Request, data: LevelForm):
|
||||
return await level.LevelAdd(request, data)
|
||||
```
|
||||
|
||||
装饰器实现:
|
||||
|
||||
```python
|
||||
def check_demo(func):
|
||||
"""演示模式拦截装饰器,放在 @permission_required 之后、路由函数之前"""
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
if FASTAPI_DEMO:
|
||||
return R.failed("演示环境,暂无操作权限")
|
||||
return await func(*args, **kwargs)
|
||||
return async_wrapper
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
if FASTAPI_DEMO:
|
||||
return R.failed("演示环境,暂无操作权限")
|
||||
return func(*args, **kwargs)
|
||||
return sync_wrapper
|
||||
```
|
||||
|
||||
## 装饰器顺序
|
||||
|
||||
`@check_demo` 必须放在 `@permission_required` 之后:
|
||||
|
||||
```python
|
||||
# 正确顺序
|
||||
@router.post('/add')
|
||||
@permission_required("sys:level:add") # 先校验权限
|
||||
@check_demo # 再检查演示模式
|
||||
async def add(request: Request, data: LevelForm):
|
||||
...
|
||||
```
|
||||
|
||||
:::warning 顺序重要
|
||||
如果 `@check_demo` 放在 `@permission_required` 之前,权限校验会被跳过,可能导致安全问题。
|
||||
:::
|
||||
|
||||
## 万能验证码
|
||||
|
||||
演示模式下万能验证码 `618618` 可绕过正常验证码校验,方便演示环境登录:
|
||||
|
||||
```python
|
||||
# src/utils/captcha.py
|
||||
async def verify_captcha(key: str, code: str) -> bool:
|
||||
# 演示模式下检查万能验证码
|
||||
if FASTAPI_DEMO and code == CAPTCHA_BYPASS_CODE:
|
||||
return True
|
||||
# 正常校验逻辑...
|
||||
```
|
||||
|
||||
## 适用场景
|
||||
|
||||
```
|
||||
1. 在线体验:让用户无需注册即可体验系统功能
|
||||
2. 演示环境:销售或产品演示时防止误操作
|
||||
3. 公开测试:开放测试环境但保护数据安全
|
||||
```
|
||||
|
||||
## 受保护的操作
|
||||
|
||||
演示模式拦截以下操作(带 `@check_demo` 装饰器的端点):
|
||||
|
||||
```
|
||||
- 新增(add)
|
||||
- 编辑(update)
|
||||
- 删除(delete / batchDelete)
|
||||
- 状态设置(status)
|
||||
- 重置密码(resetPwd)
|
||||
- 导入(import)
|
||||
- 授权(auth)
|
||||
- 生成代码(generate)
|
||||
```
|
||||
|
||||
不受影响的操作:
|
||||
|
||||
```
|
||||
- 登录(login)
|
||||
- 获取验证码(captcha)
|
||||
- 查询列表(list)
|
||||
- 查询详情(detail)
|
||||
- 导出(export)
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
演示模式具备以下特点:
|
||||
|
||||
```
|
||||
1. 环境变量控制:FASTAPI_DEMO=True 开启,False 关闭
|
||||
2. 写操作拦截:@check_demo 装饰器拦截所有写操作
|
||||
3. 万能验证码:演示模式下可用,方便登录体验
|
||||
4. 安全顺序:必须放在 @permission_required 之后
|
||||
5. 只读体验:查询操作不受影响,用户可正常浏览数据
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
:::tip 本章概要
|
||||
数据字典的完整使用流程,从新增字典类型到在代码中使用字典数据的实操指南。
|
||||
:::
|
||||
|
||||
# 数据字典使用指南
|
||||
|
||||
数据字典是项目的基础配置功能,用于管理枚举类型的下拉选项。本章从「如何新增字典」到「如何在代码中使用」的完整实操流程。
|
||||
|
||||
## 数据字典结构
|
||||
|
||||
数据字典由两层组成:
|
||||
|
||||
```
|
||||
字典类型(dict) 字典项(dict_item)
|
||||
├── sys_user_status ├── 1 → 正常
|
||||
├── sys_user_gender ├── 2 → 停用
|
||||
├── article_type └── 3 → 删除
|
||||
└── ...
|
||||
```
|
||||
|
||||
| 层级 | 表 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| 字典类型 | `fastapi_dict` | 字典分类(如「用户状态」「文章类型」) |
|
||||
| 字典项 | `fastapi_dict_item` | 字典的具体选项(如「1-正常」「2-停用」) |
|
||||
|
||||
## 实操:新增一个字典类型
|
||||
|
||||
### 场景
|
||||
|
||||
需要为「培训方式」模块添加一个下拉选项:`1-线上 2-线下 3-混合`。
|
||||
|
||||
### 第 1 步:在后台添加字典类型
|
||||
|
||||
登录管理后台,进入「系统管理 → 数据字典」:
|
||||
|
||||
1. 点击「新增」按钮
|
||||
2. 填写信息:
|
||||
|
||||
| 字段 | 值 |
|
||||
|:----|:----|
|
||||
| 字典名称 | 培训方式 |
|
||||
| 字典编码 | training_method |
|
||||
| 状态 | 正常 |
|
||||
|
||||
3. 保存
|
||||
|
||||
### 第 2 步:添加字典项
|
||||
|
||||
在字典类型列表中点击「培训方式」,进入字典项管理:
|
||||
|
||||
1. 添加字典项:
|
||||
|
||||
| 字典值 | 字典标签 | 排序 |
|
||||
|:----|:----|:----|
|
||||
| 1 | 线上 | 1 |
|
||||
| 2 | 线下 | 2 |
|
||||
| 3 | 混合 | 3 |
|
||||
|
||||
2. 保存
|
||||
|
||||
### 第 3 步:在模型中关联字典
|
||||
|
||||
在模块的 `service.py` 中配置 `serialize_maps`:
|
||||
|
||||
```python
|
||||
class TrainingService(BaseService[Training]):
|
||||
# ... 其他配置 ...
|
||||
|
||||
# 枚举显示名映射:字段名 → 字典编码
|
||||
serialize_maps = {
|
||||
'training_method': 'training_method',
|
||||
'status': 'training_status',
|
||||
}
|
||||
```
|
||||
|
||||
配置后,列表和详情接口会自动将 `training_method=1` 转换为 `training_method_name='线上'`。
|
||||
|
||||
## 在后端代码中使用
|
||||
|
||||
### 方式 1:通过 serialize_maps 自动转换(推荐)
|
||||
|
||||
在 `service.py` 中配置 `serialize_maps`,基类自动处理:
|
||||
|
||||
```python
|
||||
class TrainingService(BaseService[Training]):
|
||||
serialize_maps = {'training_method': 'training_method'}
|
||||
```
|
||||
|
||||
效果:列表/详情返回数据中自动添加 `trainingMethod` 和 `trainingMethodName` 字段。
|
||||
|
||||
### 方式 2:手动获取字典值
|
||||
|
||||
```python
|
||||
from modules.dictionary.dict_item.repository import dict_item_repo
|
||||
|
||||
# 获取字典项列表
|
||||
items = dict_item_repo.get_all(dict_code='training_method', status=1)
|
||||
|
||||
# 转为 {value: label} 字典
|
||||
options = {item.value: item.label for item in items}
|
||||
# 结果:{'1': '线上', '2': '线下', '3': '混合'}
|
||||
```
|
||||
|
||||
### 方式 3:通过参数模块获取(适合系统配置)
|
||||
|
||||
```python
|
||||
from modules.param.service import param_service
|
||||
|
||||
# 获取参数值
|
||||
timeout = param_service.get_value_by_code('REQUEST_TIMEOUT', '30')
|
||||
```
|
||||
|
||||
## 在前端代码中使用
|
||||
|
||||
### 方式 1:字典下拉组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-select v-model="form.trainingMethod" placeholder="请选择培训方式">
|
||||
<el-option
|
||||
v-for="item in dictOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getDictItems } from '@/api/system/dictionary';
|
||||
|
||||
const dictOptions = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getDictItems('training_method');
|
||||
dictOptions.value = data;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 方式 2:表格列显示字典文本
|
||||
|
||||
在 `columns.ts` 中使用 `render` 函数:
|
||||
|
||||
```typescript
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
label: '培训方式',
|
||||
prop: 'trainingMethod',
|
||||
render(record) {
|
||||
const map = { 1: '线上', 2: '线下', 3: '混合' };
|
||||
const typeMap = { 1: 'success', 2: 'warning', 3: 'info' };
|
||||
return h(ElTag, { type: typeMap[record.row.trainingMethod] }, {
|
||||
default: () => map[record.row.trainingMethod] || '-'
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 方式 3:搜索表单下拉
|
||||
|
||||
在 `querySchemas.ts` 中配置:
|
||||
|
||||
```typescript
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'trainingMethod',
|
||||
component: 'Select',
|
||||
label: '培训方式',
|
||||
componentProps: {
|
||||
placeholder: '请选择培训方式',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '线上', value: '1' },
|
||||
{ label: '线下', value: '2' },
|
||||
{ label: '混合', value: '3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 代码生成器与字典
|
||||
|
||||
代码生成器会自动识别字段注释中的枚举格式(`1-线上 2-线下 3-混合`),并:
|
||||
|
||||
1. 自动解析 `choices` 列表
|
||||
2. 自动生成 `dict_code`(格式:`{table_name}_{field_name}`)
|
||||
3. 自动配置 `serialize_maps`
|
||||
4. 前端自动生成下拉选择框
|
||||
|
||||
生成后需在后台手动创建对应的字典类型和字典项。
|
||||
|
||||
## 字典缓存
|
||||
|
||||
项目使用进程内缓存 + Redis 二级缓存存储字典数据:
|
||||
|
||||
| 层级 | 说明 | 失效策略 |
|
||||
|:----|:----|:----|
|
||||
| L1 进程内缓存 | Python 字典,TTL 60 秒 | 自动过期 |
|
||||
| L2 Redis 缓存 | Redis 字符串,TTL 120 秒 | 自动过期 + 主动失效 |
|
||||
|
||||
字典数据修改时自动清除相关缓存。
|
||||
|
||||
## 字典管理 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| GET | `/api/v1/dict/page` | 字典类型分页 |
|
||||
| POST | `/api/v1/dict/add` | 新增字典类型 |
|
||||
| PUT | `/api/v1/dict/update` | 更新字典类型 |
|
||||
| DELETE | `/api/v1/dict/delete/{id}` | 删除字典类型 |
|
||||
| GET | `/api/v1/dictItem/page` | 字典项分页 |
|
||||
| POST | `/api/v1/dictItem/add` | 新增字典项 |
|
||||
| PUT | `/api/v1/dictItem/update` | 更新字典项 |
|
||||
| DELETE | `/api/v1/dictItem/delete/{id}` | 删除字典项 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **字典编码命名**:使用 `模块_字段` 格式,如 `user_status`、`article_type`
|
||||
2. **字典值使用整数**:便于排序和比较,避免使用字符串
|
||||
3. **字典标签简洁**:控制在 4 个字以内,适合表格列显示
|
||||
4. **状态字段统一**:`1-正常 2-停用` 全项目保持一致
|
||||
5. **生成后补充字典**:代码生成器创建的 `dict_code` 需在后台手动创建对应数据
|
||||
|
||||
## 总结
|
||||
|
||||
数据字典通过「字典类型 + 字典项」两层结构管理枚举选项。后端通过 `serialize_maps` 自动转换,前端通过 API 获取下拉数据。代码生成器会自动识别注释中的枚举格式并生成对应配置。
|
||||
@@ -0,0 +1,202 @@
|
||||
:::tip 数据字典
|
||||
数据字典用于管理系统中的枚举值和配置项,如状态、类型、性别等。通过字典编码统一管理,前端可通过 `useDictStore` 获取字典选项,避免硬编码。后端读取工具位于 `src/utils/dict_util.py`,内置进程内 TTL 缓存。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/modules/dictionary/
|
||||
├── dict/ # 字典类型(分组)
|
||||
│ ├── models.py
|
||||
│ ├── schemas.py
|
||||
│ ├── repository.py
|
||||
│ └── service.py
|
||||
└── dict_item/ # 字典项(具体值)
|
||||
├── models.py
|
||||
├── schemas.py
|
||||
├── repository.py
|
||||
└── service.py
|
||||
```
|
||||
|
||||
## 字典模型
|
||||
|
||||
### 字典类型(Dict)
|
||||
|
||||
```python
|
||||
# src/modules/dictionary/dict/models.py
|
||||
# ============================================================
|
||||
# 字典模型
|
||||
# ============================================================
|
||||
class Dict(base_model, base_db):
|
||||
"""字典模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__tablename__ = DB_PREFIX + "dict"
|
||||
__table_comment__ = "数据字典表"
|
||||
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 字典名称
|
||||
name = Column(String(150), nullable=False, index=True, comment="字典名称")
|
||||
# 字典编码
|
||||
code = Column(String(150), nullable=False, index=True, comment="字典编码")
|
||||
# 字典排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="字典排序")
|
||||
# 字典备注
|
||||
note = Column(String(255), nullable=True, comment="字典备注")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
# ============================================================
|
||||
def __str__(self):
|
||||
"""返回字典ID作为字符串表示"""
|
||||
return "字典{}".format(self.id)
|
||||
```
|
||||
|
||||
### 字典项(DictItem)
|
||||
|
||||
```python
|
||||
# src/modules/dictionary/dict_item/models.py
|
||||
# ============================================================
|
||||
# 字典项模型
|
||||
# ============================================================
|
||||
class DictItem(base_model, base_db):
|
||||
"""字典项模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__tablename__ = DB_PREFIX + "dict_item"
|
||||
__table_comment__ = "字典项表"
|
||||
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 字典项名称
|
||||
name = Column(String(150), nullable=False, index=True, comment="字典项名称")
|
||||
# 字典项值
|
||||
value = Column(String(150), nullable=False, comment="字典项值")
|
||||
# 字典ID
|
||||
dict_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="字典ID")
|
||||
# 字典项排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="字典项顺序")
|
||||
# 字典项备注
|
||||
note = Column(String(255), nullable=True, comment="字典项备注")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
# ============================================================
|
||||
def __str__(self):
|
||||
"""返回字典项ID作为字符串表示"""
|
||||
return "字典项{}".format(self.id)
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 后端获取字典
|
||||
|
||||
```python
|
||||
# 通过字典编码获取字典项列表
|
||||
from modules.dictionary.dict_item import service as dict_item_service
|
||||
|
||||
# 获取状态字典
|
||||
status_list = await dict_item_service.get_by_dict_code("sys_status")
|
||||
# 返回: [{"label": "正常", "value": "1"}, {"label": "停用", "value": "2"}]
|
||||
```
|
||||
|
||||
### dict_util 缓存工具
|
||||
|
||||
项目提供 `dict_util` 工具(`src/utils/dict_util.py`),内置进程内 TTL 缓存。由于字典数据几乎不变,且读写均落在同步 service 路径(无法使用请求级异步 Redis),因此采用进程内缓存而非 Redis 缓存:
|
||||
|
||||
```python
|
||||
from utils.dict_util import get_dict_items, get_dict_map, get_dict_label
|
||||
|
||||
# 获取字典项列表(自动缓存,TTL 60秒兜底)
|
||||
items = get_dict_items("sys_status")
|
||||
# 返回: [{"label": "正常", "value": "1", "sort": 0}, {"label": "停用", "value": "2", "sort": 1}]
|
||||
|
||||
# 获取 {value: label} 映射
|
||||
status_map = get_dict_map("sys_status")
|
||||
# 返回: {"1": "正常", "2": "停用"}
|
||||
|
||||
# 单值翻译(value 自动转字符串匹配)
|
||||
label = get_dict_label("sys_status", 1, default="未知")
|
||||
# 返回: "正常"
|
||||
```
|
||||
|
||||
缓存实现细节:
|
||||
|
||||
```python
|
||||
# 进程内字典缓存:{code: (过期时间戳, 字典项列表)}
|
||||
_dict_cache: dict = {}
|
||||
_CACHE_TTL = 60 # 秒
|
||||
|
||||
def get_dict_items(code):
|
||||
"""按字典编码读取字典项列表,字典不存在返回空列表"""
|
||||
now = time.time()
|
||||
hit = _dict_cache.get(code)
|
||||
if hit and hit[0] > now:
|
||||
return hit[1]
|
||||
items = _load_dict_items(code)
|
||||
_dict_cache[code] = (now + _CACHE_TTL, items)
|
||||
return items
|
||||
|
||||
def invalidate_dict(code):
|
||||
"""清除指定字典编码的缓存(字典/字典项增删改时由 service 主动调用)"""
|
||||
if code:
|
||||
_dict_cache.pop(code, None)
|
||||
|
||||
def invalidate_dicts_by_ids(dict_ids):
|
||||
"""按字典ID批量清除缓存:字典项变更后据 dict_id 反查 code 再失效"""
|
||||
```
|
||||
|
||||
:::warning value 类型
|
||||
字典项的 `value` 字段为字符串类型,`get_dict_label()` 内部会自动将查询值转为字符串匹配。前端使用时注意类型转换。
|
||||
:::
|
||||
|
||||
### 前端使用
|
||||
|
||||
前端通过 `useDictStore` 获取字典数据:
|
||||
|
||||
```javascript
|
||||
import { useDictStore } from '@/store/dict'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
// 获取字典选项
|
||||
const statusOptions = dictStore.getDict('sys_status')
|
||||
// [{label: "正常", value: "1"}, {label: "停用", value: "2"}]
|
||||
|
||||
// 在 el-select 中使用
|
||||
<el-select v-model="form.status">
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
```
|
||||
|
||||
## 常用字典示例
|
||||
|
||||
| 字典编码 | 说明 | 字典项 |
|
||||
|---------|------|--------|
|
||||
| `sys_status` | 系统状态 | 正常(1)、停用(2) |
|
||||
| `sys_gender` | 性别 | 男(1)、女(2) |
|
||||
| `operation_type` | 操作类型 | 新增(1)、修改(2)、删除(3) 等 |
|
||||
| `menu_type` | 菜单类型 | 菜单(0)、节点(1) |
|
||||
|
||||
## 总结
|
||||
|
||||
数据字典模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理
|
||||
2. 进程内缓存:dict_util 内置 TTL 缓存(60秒兜底),变更时主动失效
|
||||
3. 前端集成:useDictStore 统一管理,el-select 直接绑定
|
||||
4. 值为字符串:字典项 value 统一为字符串,get_dict_label 自动转字符串匹配
|
||||
5. 避免硬编码:枚举值统一走字典,修改无需改代码
|
||||
6. 三种取值:get_dict_items(列表)、get_dict_map(映射)、get_dict_label(单值翻译)
|
||||
```
|
||||
@@ -0,0 +1,184 @@
|
||||
:::tip 全局异常与响应
|
||||
定义了统一的响应格式和全局异常处理机制。所有 API 返回 `{code, data, msg, ok}` 标准格式,异常被捕获后也转换为相同格式。响应函数位于 `src/core/response.py`,异常类型位于 `src/core/exceptions.py`。
|
||||
:::
|
||||
|
||||
## 统一响应格式
|
||||
|
||||
`src/core/response.py` 提供统一的响应生成函数:
|
||||
|
||||
```python
|
||||
from core import response as R
|
||||
|
||||
# 成功响应
|
||||
R.ok(data={"id": 1}, msg="操作成功")
|
||||
# {"code": 0, "data": {"id": 1}, "msg": "操作成功", "ok": true}
|
||||
|
||||
# 成功响应(带额外字段)
|
||||
R.ok(data=list, count=total)
|
||||
# {"code": 0, "data": [...], "msg": "操作成功", "ok": true, "count": 100}
|
||||
|
||||
# 失败响应
|
||||
R.failed(msg="参数错误")
|
||||
# {"code": 1, "data": null, "msg": "参数错误", "ok": false}
|
||||
```
|
||||
|
||||
### 响应函数说明
|
||||
|
||||
| 函数 | 说明 | code | ok |
|
||||
|------|------|------|------|
|
||||
| `R.ok()` | 成功响应 | 0 | true |
|
||||
| `R.failed()` | 失败响应 | 1 | false |
|
||||
| `R.page()` | 分页响应 | 0 | true |
|
||||
| `R.response()` | 通用响应 | 自定义 | 自定义 |
|
||||
|
||||
函数签名:
|
||||
|
||||
```python
|
||||
def ok(data=None, msg="操作成功", code=0, **kwargs) -> JSONResponse:
|
||||
"""生成成功响应,支持通过 kwargs 添加额外字段(如 count)"""
|
||||
|
||||
def failed(msg="操作失败", code=1, data=None, **kwargs) -> JSONResponse:
|
||||
"""生成失败响应"""
|
||||
|
||||
def page(data: list, total: int, current: int, size: int, msg="操作成功", code=0) -> JSONResponse:
|
||||
"""分页响应:封装 {records, total, size, current, pages} 标准分页结构"""
|
||||
pages = (total + size - 1) // size if size > 0 else 0
|
||||
|
||||
def response(data=None, msg="操作成功", code=0, success=True, **kwargs) -> JSONResponse:
|
||||
"""通用响应生成器(高级用法),允许完全自定义字段"""
|
||||
```
|
||||
|
||||
### 分页响应
|
||||
|
||||
```python
|
||||
R.page(data=records, total=100, current=1, size=10)
|
||||
# {
|
||||
# "code": 0,
|
||||
# "data": {
|
||||
# "records": [...],
|
||||
# "total": 100,
|
||||
# "size": 10,
|
||||
# "current": 1,
|
||||
# "pages": 10
|
||||
# },
|
||||
# "msg": "操作成功",
|
||||
# "ok": true
|
||||
# }
|
||||
```
|
||||
|
||||
## 异常类型
|
||||
|
||||
### AuthorizationException
|
||||
|
||||
认证异常,由 `login_required` 中间件抛出:
|
||||
|
||||
```python
|
||||
# src/core/exceptions.py
|
||||
class AuthorizationException(Exception):
|
||||
def __init__(self, code: int, msg: str):
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
```
|
||||
|
||||
### BusinessException
|
||||
|
||||
业务异常,业务代码可主动抛出:
|
||||
|
||||
```python
|
||||
class BusinessException(Exception):
|
||||
def __init__(self, code: int = 1, msg: str = ""):
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```python
|
||||
from core.exceptions import BusinessException
|
||||
|
||||
def some_business_logic():
|
||||
if some_condition:
|
||||
raise BusinessException(code=1, msg="数据不存在")
|
||||
```
|
||||
|
||||
## 全局异常处理器
|
||||
|
||||
`src/core/app.py` 注册了全局异常处理器,捕获所有异常并转换为标准格式:
|
||||
|
||||
```python
|
||||
def register_exception(app: FastAPI):
|
||||
# 认证异常
|
||||
@app.exception_handler(AuthorizationException)
|
||||
async def auth_exception_handler(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
|
||||
)
|
||||
|
||||
# 业务异常
|
||||
@app.exception_handler(BusinessException)
|
||||
async def business_exception_handler(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
|
||||
)
|
||||
|
||||
# HTTP 异常(404/405 等)
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": exc.detail, "ok": False},
|
||||
)
|
||||
|
||||
# 参数验证异常
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request, exc):
|
||||
errors = []
|
||||
for err in exc.errors():
|
||||
field = err.get("loc", [""])[-1]
|
||||
msg = err.get("msg", "")
|
||||
errors.append(f"{field}: {msg}" if field and field != "body" else msg)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": "/".join(errors)},
|
||||
)
|
||||
|
||||
# 未捕获异常
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": "服务器内部错误"},
|
||||
)
|
||||
```
|
||||
|
||||
:::tip HTTP 200
|
||||
所有异常响应均返回 HTTP 200 状态码,业务状态通过 `code` 字段区分。这是前后端分离架构的常见做法,避免浏览器拦截非200响应。
|
||||
:::
|
||||
|
||||
## 状态码常量
|
||||
|
||||
```python
|
||||
# src/core/response.py
|
||||
class Codes:
|
||||
SUCCESS = 0
|
||||
FAILED = 1
|
||||
UNAUTHORIZED = 401 # 未认证
|
||||
FORBIDDEN = 403 # 无权限
|
||||
NOT_FOUND = 404 # 资源不存在
|
||||
VALIDATE_ERROR = 422 # 验证错误
|
||||
SERVER_ERROR = 500 # 服务器错误
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
全局异常与响应模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 统一格式:所有 API 返回 {code, data, msg, ok} 标准格式
|
||||
2. 全局捕获:认证异常、业务异常、HTTP异常、验证异常、未捕获异常
|
||||
3. HTTP 200:所有响应均返回 200,业务状态通过 code 区分
|
||||
4. 错误聚合:多个验证错误用 / 分隔,便于前端展示
|
||||
5. 安全隐藏:未捕获异常返回通用提示,不暴露内部错误信息
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
:::tip 防重复提交
|
||||
防重复提交是保障数据一致性的重要措施。通过前端防抖和后端唯一性校验双重保护,防止用户重复点击导致的数据重复提交。后端唯一性校验通过 Pydantic `@field_validator` 查询数据库实现。
|
||||
:::
|
||||
|
||||
## 前端防抖
|
||||
|
||||
### useLockFn
|
||||
|
||||
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
|
||||
|
||||
```javascript
|
||||
import { useLockFn } from '@/utils/lock'
|
||||
|
||||
// 包装提交函数,防止重复点击
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
await axios.post('/api/v1/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
})
|
||||
```
|
||||
|
||||
`useLockFn` 原理:
|
||||
|
||||
```javascript
|
||||
function useLockFn(fn) {
|
||||
let isLocked = false
|
||||
return async (...args) => {
|
||||
if (isLocked) return
|
||||
isLocked = true
|
||||
try {
|
||||
return await fn(...args)
|
||||
} finally {
|
||||
isLocked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 按钮 loading
|
||||
|
||||
ElementPlus 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交
|
||||
</el-button>
|
||||
|
||||
<script setup>
|
||||
const submitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await axios.post('/api/v1/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 后端幂等性
|
||||
|
||||
### 唯一性约束
|
||||
|
||||
通过数据库唯一索引和 Pydantic 验证器保证数据唯一性:
|
||||
|
||||
```python
|
||||
class LevelForm(BaseForm):
|
||||
name: str = Field(..., min_length=1, max_length=150)
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def check_unique_name(cls, v, info):
|
||||
# 查询数据库校验唯一性
|
||||
if level_repo.exists_by_field('name', v, exclude_id=info.data.get('id')):
|
||||
raise ValueError('职级名称已存在')
|
||||
return v
|
||||
```
|
||||
|
||||
### 软删除 + 唯一索引
|
||||
|
||||
软删除场景下,唯一索引需要特殊处理:
|
||||
|
||||
```sql
|
||||
-- MySQL: 唯一索引包含 is_delete 字段
|
||||
ALTER TABLE fastapi_level ADD UNIQUE INDEX uk_name (name, is_delete);
|
||||
|
||||
-- PostgreSQL: 部分唯一索引
|
||||
CREATE UNIQUE INDEX uk_name ON fastapi_level (name) WHERE is_delete = 0;
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
```
|
||||
1. 前端防抖:useLockFn 或按钮 loading,防止用户重复点击
|
||||
2. 后端校验:Pydantic 验证器 + 数据库唯一索引,双重保障
|
||||
3. 幂等设计:相同请求多次执行结果一致
|
||||
4. 错误提示:重复提交时返回明确的错误提示
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
防重复提交方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 前端防抖:useLockFn 函数级防抖,按钮 loading 状态控制
|
||||
2. 后端校验:Pydantic 验证器查询数据库校验唯一性
|
||||
3. 数据库约束:唯一索引兜底,防止并发重复插入
|
||||
4. 双重保护:前端防抖 + 后端校验,确保数据一致性
|
||||
5. 用户体验:重复点击时按钮禁用,返回明确提示
|
||||
```
|
||||
@@ -0,0 +1,407 @@
|
||||
:::tip JWT认证
|
||||
JWT(JSON Web Token)是的核心认证机制。用户登录成功后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头携带 Token 进行身份验证。项目内置了 Token 生成、解析、刷新和黑名单管理功能,源码位于 `src/core/jwt.py`。
|
||||
:::
|
||||
|
||||
## 认证流程
|
||||
|
||||
```
|
||||
1. 前端发送 POST /api/v1/login(username + password + captcha)
|
||||
2. 后端校验验证码、用户名密码
|
||||
3. 登录成功 → create_token() 生成 JWT
|
||||
4. 前端存储 Token,后续请求携带 Authorization: Bearer <token>
|
||||
5. login_required 依赖解析 Token → 注入 request.state
|
||||
6. 退出登录 → Token 加入 Redis 黑名单立即失效
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `JWT_SALT` | 无(必填) | JWT 签名密钥,至少32字节 |
|
||||
| `JWT_EXPIRE_MINUTES` | `20` | Token 过期时间(分钟) |
|
||||
|
||||
:::warning 密钥安全
|
||||
`JWT_SALT` 必须通过环境变量设置,启动时会校验密钥强度(至少32字节),强度不足将拒绝启动。生成方式:
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
:::
|
||||
|
||||
## Token 生成
|
||||
|
||||
`src/core/jwt.py` 中的 `create_token()` 函数负责生成 JWT:
|
||||
|
||||
```python
|
||||
from core.jwt import create_token
|
||||
|
||||
# 登录成功后生成 Token
|
||||
access_token = create_token({
|
||||
"userId": user.id,
|
||||
"username": user.username,
|
||||
"realname": user.realname or ''
|
||||
})
|
||||
```
|
||||
|
||||
函数签名与核心逻辑:
|
||||
|
||||
```python
|
||||
def create_token(payload, timeout=None):
|
||||
"""
|
||||
生成JWT令牌
|
||||
|
||||
参数:
|
||||
payload (dict): JWT载荷数据,通常包含userId、username等信息
|
||||
timeout (int): 令牌过期时间(分钟),默认使用DEFAULT_TIMEOUT_MINUTES(来自JWT_EXPIRE_MINUTES)
|
||||
|
||||
返回:
|
||||
str: 生成的JWT令牌字符串
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
payload_copy = payload.copy()
|
||||
current_time = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
payload_copy['exp'] = current_time + datetime.timedelta(minutes=timeout)
|
||||
payload_copy['iat'] = current_time
|
||||
|
||||
headers = {"typ": "JWT", "alg": JWT_ALGORITHM}
|
||||
|
||||
token = jwt.encode(
|
||||
payload=payload_copy,
|
||||
key=JWT_SALT,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
headers=headers
|
||||
)
|
||||
return token
|
||||
```
|
||||
|
||||
Token 结构包含三部分:
|
||||
|
||||
```python
|
||||
# Header
|
||||
{
|
||||
"typ": "JWT",
|
||||
"alg": "HS256"
|
||||
}
|
||||
|
||||
# Payload(自动添加 exp 和 iat)
|
||||
{
|
||||
"userId": 1,
|
||||
"username": "admin",
|
||||
"realname": "管理员",
|
||||
"exp": "2026-09-05T12:00:00Z", # 过期时间
|
||||
"iat": "2026-09-05T11:40:00Z" # 签发时间
|
||||
}
|
||||
```
|
||||
|
||||
## Token 解析验证
|
||||
|
||||
`parse_payload()` 函数验证 Token 签名和过期状态,返回标准格式结果:
|
||||
|
||||
```python
|
||||
from core.jwt import parse_payload
|
||||
|
||||
result = parse_payload(token)
|
||||
if result['code'] == 0:
|
||||
user_id = result['data']['userId']
|
||||
username = result['data']['username']
|
||||
else:
|
||||
error_msg = result['msg']
|
||||
# "token已失效,请重新登录" / "token认证失败" / "非法的token"
|
||||
```
|
||||
|
||||
核心实现:
|
||||
|
||||
```python
|
||||
def parse_payload(token):
|
||||
"""解析验证JWT令牌,返回 {code, data, msg} 标准格式"""
|
||||
result = {"code": 0, "data": None, "msg": "操作成功"}
|
||||
|
||||
if not token:
|
||||
result['code'] = 1
|
||||
result['msg'] = "token不能为空"
|
||||
return result
|
||||
|
||||
try:
|
||||
verified_payload = jwt.decode(token, JWT_SALT, algorithms=[JWT_ALGORITHM])
|
||||
result['data'] = verified_payload
|
||||
except exceptions.ExpiredSignatureError:
|
||||
result['code'] = 1
|
||||
result['msg'] = "token已失效,请重新登录"
|
||||
except exceptions.DecodeError:
|
||||
result['code'] = 1
|
||||
result['msg'] = "token认证失败,无效的令牌格式"
|
||||
except exceptions.InvalidTokenError:
|
||||
result['code'] = 1
|
||||
result['msg'] = "非法的token,请检查令牌有效性"
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
异常处理说明:
|
||||
|
||||
| 异常类型 | 提示信息 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `ExpiredSignatureError` | token已失效,请重新登录 | Token 过期 |
|
||||
| `DecodeError` | token认证失败,无效的令牌格式 | 格式错误或签名无效 |
|
||||
| `InvalidTokenError` | 非法的token | 算法不匹配等 |
|
||||
|
||||
## 登录接口
|
||||
|
||||
登录接口 `POST /api/v1/login` 接收 `LoginForm` 参数:
|
||||
|
||||
```python
|
||||
# src/modules/auth/schemas.py
|
||||
class LoginForm(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=20, description="登录账号")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="登录密码")
|
||||
code: str = Field(..., min_length=CAPTCHA_LENGTH, max_length=CAPTCHA_LENGTH, description="验证码")
|
||||
key: str = Field(..., min_length=1, description="KEY值")
|
||||
```
|
||||
|
||||
登录逻辑位于 `src/modules/auth/service.py`,完整流程包括锁定检查、验证码校验、密码比对、默认密码检测:
|
||||
|
||||
```python
|
||||
async def login(request, data: LoginForm):
|
||||
ip = get_client_ip(request)
|
||||
redis = get_redis()
|
||||
|
||||
# 1. 检查是否被锁定(IP+用户名维度)
|
||||
locked, remaining = await _is_locked(redis, ip, data.username)
|
||||
if locked:
|
||||
return R.failed(f"登录失败次数过多,请{remaining}秒后再试")
|
||||
|
||||
# 2. 验证码校验(Lua 原子消费,防重放)
|
||||
if not await verify_captcha(data.key, data.code):
|
||||
return R.failed("验证码不正确")
|
||||
|
||||
# 3. 查询用户(同步DB查询放入线程池)
|
||||
user = await asyncio.to_thread(
|
||||
lambda: user_repo.get_one(username=data.username, status=1)
|
||||
)
|
||||
if not user:
|
||||
await _record_fail(redis, ip, data.username)
|
||||
return R.failed("用户名或密码不正确")
|
||||
|
||||
# 4. 比对密码(bcrypt CPU密集,放入线程池)
|
||||
password_ok = await asyncio.to_thread(
|
||||
password.verify_password, data.password, user.salt or '', user.password or '')
|
||||
if not password_ok:
|
||||
await _record_fail(redis, ip, data.username)
|
||||
return R.failed("用户名或密码不正确")
|
||||
|
||||
# 5. 登录成功,清除失败记录
|
||||
await _clear_fail(redis, ip, data.username)
|
||||
|
||||
# 6. 检测是否为默认密码(仅提示,不阻断登录)
|
||||
is_default_pwd = bool(user.password and await asyncio.to_thread(
|
||||
password.verify_password, DEFAULT_PASSWORD, user.salt or '', user.password or ''))
|
||||
|
||||
# 7. 生成 Token
|
||||
access_token = create_token({
|
||||
"userId": user.id,
|
||||
"username": user.username,
|
||||
"realname": user.realname or ''
|
||||
})
|
||||
|
||||
return R.ok(msg="登录成功", data={
|
||||
"access_token": access_token,
|
||||
"must_change_password": is_default_pwd
|
||||
})
|
||||
```
|
||||
|
||||
登录成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## Token 刷新
|
||||
|
||||
Token 支持无感刷新,刷新时会执行令牌轮换(旧 Token 立即失效):
|
||||
|
||||
```python
|
||||
from core.jwt import refresh_token
|
||||
|
||||
result = await refresh_token(old_token, extend_minutes=30)
|
||||
if result['code'] == 0:
|
||||
new_token = result['data']['access_token']
|
||||
expires_in = result['data']['expires_in'] # 秒
|
||||
```
|
||||
|
||||
刷新核心逻辑:
|
||||
|
||||
```python
|
||||
async def refresh_token(token, extend_minutes=None):
|
||||
# 1. 验证原令牌有效性
|
||||
result = parse_payload(token)
|
||||
if result['code'] != 0:
|
||||
return {"code": 1, "data": None, "msg": f"令牌无效,无法刷新: {result['msg']}"}
|
||||
|
||||
# 2. 已注销的令牌禁止刷新(防止攻击者用已注销 token 无限轮换)
|
||||
if await is_token_blacklisted(token):
|
||||
return {"code": 1, "data": None, "msg": "令牌已注销,无法刷新,请重新登录"}
|
||||
|
||||
# 3. 移除JWT标准字段,保留业务数据
|
||||
payload = result['data']
|
||||
clean_payload = {k: v for k, v in payload.items() if k not in ['exp', 'iat', 'nbf']}
|
||||
|
||||
# 4. 生成新令牌
|
||||
new_token = create_token(clean_payload, timeout=extend_minutes)
|
||||
|
||||
# 5. 令牌轮换:将旧令牌加入黑名单
|
||||
await add_token_to_blacklist(token)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {"access_token": new_token, "expires_in": extend_minutes * 60},
|
||||
"msg": "令牌刷新成功"
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 令牌轮换
|
||||
刷新后旧 Token 会被加入黑名单,防止旧 Token 在有效期内继续使用。已注销的 Token 不允许刷新,防止攻击者利用已注销令牌无限轮换出新令牌。
|
||||
:::
|
||||
|
||||
## Token 黑名单
|
||||
|
||||
退出登录时将 Token 加入 Redis 黑名单,使其立即失效:
|
||||
|
||||
```python
|
||||
from core.jwt import add_token_to_blacklist, is_token_blacklisted
|
||||
|
||||
# 退出登录:加入黑名单
|
||||
await add_token_to_blacklist(token)
|
||||
|
||||
# 中间件校验:检查是否已注销
|
||||
if await is_token_blacklisted(token):
|
||||
# 拒绝访问
|
||||
```
|
||||
|
||||
黑名单实现细节:
|
||||
|
||||
```python
|
||||
# 黑名单 Key 前缀
|
||||
_TOKEN_BLACKLIST_PREFIX = 'token:blacklist:'
|
||||
|
||||
# SHA256 指纹存储,不将完整 Token 存入 Redis
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
async def add_token_to_blacklist(token: str) -> bool:
|
||||
"""退出登录时调用,TTL 与 Token 剩余有效期一致"""
|
||||
expiration = get_token_expiration(token)
|
||||
ttl_seconds = int((expiration - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds())
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
await redis.set(key, '1', ex=ttl_seconds)
|
||||
|
||||
async def is_token_blacklisted(token: str) -> bool:
|
||||
"""检查 Token 是否已注销,Redis 不可用时 fail-open 放行"""
|
||||
try:
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
return await redis.exists(key) > 0
|
||||
except Exception as e:
|
||||
# fail-open:Redis 故障时放行,避免全站不可用
|
||||
_log_blacklist_check_alert(e)
|
||||
return False
|
||||
```
|
||||
|
||||
黑名单特性:
|
||||
- 使用 SHA256 指纹存储,不将完整 Token 存入 Redis
|
||||
- TTL 与 Token 剩余有效期一致,过期自动清理
|
||||
- Redis 不可用时采用 fail-open 策略放行(避免全站不可用)
|
||||
- 告警节流:Redis 故障期间首次失败记 ERROR,随后每 60 秒至多一条 ERROR,避免日志刷屏
|
||||
|
||||
## 请求中的 Token 提取
|
||||
|
||||
```python
|
||||
from core.jwt import get_access_token, parse_token
|
||||
|
||||
# 从请求头提取 Token
|
||||
success, token, msg = get_access_token(request)
|
||||
|
||||
# 完整解析(提取 + 验证)
|
||||
result = parse_token(request)
|
||||
# result['code'] == 0 表示成功
|
||||
# result['data']['userId'], result['data']['username']
|
||||
```
|
||||
|
||||
`get_access_token()` 支持 Bearer 前缀大小写不敏感(RFC 7235):
|
||||
|
||||
```python
|
||||
def get_access_token(request):
|
||||
"""从请求头中提取 access_token,返回 (success, token, message)"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
stripped = auth_header.strip()
|
||||
if stripped.lower().startswith('bearer '):
|
||||
access_token = stripped[7:].strip()
|
||||
else:
|
||||
access_token = stripped
|
||||
return True, access_token, 'success'
|
||||
```
|
||||
|
||||
## login_required 依赖
|
||||
|
||||
`src/api/deps.py` 中的 `login_required` 是全局认证依赖,应用于所有 v1 路由:
|
||||
|
||||
```python
|
||||
# 认证成功后注入 request.state
|
||||
request.state.user_id = userId
|
||||
request.state.username = username
|
||||
request.state.realname = realname
|
||||
```
|
||||
|
||||
业务代码通过 `src/core/security.py` 获取当前用户信息:
|
||||
|
||||
```python
|
||||
from core.security import get_user_id, get_username, get_realname
|
||||
|
||||
user_id = get_user_id(request)
|
||||
username = get_username(request)
|
||||
realname = get_realname(request)
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
前端登录后将 Token 存入 localStorage,每次请求自动携带:
|
||||
|
||||
```javascript
|
||||
// 请求拦截器
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:检测 Token 过期
|
||||
axios.interceptors.response.use(response => {
|
||||
if (response.data.code === 401) {
|
||||
// Token 过期,跳转登录页
|
||||
router.push('/login')
|
||||
}
|
||||
return response
|
||||
})
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
的 JWT 认证方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 安全性:HS256 算法 + 强密钥校验(至少32字节),启动时 fail-close
|
||||
2. 令牌轮换:刷新时旧 Token 立即加入黑名单,防止重放攻击
|
||||
3. 黑名单管理:SHA256 指纹存储,TTL 自动清理,Redis 故障时 fail-open 降级
|
||||
4. 防暴力破解:登录失败计数 + 账户锁定(IP+用户名维度)
|
||||
5. 全局统一:login_required 依赖 + request.state 注入,业务代码零侵入
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
:::tip 登录日志
|
||||
登录日志记录每次登录尝试(成功/失败),包含登录账号、IP地址、浏览器信息、登录时间等。支持 IP 地理位置解析(ip2region)和 User-Agent 解析。登录逻辑位于 `src/modules/auth/service.py`,日志记录位于 `src/modules/login_log/service.py`。
|
||||
:::
|
||||
|
||||
## 记录时机
|
||||
|
||||
登录日志在以下时机自动记录:
|
||||
|
||||
```
|
||||
1. 登录成功 → 记录成功日志(status=0)
|
||||
2. 登录失败(密码错误)→ 记录失败日志(status=1)
|
||||
3. 登录失败(用户不存在)→ 记录失败日志(status=1)
|
||||
4. 登录失败(账户被锁定)→ 记录失败日志(status=1)
|
||||
```
|
||||
|
||||
## 日志字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `username` | 登录账号 |
|
||||
| `ip` | 客户端 IP 地址 |
|
||||
| `ip_location` | IP 地理位置(ip2region 解析) |
|
||||
| `browser` | 浏览器类型 |
|
||||
| `os` | 操作系统 |
|
||||
| `status` | 登录状态:0-成功 1-失败 |
|
||||
| `message` | 提示信息 |
|
||||
| `login_time` | 登录时间 |
|
||||
|
||||
## IP 地理位置解析
|
||||
|
||||
框架集成 ip2region 库,自动将 IP 解析为地理位置:
|
||||
|
||||
```python
|
||||
# src/utils/ip2region.py
|
||||
from utils.ip2region import get_ip_location
|
||||
|
||||
location = get_ip_location("116.25.146.177")
|
||||
# 返回: "广东省深圳市"
|
||||
```
|
||||
|
||||
:::tip ip2region
|
||||
ip2region 是离线 IP 地理位置解析库,数据文件存储在 `static/ip2region.xdb`,无需网络请求,解析速度极快。
|
||||
:::
|
||||
|
||||
## User-Agent 解析
|
||||
|
||||
框架解析请求头中的 User-Agent,提取浏览器和操作系统信息:
|
||||
|
||||
```python
|
||||
# src/utils/ua.py
|
||||
from utils.ua import parse_ua
|
||||
|
||||
ua_info = parse_ua(user_agent_string)
|
||||
# 返回: {"browser": "Chrome 120", "os": "Windows 10"}
|
||||
```
|
||||
|
||||
## 查询接口
|
||||
|
||||
```
|
||||
GET /api/v1/login/log/list?pageNo=1&pageSize=10
|
||||
GET /api/v1/login/log/detail/{id}
|
||||
```
|
||||
|
||||
## 防暴力破解
|
||||
|
||||
登录日志配合登录失败锁定机制,防止暴力破解。锁定逻辑按 IP+用户名 维度:
|
||||
|
||||
```python
|
||||
# src/modules/auth/service.py
|
||||
_LOGIN_FAIL_PREFIX = 'login:fail:'
|
||||
_LOGIN_LOCK_PREFIX = 'login:lock:'
|
||||
|
||||
def _fail_key(ip: str, username: str) -> str:
|
||||
"""登录失败计数Key:按IP+用户名维度"""
|
||||
return f'{_LOGIN_FAIL_PREFIX}{ip}:{username}'
|
||||
|
||||
def _lock_key(ip: str, username: str) -> str:
|
||||
"""登录锁定Key"""
|
||||
return f'{_LOGIN_LOCK_PREFIX}{ip}:{username}'
|
||||
|
||||
async def _is_locked(redis, ip: str, username: str) -> tuple[bool, int]:
|
||||
"""检查是否处于锁定状态,返回 (是否锁定, 剩余秒数)"""
|
||||
key = _lock_key(ip, username)
|
||||
ttl = await redis.ttl(key)
|
||||
if ttl and ttl > 0:
|
||||
return True, ttl
|
||||
return False, 0
|
||||
|
||||
async def _record_fail(redis, ip: str, username: str):
|
||||
"""记录一次登录失败,达到上限则锁定"""
|
||||
key = _fail_key(ip, username)
|
||||
# 原子 incr + 首次设置过期(Lua 脚本,防止崩溃窗口内 key 永久残留)
|
||||
count = await incr_with_expire(redis, key, LOGIN_LOCK_SECONDS)
|
||||
if count >= LOGIN_FAIL_MAX:
|
||||
lock_k = _lock_key(ip, username)
|
||||
await redis.set(lock_k, '1', ex=LOGIN_LOCK_SECONDS)
|
||||
|
||||
async def _clear_fail(redis, ip: str, username: str):
|
||||
"""登录成功后清除失败记录"""
|
||||
fail_k = _fail_key(ip, username)
|
||||
lock_k = _lock_key(ip, username)
|
||||
await redis.delete(fail_k, lock_k)
|
||||
```
|
||||
|
||||
配置项:
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `LOGIN_FAIL_MAX` | `5` | 登录失败次数上限 |
|
||||
| `LOGIN_LOCK_SECONDS` | `300` | 锁定时间(秒) |
|
||||
|
||||
## 总结
|
||||
|
||||
登录日志模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 自动记录:登录成功/失败自动记录,无需手动埋点
|
||||
2. IP 解析:ip2region 离线解析,速度快、无网络依赖
|
||||
3. UA 解析:自动提取浏览器和操作系统信息
|
||||
4. 防暴力破解:失败计数 + 账户锁定(IP+用户名维度)
|
||||
5. 安全审计:完整记录登录行为,便于安全审计
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
:::tip 操作日志
|
||||
操作日志中间件自动拦截写请求(POST/PUT/DELETE/PATCH)并记录操作日志,无需手动埋点。日志落库通过 `response.background` 异步执行,不阻塞响应。源码位于 `src/middleware/operation_log.py`。
|
||||
:::
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
1. 请求进入 → operation_log_middleware 判断是否需要记录
|
||||
2. 仅拦截写请求(POST/PUT/DELETE/PATCH),排除登录/验证码等路径
|
||||
3. 记录请求参数、响应结果、耗时、操作类型
|
||||
4. 日志落库挂到 response.background,响应返回后异步执行
|
||||
5. 不阻塞主请求,对性能影响极小
|
||||
```
|
||||
|
||||
## 排除路径
|
||||
|
||||
以下路径不记录操作日志:
|
||||
|
||||
```python
|
||||
_EXCLUDE_PATHS = (
|
||||
'/api/v1/login',
|
||||
'/api/v1/logout',
|
||||
'/api/v1/captcha',
|
||||
'/api/v1/upload',
|
||||
'/api/v1/loginlog',
|
||||
'/api/v1/operationlog',
|
||||
'/api/v1/index/menu',
|
||||
'/api/v1/index/user',
|
||||
)
|
||||
```
|
||||
|
||||
## 操作类型自动推断
|
||||
|
||||
中间件根据 URL 路径末段自动推断操作类型,映射表定义在 `_TYPE_MAP` 中:
|
||||
|
||||
```python
|
||||
_TYPE_MAP = {
|
||||
'add': 1, # 新增
|
||||
'update': 2, # 修改
|
||||
'delete': 3, # 删除
|
||||
'batchdelete': 4, # 批量删除
|
||||
'status': 5, # 状态设置
|
||||
'resetpwd': 6, # 重置密码
|
||||
'updatepassword': 7, # 修改密码
|
||||
'import': 8, # 导入
|
||||
'export': 9, # 导出
|
||||
'kickout': 10, # 强退
|
||||
'generate': 11, # 生成代码
|
||||
'clear': 12, # 清空数据
|
||||
'auth': 13, # 授权
|
||||
'refresh': 14, # 缓存
|
||||
'openaccount': 15, # 开账号
|
||||
}
|
||||
|
||||
def _infer_type(request: Request) -> int:
|
||||
"""从 URL 路径末段推断操作类型,匹配不到返回 0(其它)"""
|
||||
path = request.url.path.rstrip('/')
|
||||
last_segment = path.split('/')[-1].lower()
|
||||
# 跳过纯数字路径参数(如 /delete/123 → 取 delete)
|
||||
if last_segment.isdigit():
|
||||
parts = path.split('/')
|
||||
last_segment = parts[-2].lower() if len(parts) >= 2 else ''
|
||||
return _TYPE_MAP.get(last_segment, 0)
|
||||
```
|
||||
|
||||
| 路径末段 | 操作类型 | 类型ID |
|
||||
|---------|---------|--------|
|
||||
| `add` | 新增 | 1 |
|
||||
| `update` | 修改 | 2 |
|
||||
| `delete` | 删除 | 3 |
|
||||
| `batchdelete` | 批量删除 | 4 |
|
||||
| `status` | 状态设置 | 5 |
|
||||
| `resetpwd` | 重置密码 | 6 |
|
||||
| `updatepassword` | 修改密码 | 7 |
|
||||
| `import` | 导入 | 8 |
|
||||
| `export` | 导出 | 9 |
|
||||
| `kickout` | 强退 | 10 |
|
||||
| `generate` | 生成代码 | 11 |
|
||||
| `clear` | 清空数据 | 12 |
|
||||
| `auth` | 授权 | 13 |
|
||||
| `refresh` | 缓存 | 14 |
|
||||
| `openaccount` | 开账号 | 15 |
|
||||
| 其它 | 其它 | 0 |
|
||||
|
||||
## 日志字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `title` | 操作标题(从路由 summary 获取) |
|
||||
| `type` | 操作类型(自动推断) |
|
||||
| `method` | 路由方法名(如 `v1.level.add`) |
|
||||
| `url` | 请求路径 |
|
||||
| `param` | 请求参数(截取前2000字符) |
|
||||
| `result` | 响应结果(截取前2000字符) |
|
||||
| `consume_time` | 耗时(毫秒) |
|
||||
| `status` | 状态:0-成功 1-失败 |
|
||||
| `error` | 错误信息 |
|
||||
| `create_user` | 操作人 |
|
||||
|
||||
## 查询接口
|
||||
|
||||
```
|
||||
GET /api/v1/oper/log/list?pageNo=1&pageSize=10
|
||||
GET /api/v1/oper/log/detail/{id}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
```
|
||||
1. 后台任务:日志落库通过 response.background 异步执行,不阻塞响应
|
||||
2. 前缀解析:仅解析响应体前 8KB(_READ_LIMIT),避免大响应体全量解析
|
||||
3. 长度截断:参数和结果截取前 2000 字符(_RESULT_MAX_LEN),防止撑爆日志表
|
||||
4. 路径排除:登录/验证码等高频路径不记录
|
||||
5. 路径段边界匹配:排除前缀按 path == prefix 或 prefix + '/' 匹配,避免子串误伤
|
||||
```
|
||||
|
||||
日志落库的后台任务挂载逻辑:
|
||||
|
||||
```python
|
||||
def _attach_background(response, task: BackgroundTask) -> None:
|
||||
"""把日志落库挂到响应后台任务;若响应已有后台任务则链式追加,避免覆盖"""
|
||||
prev = getattr(response, 'background', None)
|
||||
if prev is None:
|
||||
response.background = task
|
||||
return
|
||||
|
||||
async def _combined():
|
||||
await prev()
|
||||
await task()
|
||||
|
||||
response.background = BackgroundTask(_combined)
|
||||
```
|
||||
|
||||
:::tip 背景任务
|
||||
Starlette 在发送响应体后自动运行 `response.background` 中的任务。如果响应已有后台任务(如其他中间件添加的),操作日志会链式追加,不会覆盖。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
操作日志模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 自动记录:中间件拦截写请求,无需手动埋点
|
||||
2. 类型推断:根据 URL 路径自动推断操作类型
|
||||
3. 异步落库:后台任务执行,不阻塞主请求
|
||||
4. 性能友好:前缀解析 + 长度截断 + 路径排除
|
||||
5. 完整信息:记录参数、结果、耗时、操作人等
|
||||
```
|
||||
@@ -0,0 +1,145 @@
|
||||
:::tip 请求限流
|
||||
请求限流中间件基于 Redis 实现 IP 级别的滑动窗口限流,防止恶意请求和 DoS 攻击。默认关闭,可通过环境变量开启。源码位于 `src/middleware/rate_limit.py`,滑动窗口 Lua 脚本位于 `src/core/redis_client.py`。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
|
||||
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每个 IP 最大请求数 |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 统计窗口时长(秒) |
|
||||
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis 计数 key 前缀 |
|
||||
|
||||
:::warning 生产环境建议
|
||||
生产环境建议开启限流(`RATE_LIMIT_ENABLED=True`),根据实际流量调整 `RATE_LIMIT_LIMIT` 和 `RATE_LIMIT_WINDOW_SECONDS`。
|
||||
:::
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
1. 请求进入 → 检查 RATE_LIMIT_ENABLED 开关
|
||||
2. 获取客户端 IP(支持 X-Forwarded-For 代理头)
|
||||
3. 使用滑动窗口算法计算当前窗口请求数
|
||||
4. 超过限制 → 返回 {"code":1, "msg":"请求过于频繁,请 X 秒后再试"}
|
||||
5. 未超过 → 放行请求
|
||||
```
|
||||
|
||||
## 滑动窗口算法
|
||||
|
||||
采用滑动窗口计数器,按当前窗口与上一窗口的计数时间加权,消除固定窗口边界处的 2x 突发问题。
|
||||
|
||||
### 中间件实现
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
# ============================================================
|
||||
# 限流中间件
|
||||
# ============================================================
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
"""按客户端 IP 做滑动窗口限流,超限直接返回统一 200 + code 响应"""
|
||||
if not RATE_LIMIT_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# CORS 预检请求不计数,避免浏览器预检占用限流额度
|
||||
if request.method == 'OPTIONS':
|
||||
return await call_next(request)
|
||||
|
||||
# 直接读 app.state(lifespan 注入),不依赖 contextvar 注入顺序;Redis 未就绪时降级放行
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is None:
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
now = time.time()
|
||||
window_index = int(now // RATE_LIMIT_WINDOW_SECONDS)
|
||||
current_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index}"
|
||||
prev_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index - 1}"
|
||||
|
||||
# 原子滑动窗口计数(Lua 内完成 incr/expire/get/加权),消除窗口边界 2× 突发。
|
||||
# Redis 运行期故障(连接重置/超时/连接池耗尽,此时 redis 对象仍非 None)时降级放行
|
||||
# (fail-open),避免 Redis 一挂全站请求失败——仅判 redis is None 覆盖不到运行期异常
|
||||
try:
|
||||
count = await sliding_window_incr(
|
||||
redis, current_key, prev_key, RATE_LIMIT_WINDOW_SECONDS, window_index, now)
|
||||
except Exception as e:
|
||||
logger.warning(f"限流计数异常,降级放行: {e}")
|
||||
return await call_next(request)
|
||||
|
||||
# 直接返回而非抛异常:BaseHTTPMiddleware 内抛异常会绕过全局异常处理器
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
try:
|
||||
ttl = await redis.ttl(current_key)
|
||||
except Exception:
|
||||
ttl = RATE_LIMIT_WINDOW_SECONDS
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": f"请求过于频繁,请 {ttl} 秒后再试", "ok": False},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
```
|
||||
|
||||
### Lua 脚本
|
||||
|
||||
滑动窗口的核心在 `src/core/redis_client.py` 中的 Lua 脚本:
|
||||
|
||||
```python
|
||||
_SLIDING_WINDOW_LUA = """
|
||||
local cur = redis.call('incr', KEYS[1])
|
||||
if cur == 1 then
|
||||
redis.call('expire', KEYS[1], ARGV[1])
|
||||
end
|
||||
local prev = tonumber(redis.call('get', KEYS[2]) or '0')
|
||||
local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[2]) * tonumber(ARGV[1])
|
||||
local f = elapsed / tonumber(ARGV[1])
|
||||
if f < 0 then f = 0 elseif f > 1 then f = 1 end
|
||||
local effective = prev * (1 - f) + cur
|
||||
return effective
|
||||
"""
|
||||
|
||||
async def sliding_window_incr(redis, current_key, prev_key,
|
||||
window_seconds, window_index, now) -> float:
|
||||
"""滑动窗口限流计数:返回当前滑动窗口内的加权请求数(近似精确)"""
|
||||
return await redis.eval(
|
||||
_SLIDING_WINDOW_LUA, 2, current_key, prev_key,
|
||||
window_seconds, window_index, now)
|
||||
```
|
||||
|
||||
算法原理:当前窗口计数 `cur` + 上一窗口计数 `prev` 按时间比例加权(`prev * (1 - f)`),窗口边界处请求数平滑过渡,消除固定窗口在边界整体重置导致的 2x 突发。
|
||||
|
||||
## 降级策略
|
||||
|
||||
```
|
||||
1. Redis 未连接:直接放行,不阻塞请求
|
||||
2. Redis 运行期故障:降级放行,记录警告日志
|
||||
3. 限流计数异常:降级放行,避免全站不可用
|
||||
```
|
||||
|
||||
:::tip fail-open 策略
|
||||
限流采用 fail-open 策略:Redis 不可用时放行所有请求,优先保证服务可用性。这是安全与可用性的权衡。
|
||||
:::
|
||||
|
||||
## 中间件位置
|
||||
|
||||
限流中间件在 `core/app.py` 中注册为最外层(CORS 内层):
|
||||
|
||||
```python
|
||||
# 最外层:限流(被限流的请求不进入 DB 会话与操作日志)
|
||||
app.middleware("http")(rate_limit_middleware)
|
||||
# CORS(确保限流响应也带 CORS 头)
|
||||
register_cors(app)
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
请求限流模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 滑动窗口:消除固定窗口边界处的 2x 突发问题
|
||||
2. IP 维度:按客户端 IP 限流,支持代理头
|
||||
3. 默认关闭:opt-in 模式,按需开启
|
||||
4. 降级策略:Redis 故障时 fail-open 放行
|
||||
5. 最外层拦截:限流请求不进入 DB 会话和操作日志
|
||||
6. Lua 原子操作:计数和过期设置原子执行,避免并发问题
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
:::tip RBAC权限
|
||||
采用基于角色的访问控制(RBAC)模型,通过用户-角色-菜单三层关系实现细粒度的权限管理。权限控制贯穿前后端:后端通过 `@permission_required` 装饰器校验(`src/core/access_decorators.py`),前端通过 `v-perm` 指令控制按钮显隐。
|
||||
:::
|
||||
|
||||
## 权限模型
|
||||
|
||||
```
|
||||
用户(User) ──N:N── 角色(Role) ──N:N── 菜单(Menu)
|
||||
│
|
||||
type=0: 菜单(页面路由)
|
||||
type=1: 节点(权限按钮)
|
||||
```
|
||||
|
||||
数据库表关系:
|
||||
|
||||
| 关联表 | 说明 |
|
||||
|--------|------|
|
||||
| `fastapi_user_role` | 用户-角色关联表 |
|
||||
| `fastapi_role_menu` | 角色-菜单关联表 |
|
||||
| `fastapi_menu` | 菜单表(含权限节点) |
|
||||
|
||||
## 权限节点格式
|
||||
|
||||
权限节点采用 `sys:module:action` 三段式命名:
|
||||
|
||||
```
|
||||
sys:user:list 用户列表
|
||||
sys:user:add 用户新增
|
||||
sys:user:update 用户编辑
|
||||
sys:user:delete 用户删除
|
||||
sys:user:resetPwd 重置密码
|
||||
sys:role:list 角色列表
|
||||
sys:role:add 角色新增
|
||||
sys:menu:list 菜单列表
|
||||
sys:dept:list 部门列表
|
||||
sys:level:list 职级列表
|
||||
sys:position:list 岗位列表
|
||||
```
|
||||
|
||||
## 菜单模型
|
||||
|
||||
```python
|
||||
# src/modules/system/menu/models.py
|
||||
# ============================================================
|
||||
# 菜单模型
|
||||
# ============================================================
|
||||
class Menu(base_model, base_db):
|
||||
"""菜单模型类"""
|
||||
# ============================================================
|
||||
# 表名配置
|
||||
# ============================================================
|
||||
__tablename__ = DB_PREFIX + "menu"
|
||||
__table_comment__ = "菜单表"
|
||||
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 菜单名称
|
||||
name = Column(String(150), nullable=False, index=True, comment="菜单名称")
|
||||
# 菜单图标
|
||||
icon = Column(String(50), nullable=True, comment="菜单图标")
|
||||
# 菜单图标(备用)
|
||||
icon2 = Column(String(50), nullable=True, comment="菜单图标")
|
||||
# 上级ID
|
||||
parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级ID")
|
||||
# 菜单路径
|
||||
path = Column(String(255), nullable=True, comment="菜单路径")
|
||||
# 菜单组件
|
||||
component = Column(String(255), nullable=True, comment="菜单组件")
|
||||
# 权限节点
|
||||
permission = Column(String(150), nullable=True, comment="权限节点")
|
||||
# 打开方式:0-组件 1-内链 2-外链
|
||||
target = Column(Integer, default=1, server_default=text('1'), comment="打开方式:0-组件 1-内链 2-外链")
|
||||
# 菜单类型:0-菜单 1-节点
|
||||
type = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单类型:0-菜单 1-节点")
|
||||
# 状态选项
|
||||
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单状态:0-正常 1-停用")
|
||||
# 是否可见:0-可见 1-不可见
|
||||
hide = Column(Integer, default=1, server_default=text('1'), comment="是否可见:0-可见 1-不可见")
|
||||
# 排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="排序")
|
||||
# 备注
|
||||
note = Column(String(255), nullable=True, comment="备注")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
# ============================================================
|
||||
def __str__(self):
|
||||
"""返回菜单ID作为字符串表示"""
|
||||
return "菜单{}".format(self.id)
|
||||
```
|
||||
|
||||
菜单类型说明:
|
||||
|
||||
| type | 说明 | 用途 |
|
||||
|------|------|------|
|
||||
| `0` | 菜单 | 页面路由,前端生成菜单导航 |
|
||||
| `1` | 节点 | 权限按钮,控制操作权限 |
|
||||
|
||||
## 后端权限校验
|
||||
|
||||
### permission_required 装饰器
|
||||
|
||||
`src/core/access_decorators.py` 中的 `@permission_required` 装饰器用于端点级权限校验:
|
||||
|
||||
```python
|
||||
from core.access_decorators import permission_required, check_demo
|
||||
|
||||
@router.post('/add')
|
||||
@permission_required("sys:level:add")
|
||||
@check_demo
|
||||
async def add(request: Request, data: LevelForm):
|
||||
return await level.LevelAdd(request, data)
|
||||
```
|
||||
|
||||
校验逻辑(完整实现):
|
||||
|
||||
```python
|
||||
def permission_required(permission: str):
|
||||
"""节点权限鉴权装饰器,userId==1(管理员)直接放行"""
|
||||
def decorator(func):
|
||||
def _check(request):
|
||||
"""同步权限校验(直查DB,无Redis依赖,供线程池/回退场景使用)"""
|
||||
userId = get_user_id(request)
|
||||
if userId == 1:
|
||||
return None
|
||||
from modules.system.menu import service as menu
|
||||
permission_list = menu.get_permissions_list_sync(userId)
|
||||
if permission not in permission_list:
|
||||
return R.failed("权限不足")
|
||||
return None
|
||||
|
||||
async def _check_cached(request):
|
||||
"""异步权限校验(走 Redis 缓存,未命中才回源DB)"""
|
||||
userId = get_user_id(request)
|
||||
if userId == 1:
|
||||
return None
|
||||
from modules.system.menu import service as menu
|
||||
permission_list = await menu.get_permissions_list(userId)
|
||||
if permission not in permission_list:
|
||||
return R.failed("权限不足")
|
||||
return None
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
request = _extract_request(*args, **kwargs)
|
||||
denied = await _check_cached(request)
|
||||
if denied:
|
||||
return denied
|
||||
return await func(*args, **kwargs)
|
||||
return async_wrapper
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
request = _extract_request(*args, **kwargs)
|
||||
denied = _check(request)
|
||||
if denied:
|
||||
return denied
|
||||
return func(*args, **kwargs)
|
||||
return sync_wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
:::warning Request 提取
|
||||
装饰器通过 `_extract_request()` 从函数参数中提取 Request 对象,支持 `request` 关键字参数或位置参数两种方式。如果端点函数没有 request 参数,权限校验将被跳过。
|
||||
:::
|
||||
|
||||
:::tip 管理员免检
|
||||
用户ID为1的管理员账号(admin)自动跳过所有权限校验,拥有全部权限。
|
||||
:::
|
||||
|
||||
### 同步/异步适配
|
||||
|
||||
装饰器自动适配同步和异步端点:
|
||||
|
||||
- **异步端点**:走 Redis 缓存的 `get_permissions_list()`,性能更优
|
||||
- **同步端点**:走直接查询 DB 的 `get_permissions_list_sync()`,避免跨事件循环问题
|
||||
|
||||
## 前端权限控制
|
||||
|
||||
### v-perm 指令
|
||||
|
||||
前端使用自定义 `v-perm` 指令控制按钮显隐:
|
||||
|
||||
```html
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
|
||||
<!-- 多个权限,满足其一即可 -->
|
||||
<el-button v-perm="['sys:user:update', 'sys:user:delete']">操作</el-button>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
前端路由守卫根据用户菜单权限动态生成路由,无权限的页面不会出现在导航菜单中。
|
||||
|
||||
## 权限数据查询
|
||||
|
||||
权限列表通过角色-菜单关联查询:
|
||||
|
||||
```python
|
||||
# 获取用户的权限节点列表(带 Redis 缓存)
|
||||
async def get_permissions_list(user_id: int) -> list[str]:
|
||||
# 1. 先查 Redis 缓存
|
||||
# 2. 缓存未命中 → 查 DB(user → role → menu where type=1)
|
||||
# 3. 返回 permission 字段列表
|
||||
pass
|
||||
|
||||
# 同步版本(供同步端点使用)
|
||||
def get_permissions_list_sync(user_id: int) -> list[str]:
|
||||
# 直接查 DB,不走缓存
|
||||
pass
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
的 RBAC 权限方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 三层模型:用户-角色-菜单,支持灵活的角色分配
|
||||
2. 节点权限:sys:module:action 格式,细粒度控制每个操作
|
||||
3. 前后端统一:后端 @permission_required + 前端 v-perm 指令
|
||||
4. 管理员免检:userId=1 自动跳过权限校验
|
||||
5. 双路校验:异步端点走 Redis 缓存,同步端点直查 DB(避免跨事件循环)
|
||||
6. 菜单路由:type=0 菜单生成前端路由,type=1 节点控制按钮权限
|
||||
7. 懒加载:装饰器内懒加载业务 service,避免 core → 业务模块顶层循环依赖
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
:::tip 参数验证
|
||||
使用 Pydantic v2 进行请求参数验证。所有请求体参数通过 Pydantic BaseModel 定义,FastAPI 自动解析和验证 JSON 请求体,验证失败返回标准格式错误信息。BaseForm 基类位于 `src/core/base_schemas.py`。
|
||||
:::
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义表单类
|
||||
|
||||
```python
|
||||
# src/modules/system/position/schemas.py
|
||||
# +======================================================================
|
||||
# | 模块: 岗位表单验证
|
||||
# | 说明: 岗位创建/编辑/状态更新的请求数据校验
|
||||
# +======================================================================
|
||||
|
||||
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-停用")
|
||||
```
|
||||
|
||||
### 端点使用
|
||||
|
||||
```python
|
||||
@router.post('/add')
|
||||
@permission_required("sys:position:add")
|
||||
@check_demo
|
||||
async def add(request: Request, data: PositionForm):
|
||||
return await position.PositionAdd(request, data)
|
||||
```
|
||||
|
||||
:::tip 自动验证
|
||||
FastAPI 在调用端点函数前自动完成参数验证。验证失败时返回标准格式错误:
|
||||
```json
|
||||
{"code": 1, "data": null, "msg": "name: 字段长度不能少于1个字符"}
|
||||
```
|
||||
:::
|
||||
|
||||
## Field 约束
|
||||
|
||||
Pydantic v2 提供丰富的字段约束:
|
||||
|
||||
| 约束 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `min_length` | 字符串最小长度 | `Field(..., min_length=1)` |
|
||||
| `max_length` | 字符串最大长度 | `Field(..., max_length=150)` |
|
||||
| `ge` | 大于等于 | `Field(..., ge=0)` |
|
||||
| `le` | 小于等于 | `Field(..., le=99999)` |
|
||||
| `gt` | 大于 | `Field(..., gt=0)` |
|
||||
| `lt` | 小于 | `Field(..., lt=100)` |
|
||||
| `pattern` | 正则匹配 | `Field(..., pattern=r'^[a-z]+$')` |
|
||||
|
||||
## 自定义验证器
|
||||
|
||||
使用 `@field_validator` 装饰器添加自定义验证逻辑:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class UserForm(BaseForm):
|
||||
username: str = Field(..., min_length=1, max_length=50)
|
||||
email: str = Field(..., max_length=100)
|
||||
|
||||
@field_validator('username')
|
||||
@classmethod
|
||||
def validate_username(cls, v):
|
||||
if not v.isalnum():
|
||||
raise ValueError('用户名只能包含字母和数字')
|
||||
return v
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v):
|
||||
if '@' not in v:
|
||||
raise ValueError('邮箱格式不正确')
|
||||
return v
|
||||
```
|
||||
|
||||
## BaseForm 基类
|
||||
|
||||
所有创建/编辑表单应继承 `BaseForm`,自动获得 `id` 字段和空字符串处理:
|
||||
|
||||
```python
|
||||
# src/core/base_schemas.py
|
||||
# +======================================================================
|
||||
# | 模块: 表单基类
|
||||
# | 说明: 统一处理前端传入的空字符串 id → None 等通用校验
|
||||
# +======================================================================
|
||||
|
||||
"""表单基类:统一处理前端传入的空字符串 id → None 等通用校验。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 表单基类
|
||||
# ============================================================
|
||||
class BaseForm(BaseModel):
|
||||
"""
|
||||
表单基类
|
||||
|
||||
所有创建/编辑表单应继承此类,自动获得:
|
||||
1. id 字段:Optional[int],前端传空字符串时自动转为 None
|
||||
"""
|
||||
|
||||
id: Optional[int] = Field(None, description="主键ID")
|
||||
|
||||
@field_validator('id', mode='before')
|
||||
@classmethod
|
||||
def empty_str_to_none(cls, v):
|
||||
"""前端 POST 空字符串 '' 时转为 None,避免 Pydantic 整数解析报错"""
|
||||
if v == '' or v == 'null' or v == 'undefined':
|
||||
return None
|
||||
return v
|
||||
```
|
||||
|
||||
:::warning 前端空字符串
|
||||
前端某些组件(如 el-select)在未选择时可能传空字符串 `""`,BaseForm 的 `empty_str_to_none` 验证器会自动将其转为 `None`,避免整数字段解析报错。
|
||||
:::
|
||||
|
||||
## 唯一性校验
|
||||
|
||||
在 Pydantic 验证器中查询数据库校验唯一性:
|
||||
|
||||
```python
|
||||
from pydantic import 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)
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def check_unique_name(cls, v, info):
|
||||
db = get_db_session()
|
||||
exclude_id = info.data.get('id')
|
||||
query = db.query(Position).filter(
|
||||
Position.name == v,
|
||||
Position.is_delete == 0
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(Position.id != exclude_id)
|
||||
if query.first():
|
||||
raise ValueError('岗位名称已存在')
|
||||
return v
|
||||
```
|
||||
|
||||
## 登录表单示例
|
||||
|
||||
```python
|
||||
# src/modules/auth/schemas.py
|
||||
# +======================================================================
|
||||
# | 模块: 登录表单验证
|
||||
# | 说明: 用户登录的请求数据校验
|
||||
# +======================================================================
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.config import CAPTCHA_LENGTH
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 登录表单类
|
||||
# ============================================================
|
||||
class LoginForm(BaseModel):
|
||||
"""登录表单验证类"""
|
||||
username: str = Field(..., min_length=1, max_length=20, description="登录账号")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="登录密码")
|
||||
code: str = Field(..., min_length=CAPTCHA_LENGTH, max_length=CAPTCHA_LENGTH, description="验证码")
|
||||
key: str = Field(..., min_length=1, description="KEY值")
|
||||
```
|
||||
|
||||
## 错误响应格式
|
||||
|
||||
验证失败时,全局异常处理器捕获 `RequestValidationError` 并返回标准格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "name: 字段长度不能少于1个字符/status: 输入值必须大于等于1",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
多个错误用 `/` 分隔。
|
||||
|
||||
## 总结
|
||||
|
||||
参数验证模块具备以下特点:
|
||||
|
||||
```
|
||||
1. Pydantic v2:类型安全、自动解析 JSON 请求体
|
||||
2. 丰富约束:min_length、max_length、ge、le、gt、lt、pattern
|
||||
3. 自定义验证:@field_validator 实现复杂校验逻辑
|
||||
4. BaseForm 基类:统一 id 字段处理,兼容前端空字符串
|
||||
5. 唯一性校验:验证器中查询数据库,确保字段唯一性
|
||||
6. 标准错误:全局异常处理器统一错误响应格式
|
||||
```
|
||||
Reference in New Issue
Block a user