签入版本

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,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. 标准错误:全局异常处理器统一错误响应格式
```