签入版本
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
:::tip 本章概要
|
||||
登录 -> Token 获取 -> 权限校验 -> Token 刷新 -> 退出登录的完整认证链路实战说明。
|
||||
:::
|
||||
|
||||
# 认证流程实战
|
||||
|
||||
本章将登录 -> Token 获取 -> 权限校验 -> Token 刷新 -> 退出登录的完整认证链路串联说明,帮助开发者理解端到端的认证机制。
|
||||
|
||||
## 认证架构总览
|
||||
|
||||
```
|
||||
+----------+ 1获取验证码 +----------+
|
||||
| | -----------------> | |
|
||||
| 前端 | | 后端 |
|
||||
| Vue3 | 2登录(账号+密码+验证码) | Django |
|
||||
| | -----------------> | |
|
||||
| | 3返回 Token | |
|
||||
| | <------------------ | |
|
||||
| | | |
|
||||
| | 4携带 Token 请求 | |
|
||||
| | -----------------> | |
|
||||
| | 5返回业务数据 | |
|
||||
| | <------------------ | |
|
||||
| | | |
|
||||
| | 6刷新 Token | |
|
||||
| | -----------------> | |
|
||||
| | 7返回新 Token | |
|
||||
| | <------------------ | |
|
||||
| | | |
|
||||
| | 8退出登录 | |
|
||||
| | -----------------> | |
|
||||
+----------+ +----------+
|
||||
```
|
||||
|
||||
## 第 1 步:获取验证码
|
||||
|
||||
登录前需先获取图形验证码,防止暴力破解。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /login/captcha
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"captcha": "data:image/png;base64,iVBORw0KGgo...",
|
||||
"key": 1
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `captcha`:Base64 编码的验证码图片,前端直接赋值给 `<img>` 的 `src`
|
||||
- `key`:验证码记录 ID,登录时需回传
|
||||
|
||||
### 后端实现
|
||||
|
||||
```python
|
||||
# application/login/views.py
|
||||
from captcha.models import CaptchaStore
|
||||
from captcha.views import captcha_image
|
||||
|
||||
class CaptchaView(View):
|
||||
def get(self, request):
|
||||
# 生成验证码密钥
|
||||
hashkey = CaptchaStore.generate_key()
|
||||
id_key = CaptchaStore.objects.filter(hashkey=hashkey).first().id
|
||||
|
||||
# 生成验证码图片并转 Base64
|
||||
image = captcha_image(request, hashkey)
|
||||
base64_image = base64.b64encode(image.content)
|
||||
img_str = "data:image/png;base64," + base64_image.decode("utf-8")
|
||||
|
||||
return R.ok(idkey=id_key, data={"captcha": img_str, "key": id_key})
|
||||
```
|
||||
|
||||
验证码通过 django-simple-captcha 生成,存储在数据库中。
|
||||
|
||||
## 第 2 步:用户登录
|
||||
|
||||
携带账号、密码、验证码和 Key 发起登录请求。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
POST /login/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"code": "a3Kp",
|
||||
"key": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"must_change_password": false
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `access_token`:JWT 令牌,后续请求需携带
|
||||
- `must_change_password`:是否需要修改密码
|
||||
|
||||
### 登录流程详解
|
||||
|
||||
```
|
||||
1. 从请求体获取 username、password、code、key
|
||||
2. 验证码校验(比对数据库中的验证码记录)
|
||||
- 不正确 -> 返回失败
|
||||
3. 查询用户(username + status=1)
|
||||
- 不存在 -> 返回失败
|
||||
4. 比对密码(bcrypt 双重加盐验证)
|
||||
- 不匹配 -> 返回失败
|
||||
5. 登录成功
|
||||
- 生成 JWT Token
|
||||
- 记录登录日志
|
||||
- 返回 Token
|
||||
```
|
||||
|
||||
## 第 3 步:携带 Token 请求业务接口
|
||||
|
||||
登录成功后,前端将 Token 存入 localStorage,后续所有请求自动携带。
|
||||
|
||||
### 请求头格式
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
```
|
||||
|
||||
### Token 解析流程
|
||||
|
||||
```
|
||||
请求进入 -> check_login 装饰器
|
||||
+-- 提取 Authorization 头(request.META['HTTP_AUTHORIZATION'])
|
||||
+-- 移除 "Bearer " 前缀
|
||||
+-- parse_payload() 解析 JWT Token
|
||||
| +-- 验证签名(HS256 + JWT_SALT)
|
||||
| +-- 验证过期时间
|
||||
| +-- 检查黑名单(Redis)
|
||||
+-- 验证通过 -> 执行原视图函数
|
||||
+-- 验证失败 -> R.failed(code=401, msg=...)
|
||||
```
|
||||
|
||||
### check_login 装饰器实现
|
||||
|
||||
```python
|
||||
# middleware/login_middleware.py
|
||||
def check_login(func):
|
||||
def wrapper(request, *args, **kwargs):
|
||||
ignoreURL = ['/login', '/captcha']
|
||||
|
||||
if request.path not in ignoreURL:
|
||||
access_token = request.headers['Authorization']
|
||||
access_token = access_token.replace('Bearer ', "")
|
||||
result = parse_payload(access_token)
|
||||
|
||||
if result['code'] != 0:
|
||||
return R.failed(code=401, msg=result['msg'])
|
||||
|
||||
return func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
```
|
||||
|
||||
## 第 4 步:权限校验
|
||||
|
||||
每个业务接口通过 `PermissionRequired` 混入类校验权限。
|
||||
|
||||
### 权限校验流程
|
||||
|
||||
```
|
||||
请求到达 -> PermissionRequired.has_permission()
|
||||
+-- 用户 ID = 1(admin)-> 跳过权限校验,直接放行
|
||||
+-- 其他用户
|
||||
+-- 查询用户权限列表(menu.services.get_user_permissions)
|
||||
+-- 匹配权限字符串
|
||||
| +-- 匹配 -> 放行
|
||||
| +-- 不匹配 -> 返回 "暂无操作权限"
|
||||
```
|
||||
|
||||
### 权限字符串格式
|
||||
|
||||
```
|
||||
sys:{module}:{action}
|
||||
```
|
||||
|
||||
示例:`sys:user:add`、`sys:role:page`、`sys:article:delete`
|
||||
|
||||
## 第 5 步:Token 刷新
|
||||
|
||||
Token 支持无感刷新,刷新时生成新令牌。
|
||||
|
||||
### 刷新请求
|
||||
|
||||
```
|
||||
POST /login/refreshToken
|
||||
Authorization: Bearer <old_token>
|
||||
```
|
||||
|
||||
### 刷新流程
|
||||
|
||||
```
|
||||
1. 验证原令牌有效性
|
||||
2. 移除 JWT 标准字段(exp/iat/nbf),保留业务数据
|
||||
3. 生成新令牌
|
||||
4. 返回新令牌
|
||||
```
|
||||
|
||||
## 第 6 步:退出登录
|
||||
|
||||
退出时将当前 Token 加入 Redis 黑名单,使其立即失效。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /login/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 放行(避免全站不可用)
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `JWT_SALT` | 内置默认密钥 | JWT 签名密钥,建议至少 32 字节 |
|
||||
| `DEFAULT_TIMEOUT_MINUTES` | `20` | Token 过期时间(分钟) |
|
||||
|
||||
## 总结
|
||||
|
||||
认证流程覆盖了从获取验证码到退出登录的完整链路,核心安全机制包括:
|
||||
- bcrypt 密码哈希 + 随机盐
|
||||
- JWT HS256 签名 + 过期校验
|
||||
- django-simple-captcha 验证码防暴力破解
|
||||
- Token 黑名单即时失效
|
||||
- Redis 不可用时 fail-open 降级
|
||||
@@ -0,0 +1,132 @@
|
||||
:::tip 验证码
|
||||
验证码是防止自动化攻击的重要安全手段。项目使用 django-simple-captcha 库生成图片验证码,存储在数据库中,支持多种验证码格式。源码位于 `application/login/views.py`。
|
||||
:::
|
||||
|
||||
## 验证码流程
|
||||
|
||||
```
|
||||
1. 前端请求 GET /login/captcha
|
||||
2. 后端通过 CaptchaStore.generate_key() 生成验证码
|
||||
3. captcha_image() 生成图片 -> Base64 编码
|
||||
4. 返回 {key, captcha} 给前端
|
||||
5. 前端展示图片,用户输入验证码
|
||||
6. 登录时后端通过 CaptchaStore 比对验证码
|
||||
```
|
||||
|
||||
## 接口说明
|
||||
|
||||
### 获取验证码
|
||||
|
||||
```
|
||||
GET /login/captcha
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"captcha": "data:image/png;base64,iVBORw0KGgo...",
|
||||
"key": 1
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
```python
|
||||
# application/login/views.py
|
||||
from captcha.models import CaptchaStore
|
||||
from captcha.views import captcha_image
|
||||
|
||||
class CaptchaView(View):
|
||||
def get(self, request):
|
||||
# 生成验证码密钥
|
||||
hashkey = CaptchaStore.generate_key()
|
||||
|
||||
# 从数据库中获取生成的验证码记录ID
|
||||
id_key = CaptchaStore.objects.filter(hashkey=hashkey).first().id
|
||||
|
||||
# 生成验证码图片
|
||||
image = captcha_image(request, hashkey)
|
||||
|
||||
# 将图片内容转换为Base64编码
|
||||
base64_image = base64.b64encode(image.content)
|
||||
img_str = "data:image/png;base64," + base64_image.decode("utf-8")
|
||||
|
||||
# 返回验证码图片和密钥
|
||||
return R.ok(idkey=id_key, data={"captcha": img_str, "key": id_key})
|
||||
```
|
||||
|
||||
### 验证码校验
|
||||
|
||||
验证码校验在登录流程中自动完成,通过 django-simple-captcha 内置的表单验证机制:
|
||||
|
||||
```python
|
||||
# application/login/forms.py
|
||||
from captcha.fields import CaptchaField
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
username = forms.CharField(max_length=30)
|
||||
password = forms.CharField(max_length=128)
|
||||
captcha = CaptchaField() # 自动校验验证码
|
||||
```
|
||||
|
||||
:::warning 验证码字段
|
||||
`CaptchaField` 会自动校验用户输入的验证码是否与数据库中存储的一致,验证成功后自动删除(防重放)。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
在 `settings.py` 中配置验证码相关参数:
|
||||
|
||||
```python
|
||||
# application/settings.py
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'captcha',
|
||||
]
|
||||
|
||||
# 验证码配置
|
||||
CAPTCHA_IMAGE_SIZE = (160, 50) # 图片尺寸
|
||||
CAPTCHA_FONT_SIZE = 28 # 字体大小
|
||||
CAPTCHA_LETTER_ROTATION = (-35, 35) # 字符旋转角度
|
||||
CAPTCHA_NOISE_LINES = 6 # 干扰线数量
|
||||
CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.random_char_challenge' # 随机字符
|
||||
CAPTCHA_LENGTH = 4 # 验证码长度
|
||||
CAPTCHA_TIMEOUT = 5 # 验证码有效期(分钟)
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
```javascript
|
||||
// 获取验证码
|
||||
async function getCaptcha() {
|
||||
const res = await axios.get('/login/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. django-simple-captcha:成熟的 Django 验证码库,支持多种格式
|
||||
2. 数据库存储:验证码存储在数据库中,支持过期清理
|
||||
3. 自动校验:CaptchaField 内置校验逻辑,验证成功后自动删除
|
||||
4. Base64 传输:图片转 Base64 编码,无需额外的图片访问接口
|
||||
5. 防重放:验证码一次性使用,验证成功后立即失效
|
||||
```
|
||||
@@ -0,0 +1,187 @@
|
||||
:::tip 系统配置
|
||||
系统配置模块用于管理动态的键值对配置项,与数据字典类似但更偏向系统级参数。支持分组管理,可通过接口动态修改,无需重启服务。模块位于 `application/config/`。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
application/config/
|
||||
├── models.py # 配置模型
|
||||
├── forms.py # 表单验证
|
||||
├── services.py # 业务逻辑层
|
||||
├── views.py # 视图层
|
||||
└── urls.py # 路由配置
|
||||
|
||||
application/config_item/
|
||||
├── models.py # 配置项模型
|
||||
├── forms.py # 表单验证
|
||||
├── services.py # 业务逻辑层
|
||||
├── views.py # 视图层
|
||||
└── urls.py # 路由配置
|
||||
```
|
||||
|
||||
## 配置模型
|
||||
|
||||
### 配置分组(Config)
|
||||
|
||||
```python
|
||||
# application/config/models.py
|
||||
class Config(BaseModel):
|
||||
"""配置模型类"""
|
||||
class Meta:
|
||||
db_table = 'django_config'
|
||||
|
||||
name = models.CharField(max_length=150, verbose_name='配置名称')
|
||||
code = models.CharField(max_length=150, verbose_name='配置编码')
|
||||
sort = models.IntegerField(default=0, verbose_name='配置排序')
|
||||
note = models.CharField(max_length=255, null=True, blank=True, verbose_name='配置备注')
|
||||
```
|
||||
|
||||
### 配置项(ConfigItem)
|
||||
|
||||
```python
|
||||
# application/config_item/models.py
|
||||
class ConfigItem(BaseModel):
|
||||
"""配置项模型类"""
|
||||
class Meta:
|
||||
db_table = 'django_config_item'
|
||||
|
||||
name = models.CharField(max_length=150, verbose_name='配置项名称')
|
||||
code = models.CharField(max_length=150, verbose_name='配置项编码')
|
||||
value = models.CharField(max_length=1000, null=True, blank=True, verbose_name='配置项值')
|
||||
options = models.TextField(null=True, blank=True, verbose_name='配置选项')
|
||||
config_id = models.IntegerField(default=0, verbose_name='配置ID')
|
||||
type = models.CharField(max_length=150, verbose_name='配置类型')
|
||||
status = models.IntegerField(default=1, verbose_name='配置状态:1-正常 2-停用')
|
||||
sort = models.IntegerField(default=0, verbose_name='配置项顺序')
|
||||
note = models.CharField(max_length=255, null=True, blank=True, verbose_name='配置项备注')
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | str | 配置项名称 |
|
||||
| `code` | str | 配置项编码(唯一标识) |
|
||||
| `value` | str | 配置项值 |
|
||||
| `options` | str | 配置选项(JSON 格式,用于下拉选择) |
|
||||
| `config_id` | int | 所属配置分组 ID |
|
||||
| `type` | str | 配置类型(text/number/select/switch) |
|
||||
| `status` | int | 状态:1-正常 2-停用 |
|
||||
| `sort` | int | 排序权重 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
系统配置适用于以下场景:
|
||||
|
||||
```
|
||||
1. 网站基本信息(站点名称、Logo、版权信息)
|
||||
2. 邮件配置(SMTP 服务器、端口、账号)
|
||||
3. 短信配置(API Key、模板 ID)
|
||||
4. 存储配置(OSS Bucket、上传限制)
|
||||
5. 业务参数(默认密码、分页大小等)
|
||||
```
|
||||
|
||||
## 配置类型
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `text` | 文本输入 | 站点名称、SMTP 服务器 |
|
||||
| `number` | 数字输入 | 端口号、超时时间 |
|
||||
| `select` | 下拉选择 | 存储类型(本地/OSS/S3) |
|
||||
| `switch` | 开关切换 | 是否开启注册、是否开启验证码 |
|
||||
|
||||
## 网站配置
|
||||
|
||||
项目内置了网站配置模块,用于管理网站基本信息:
|
||||
|
||||
```python
|
||||
# application/config_web/models.py
|
||||
class ConfigWeb(BaseModel):
|
||||
"""网站配置模型类"""
|
||||
class Meta:
|
||||
db_table = 'django_config_web'
|
||||
|
||||
name = models.CharField(max_length=150, verbose_name='网站名称')
|
||||
logo = models.CharField(max_length=255, null=True, blank=True, verbose_name='网站Logo')
|
||||
copyright = models.CharField(max_length=255, null=True, blank=True, verbose_name='版权信息')
|
||||
record_number = models.CharField(max_length=255, null=True, blank=True, verbose_name='备案号')
|
||||
# ... 其他字段
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 配置分组
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `/config/page` | GET | `sys:config:page` | 配置分页列表 |
|
||||
| `/config/detail/<int:id>` | GET | `sys:config:detail` | 配置详情 |
|
||||
| `/config/add` | POST | `sys:config:add` | 新增配置 |
|
||||
| `/config/update` | PUT | `sys:config:update` | 编辑配置 |
|
||||
| `/config/delete/<int:id>` | DELETE | `sys:config:delete` | 删除配置 |
|
||||
|
||||
### 配置项
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `/config/item/page` | GET | `sys:configItem:page` | 配置项分页列表 |
|
||||
| `/config/item/detail/<int:id>` | GET | `sys:configItem:detail` | 配置项详情 |
|
||||
| `/config/item/add` | POST | `sys:configItem:add` | 新增配置项 |
|
||||
| `/config/item/update` | PUT | `sys:configItem:update` | 编辑配置项 |
|
||||
| `/config/item/delete/<int:id>` | DELETE | `sys:configItem:delete` | 删除配置项 |
|
||||
| `/config/item/status` | PUT | `sys:configItem:status` | 设置状态 |
|
||||
|
||||
### 网站配置
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `/config/web/detail` | GET | 需登录 | 获取网站配置 |
|
||||
| `/config/web/update` | PUT | `sys:configWeb:update` | 更新网站配置 |
|
||||
|
||||
## 与数据字典的区别
|
||||
|
||||
| 特性 | 数据字典 | 系统配置 |
|
||||
|------|---------|---------|
|
||||
| 用途 | 枚举值(下拉选项) | 系统参数(配置值) |
|
||||
| 值类型 | 简单值(字符串) | 可存储较长的配置内容 |
|
||||
| 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 |
|
||||
| 前端用法 | el-select 选项 | 配置表单 |
|
||||
| 缓存策略 | Redis 缓存 | 按需查询 |
|
||||
| 数据量 | 通常每个字典 < 50 项 | 每个分组 < 20 项 |
|
||||
|
||||
## 前端使用
|
||||
|
||||
### 配置项表单
|
||||
|
||||
配置项根据 `type` 字段动态渲染不同表单组件:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-form-item :label="item.name">
|
||||
<!-- 文本输入 -->
|
||||
<el-input v-if="item.type === 'text'" v-model="item.value" />
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number v-else-if="item.type === 'number'" v-model="item.value" />
|
||||
<!-- 下拉选择 -->
|
||||
<el-select v-else-if="item.type === 'select'" v-model="item.value">
|
||||
<el-option v-for="opt in parseOptions(item.options)" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<!-- 开关 -->
|
||||
<el-switch v-else-if="item.type === 'switch'" v-model="item.value" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
系统配置模块具备以下特点:
|
||||
|
||||
```
|
||||
1. KV 结构:配置分组 + 配置项,灵活管理各类参数
|
||||
2. 动态修改:通过接口修改,无需重启服务
|
||||
3. 分组管理:按功能模块分组,清晰组织配置项
|
||||
4. 类型丰富:支持 text/number/select/switch 多种类型
|
||||
5. 网站配置:内置网站基本信息管理
|
||||
6. 与字典互补:字典管理枚举值,配置管理系统参数
|
||||
```
|
||||
@@ -0,0 +1,232 @@
|
||||
:::tip CORS跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。项目使用 `django-cors-headers` 中间件处理跨域请求,通过 `.env` 配置允许的跨域源。
|
||||
:::
|
||||
|
||||
## 什么是跨域
|
||||
|
||||
浏览器的同源策略限制了不同源(协议+域名+端口)之间的请求。前后端分离架构中,前端(如 `http://localhost:8001`)请求后端(如 `http://127.0.0.1:8000`)会产生跨域问题。
|
||||
|
||||
```
|
||||
同源请求:http://localhost:8000/api/user → http://localhost:8000/api/role ✅
|
||||
跨域请求:http://localhost:8001/api/user → http://localhost:8000/api/role ❌(端口不同)
|
||||
```
|
||||
|
||||
## CORS 预检请求
|
||||
|
||||
对于非简单请求(如 PUT/DELETE 或自定义 Header),浏览器会先发送 OPTIONS 预检请求:
|
||||
|
||||
```
|
||||
浏览器 → OPTIONS /api/user → 服务器
|
||||
浏览器 ← Access-Control-Allow-Origin: * ← 服务器
|
||||
浏览器 → PUT /api/user → 服务器(正式请求)
|
||||
```
|
||||
|
||||
:::tip 预检请求
|
||||
`django-cors-headers` 中间件自动处理 OPTIONS 预检请求,无需手动编写代码。预检请求不会到达视图层,由中间件直接返回。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
在 `settings.py` 中配置 CORS 相关参数:
|
||||
|
||||
```python
|
||||
# application/settings.py
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'corsheaders',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware', # 必须在最前面
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
...
|
||||
]
|
||||
|
||||
# CORS 配置
|
||||
CORS_ALLOW_ALL_ORIGINS = True # 开发环境允许所有源
|
||||
CORS_ALLOW_CREDENTIALS = True # 允许携带凭证(Cookie/Authorization)
|
||||
CORS_ALLOW_METHODS = [ # 允许的请求方法
|
||||
'DELETE', 'GET', 'OPTIONS', 'PATCH', 'POST', 'PUT',
|
||||
]
|
||||
CORS_ALLOW_HEADERS = [ # 允许的请求头
|
||||
'accept-encoding',
|
||||
'authorization', # JWT 认证头
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
'XMLHttpRequest',
|
||||
'X_FILENAME',
|
||||
'Pragma',
|
||||
]
|
||||
```
|
||||
|
||||
## 环境配置
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 开发环境:允许所有源
|
||||
CORS_ALLOW_ALL_ORIGINS=True
|
||||
|
||||
# 生产环境:指定域名
|
||||
# CORS_ALLOW_ALL_ORIGINS=False
|
||||
# CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com
|
||||
```
|
||||
|
||||
:::warning 生产环境
|
||||
生产环境务必设置 `CORS_ALLOW_ALL_ORIGINS=False` 并配置 `CORS_ALLOWED_ORIGINS` 为具体域名,避免允许所有源带来的安全风险。
|
||||
:::
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
### 多域名配置
|
||||
|
||||
```python
|
||||
# 生产环境:指定多个允许的域名
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://admin.example.com",
|
||||
"https://www.example.com",
|
||||
"https://m.example.com",
|
||||
]
|
||||
```
|
||||
|
||||
### 正则匹配
|
||||
|
||||
```python
|
||||
# 允许所有子域名
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r"^https://.*\.example\.com$",
|
||||
]
|
||||
```
|
||||
|
||||
### 禁止凭证
|
||||
|
||||
如果不需要携带 Cookie,可以关闭凭证以提高安全性:
|
||||
|
||||
```python
|
||||
CORS_ALLOW_CREDENTIALS = False # 不允许携带凭证
|
||||
```
|
||||
|
||||
## 中间件顺序
|
||||
|
||||
CORS 中间件必须在 `MIDDLEWARE` 列表中**最前面**注册:
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware', # ← 必须在最前面
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
]
|
||||
```
|
||||
|
||||
:::warning 顺序重要
|
||||
`CorsMiddleware` 必须在所有中间件之前,特别是要在 `CommonMiddleware` 之前。否则 OPTIONS 预检请求可能被 `CommonMiddleware` 拦截,导致 CORS 头未正确添加。
|
||||
:::
|
||||
|
||||
## 前端代理配置
|
||||
|
||||
开发环境通过 Vite 代理解决跨域问题(无需 CORS):
|
||||
|
||||
```javascript
|
||||
// ui/vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 开发环境 vs 生产环境
|
||||
- **开发环境**:Vite 代理将 `/api` 请求转发到后端,浏览器视为同源请求,不触发 CORS
|
||||
- **生产环境**:Nginx 统一处理前端静态资源和 API 转发,同样不触发 CORS
|
||||
- **跨域场景**:当前端和后端部署在不同域名时,才需要 CORS 配置
|
||||
:::
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 问题 1:请求被 CORS 拦截
|
||||
|
||||
**现象**:浏览器控制台报 `Access-Control-Allow-Origin` 错误
|
||||
|
||||
**排查**:
|
||||
```bash
|
||||
# 1. 检查 CORS 中间件是否在最前面
|
||||
grep -n "CorsMiddleware" application/settings.py
|
||||
|
||||
# 2. 检查 CORS 配置
|
||||
grep -n "CORS_ALLOW" application/settings.py
|
||||
|
||||
# 3. 检查 .env 中的配置
|
||||
cat .env | grep CORS
|
||||
```
|
||||
|
||||
### 问题 2:OPTIONS 预检请求返回 403
|
||||
|
||||
**现象**:OPTIONS 请求被权限中间件拦截
|
||||
|
||||
**排查**:
|
||||
```bash
|
||||
# 检查 check_login 是否排除了 OPTIONS 请求
|
||||
# middleware/login_middleware.py 中应放行 OPTIONS
|
||||
```
|
||||
|
||||
### 问题 3:携带自定义 Header 失败
|
||||
|
||||
**现象**:自定义 Header(如 `X-Token`)被拒绝
|
||||
|
||||
**解决**:
|
||||
```python
|
||||
# 在 CORS_ALLOW_HEADERS 中添加自定义 Header
|
||||
CORS_ALLOW_HEADERS = [
|
||||
...
|
||||
'x-token',
|
||||
'custom-header',
|
||||
]
|
||||
```
|
||||
|
||||
### 问题 4:Cookie 未传递
|
||||
|
||||
**现象**:跨域请求中 Cookie 未携带
|
||||
|
||||
**解决**:
|
||||
```python
|
||||
# 1. 后端设置
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# 2. 前端设置(Axios)
|
||||
axios.defaults.withCredentials = true
|
||||
|
||||
# 3. 注意:Allow-Credentials 为 true 时,Allow-Origin 不能为 *
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
```
|
||||
1. 生产环境禁止 CORS_ALLOW_ALL_ORIGINS = True
|
||||
2. 只允许必要的域名和请求方法
|
||||
3. 谨慎使用 CORS_ALLOW_CREDENTIALS = True
|
||||
4. 定期审查 CORS 配置,移除不再使用的域名
|
||||
5. 避免在 CORS_ALLOW_HEADERS 中使用通配符 *
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
CORS 跨域配置具备以下特点:
|
||||
|
||||
```
|
||||
1. django-cors-headers:成熟的 Django CORS 解决方案
|
||||
2. 环境变量配置:通过 .env 灵活配置允许的源
|
||||
3. 开发/生产分离:开发环境允许所有源,生产环境指定域名
|
||||
4. 中间件顺序:必须在 CommonMiddleware 之前
|
||||
5. 前端代理:开发环境通过 Vite 代理解决跨域
|
||||
6. 预检处理:自动处理 OPTIONS 预检请求
|
||||
7. 安全可控:支持域名白名单、正则匹配、凭证控制
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
:::tip 演示模式
|
||||
演示模式用于在线体验环境,开启后所有写操作(新增、编辑、删除等)将被拦截,返回"演示环境,暂无操作权限"提示。通过 `DJANGO_DEMO` 环境变量控制。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `DJANGO_DEMO` | `False` | 是否开启演示模式 |
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 开启演示模式
|
||||
DJANGO_DEMO=True
|
||||
|
||||
# 关闭演示模式(生产环境)
|
||||
DJANGO_DEMO=False
|
||||
```
|
||||
|
||||
## 实现原理
|
||||
|
||||
演示模式采用**视图层检查**的方式实现,在每个写操作的视图方法开头判断配置:
|
||||
|
||||
```python
|
||||
# config/env.py
|
||||
import os
|
||||
DJANGO_DEMO = (os.getenv('DJANGO_DEMO', 'False') == 'True')
|
||||
```
|
||||
|
||||
视图中通过导入 `DJANGO_DEMO` 变量进行判断:
|
||||
|
||||
```python
|
||||
# application/user/views.py
|
||||
from config.env import DJANGO_DEMO
|
||||
|
||||
class UserAddView(PermissionRequired, View):
|
||||
permission_required = ("sys:user:add",)
|
||||
|
||||
def post(self, request):
|
||||
# 演示环境禁止操作
|
||||
if DJANGO_DEMO:
|
||||
return R.failed("演示环境,暂无操作权限")
|
||||
result = services.add_user(request)
|
||||
return result
|
||||
```
|
||||
|
||||
:::warning 为什么不用装饰器?
|
||||
演示模式没有使用装饰器实现,原因是:不同模块对"写操作"的定义略有差异(有的模块有导入/导出,有的有授权操作),在视图层直接判断更灵活,可以精确控制每个接口的行为。
|
||||
:::
|
||||
|
||||
## 受保护的操作
|
||||
|
||||
演示模式拦截以下操作(写操作):
|
||||
|
||||
```
|
||||
- 新增(add)
|
||||
- 编辑(update)
|
||||
- 删除(delete / batchDelete)
|
||||
- 状态设置(status)
|
||||
- 重置密码(resetPwd)
|
||||
- 修改密码(updatePwd)
|
||||
- 导入(import)
|
||||
- 授权(auth / assignMenu)
|
||||
- 保存(save)
|
||||
```
|
||||
|
||||
不受影响的操作:
|
||||
|
||||
```
|
||||
- 登录(login)
|
||||
- 获取验证码(captcha)
|
||||
- 查询列表(list/page)
|
||||
- 查询详情(detail)
|
||||
- 导出(export)
|
||||
- 获取菜单(getMenus)
|
||||
- 获取用户信息(getUser)
|
||||
```
|
||||
|
||||
## 各模块演示模式配置
|
||||
|
||||
| 模块 | 拦截的视图 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 用户管理 | add/update/delete/status/resetPwd | 新增/编辑/删除/状态/重置密码 |
|
||||
| 角色管理 | add/update/delete/status/assignMenu | 新增/编辑/删除/状态/分配菜单 |
|
||||
| 菜单管理 | add/update/delete | 新增/编辑/删除 |
|
||||
| 部门管理 | add/update/delete | 新增/编辑/删除 |
|
||||
| 字典管理 | add/update/delete | 新增/编辑/删除(含字典项) |
|
||||
| 配置管理 | add/update/delete | 新增/编辑/删除(含配置项) |
|
||||
| 文章管理 | add/update/delete/status | 新增/编辑/删除/状态 |
|
||||
| 登录日志 | delete | 删除(保留查看能力) |
|
||||
| 操作日志 | delete | 删除(保留查看能力) |
|
||||
|
||||
## 自定义演示模式提示
|
||||
|
||||
如需修改演示模式的提示信息,直接修改对应视图中的提示文本:
|
||||
|
||||
```python
|
||||
# 自定义提示信息
|
||||
if DJANGO_DEMO:
|
||||
return R.failed("当前为演示环境,如需操作请联系管理员")
|
||||
```
|
||||
|
||||
## 前端配合处理
|
||||
|
||||
前端可通过响应中的 `msg` 字段识别演示模式拦截,并展示相应提示:
|
||||
|
||||
```typescript
|
||||
// 前端拦截器已统一处理 code=1 的响应,自动弹出 ElMessage.error(msg)
|
||||
// 无需额外处理,演示模式的提示会自动显示
|
||||
```
|
||||
|
||||
## 适用场景
|
||||
|
||||
```
|
||||
1. 在线体验:让用户无需注册即可体验系统功能
|
||||
2. 演示环境:销售或产品演示时防止误操作
|
||||
3. 公开测试:开放测试环境但保护数据安全
|
||||
4. 试用评估:潜在客户试用系统但不允许修改数据
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
```
|
||||
1. 开发环境:DJANGO_DEMO=False,允许所有操作
|
||||
2. 测试环境:DJANGO_DEMO=False,允许所有操作
|
||||
3. 演示环境:DJANGO_DEMO=True,只读模式
|
||||
4. 生产环境:DJANGO_DEMO=False,通过权限控制访问
|
||||
```
|
||||
|
||||
:::warning 注意事项
|
||||
演示模式和权限控制是两套独立的机制:
|
||||
- 权限控制:决定用户**是否有权限**执行某操作
|
||||
- 演示模式:决定**所有用户**是否可以执行写操作
|
||||
|
||||
两者同时生效,即使有写权限的用户,在演示模式下也会被拦截。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
演示模式具备以下特点:
|
||||
|
||||
```
|
||||
1. 环境变量控制:DJANGO_DEMO=True 开启,False 关闭
|
||||
2. 写操作拦截:在视图中检查配置,拦截所有写操作
|
||||
3. 只读体验:查询操作不受影响,用户可正常浏览数据
|
||||
4. 简单实现:无需装饰器,在视图函数中直接判断即可
|
||||
5. 灵活配置:可按模块精确控制拦截范围
|
||||
6. 与权限独立:和 RBAC 权限体系互不影响
|
||||
```
|
||||
@@ -0,0 +1,156 @@
|
||||
:::tip 本章概要
|
||||
数据字典的完整使用流程,从新增字典类型到在代码中使用字典数据的实操指南。
|
||||
:::
|
||||
|
||||
# 数据字典使用指南
|
||||
|
||||
数据字典是项目的基础配置功能,用于管理枚举类型的下拉选项。本章从「如何新增字典」到「如何在代码中使用」的完整实操流程。
|
||||
|
||||
## 数据字典结构
|
||||
|
||||
数据字典由两层组成:
|
||||
|
||||
```
|
||||
字典类型(dict) 字典项(dict_item)
|
||||
+-- sys_user_status +-- 1 -> 正常
|
||||
+-- sys_user_gender +-- 2 -> 停用
|
||||
+-- article_type +-- 3 -> 删除
|
||||
+-- ...
|
||||
```
|
||||
|
||||
| 层级 | 表 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| 字典类型 | `django_dict` | 字典分类(如「用户状态」「文章类型」) |
|
||||
| 字典项 | `django_dict_item` | 字典的具体选项(如「1-正常」「2-停用」) |
|
||||
|
||||
## 实操:新增一个字典类型
|
||||
|
||||
### 场景
|
||||
|
||||
需要为「培训方式」模块添加一个下拉选项:`1-线上 2-线下 3-混合`。
|
||||
|
||||
### 第 1 步:在后台添加字典类型
|
||||
|
||||
登录管理后台,进入「系统管理 -> 数据字典」:
|
||||
|
||||
1. 点击「新增」按钮
|
||||
2. 填写信息:
|
||||
|
||||
| 字段 | 值 |
|
||||
|:----|:----|
|
||||
| 字典名称 | 培训方式 |
|
||||
| 字典编码 | training_method |
|
||||
| 状态 | 正常 |
|
||||
|
||||
3. 保存
|
||||
|
||||
### 第 2 步:添加字典项
|
||||
|
||||
在字典类型列表中点击「培训方式」,进入字典项管理:
|
||||
|
||||
1. 添加字典项:
|
||||
|
||||
| 字典值 | 字典标签 | 排序 |
|
||||
|:----|:----|:----|
|
||||
| 1 | 线上 | 1 |
|
||||
| 2 | 线下 | 2 |
|
||||
| 3 | 混合 | 3 |
|
||||
|
||||
2. 保存
|
||||
|
||||
## 在后端代码中使用
|
||||
|
||||
### 方式 1:通过字典编码获取字典项
|
||||
|
||||
```python
|
||||
from application.dict_item import services as dict_item_services
|
||||
|
||||
# 获取字典项列表
|
||||
items = dict_item_services.get_by_dict_code('training_method')
|
||||
# 返回: [{"label": "线上", "value": "1"}, {"label": "线下", "value": "2"}, {"label": "混合", "value": "3"}]
|
||||
```
|
||||
|
||||
### 方式 2:在 service 中获取字典值翻译
|
||||
|
||||
```python
|
||||
# 在分页查询中为记录补充字典名称
|
||||
def _enrich_records(records):
|
||||
status_map = dict_item_services.get_dict_map('sys_status')
|
||||
for record in records:
|
||||
record['statusName'] = status_map.get(str(record.get('status')), '未知')
|
||||
return records
|
||||
```
|
||||
|
||||
## 在前端代码中使用
|
||||
|
||||
### 方式 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 { useDictStore } from '@/store/dict';
|
||||
|
||||
const dictStore = useDictStore();
|
||||
const dictOptions = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
dictOptions.value = await dictStore.loadDict('training_method');
|
||||
});
|
||||
</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] || '-'
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 代码生成器与字典
|
||||
|
||||
代码生成器会自动识别字段注释中的枚举格式(`1-线上 2-线下 3-混合`),并:
|
||||
|
||||
1. 自动解析 `choices` 列表
|
||||
2. 自动生成 `dict_code`(格式:`{table_name}_{field_name}`)
|
||||
3. 前端自动生成下拉选择框
|
||||
|
||||
生成后需在后台手动创建对应的字典类型和字典项。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **字典编码命名**:使用 `模块_字段` 格式,如 `user_status`、`article_type`
|
||||
2. **字典值使用整数**:便于排序和比较
|
||||
3. **字典标签简洁**:控制在 4 个字以内,适合表格列显示
|
||||
4. **状态字段统一**:`1-正常 2-停用` 全项目保持一致
|
||||
5. **生成后补充字典**:代码生成器创建的 `dict_code` 需在后台手动创建对应数据
|
||||
|
||||
## 总结
|
||||
|
||||
数据字典通过「字典类型 + 字典项」两层结构管理枚举选项。后端通过字典编码获取选项列表,前端通过 useDictStore 获取下拉数据。代码生成器会自动识别注释中的枚举格式并生成对应配置。
|
||||
@@ -0,0 +1,182 @@
|
||||
:::tip 数据字典
|
||||
数据字典用于管理系统中的枚举值和配置项,如状态、类型、性别等。通过字典编码统一管理,前端可通过 `useDictStore` 获取字典选项,避免硬编码。模块位于 `application/dict/`。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
application/dict/
|
||||
├── models.py # 字典模型
|
||||
├── forms.py # 表单验证
|
||||
├── services.py # 业务逻辑层
|
||||
├── views.py # 视图层
|
||||
└── urls.py # 路由配置
|
||||
|
||||
application/dict_item/
|
||||
├── models.py # 字典项模型
|
||||
├── forms.py # 表单验证
|
||||
├── services.py # 业务逻辑层
|
||||
├── views.py # 视图层
|
||||
└── urls.py # 路由配置
|
||||
```
|
||||
|
||||
## 字典模型
|
||||
|
||||
### 字典类型(Dict)
|
||||
|
||||
```python
|
||||
# application/dict/models.py
|
||||
class Dict(BaseModel):
|
||||
"""字典模型类"""
|
||||
class Meta:
|
||||
db_table = 'django_dict'
|
||||
|
||||
name = models.CharField(max_length=150, verbose_name='字典名称')
|
||||
code = models.CharField(max_length=150, verbose_name='字典编码')
|
||||
sort = models.IntegerField(default=0, verbose_name='字典排序')
|
||||
note = models.CharField(max_length=255, null=True, blank=True, verbose_name='字典备注')
|
||||
```
|
||||
|
||||
### 字典项(DictItem)
|
||||
|
||||
```python
|
||||
# application/dict_item/models.py
|
||||
class DictItem(BaseModel):
|
||||
"""字典项模型类"""
|
||||
class Meta:
|
||||
db_table = 'django_dict_item'
|
||||
|
||||
name = models.CharField(max_length=150, verbose_name='字典项名称')
|
||||
value = models.CharField(max_length=150, verbose_name='字典项值')
|
||||
dict_id = models.IntegerField(default=0, verbose_name='字典ID')
|
||||
sort = models.IntegerField(default=0, verbose_name='字典项顺序')
|
||||
note = models.CharField(max_length=255, null=True, blank=True, verbose_name='字典项备注')
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 字典管理
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `/dict/page` | GET | `sys:dict:page` | 字典分页列表 |
|
||||
| `/dict/detail/<int:id>` | GET | `sys:dict:detail` | 字典详情 |
|
||||
| `/dict/add` | POST | `sys:dict:add` | 新增字典 |
|
||||
| `/dict/update` | PUT | `sys:dict:update` | 编辑字典 |
|
||||
| `/dict/delete/<int:id>` | DELETE | `sys:dict:delete` | 删除字典 |
|
||||
|
||||
### 字典项管理
|
||||
|
||||
| 接口 | 方法 | 权限节点 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `/dict/item/page` | GET | `sys:dictItem:page` | 字典项分页列表 |
|
||||
| `/dict/item/detail/<int:id>` | GET | `sys:dictItem:detail` | 字典项详情 |
|
||||
| `/dict/item/getDictItemList/{code}` | GET | 需登录 | 按编码获取字典项 |
|
||||
| `/dict/item/add` | POST | `sys:dictItem:add` | 新增字典项 |
|
||||
| `/dict/item/update` | PUT | `sys:dictItem:update` | 编辑字典项 |
|
||||
| `/dict/item/delete/<int:id>` | DELETE | `sys:dictItem:delete` | 删除字典项 |
|
||||
|
||||
## 后端获取字典
|
||||
|
||||
```python
|
||||
# 通过字典编码获取字典项列表
|
||||
from application.dict_item import services as dict_item_services
|
||||
|
||||
# 获取状态字典
|
||||
status_list = dict_item_services.get_by_dict_code("sys_status")
|
||||
# 返回: [{"label": "正常", "value": "1"}, {"label": "停用", "value": "2"}]
|
||||
```
|
||||
|
||||
## 前端使用
|
||||
|
||||
前端通过 `useDictStore` 获取字典数据:
|
||||
|
||||
```typescript
|
||||
import { useDictStore } from '@/store/dict'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
// 获取字典选项
|
||||
const statusOptions = dictStore.getDict('sys_status')
|
||||
// [{label: "正常", value: "1"}, {label: "停用", "value": "2"}]
|
||||
```
|
||||
|
||||
### 在组件中使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- el-select 下拉选择 -->
|
||||
<el-select v-model="form.status" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- el-tag 标签展示 -->
|
||||
<el-tag v-if="form.status === '1'">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 字典缓存
|
||||
|
||||
前端字典数据通过 Pinia Store 缓存,首次获取后缓存在内存中:
|
||||
|
||||
```typescript
|
||||
// store/dict.ts
|
||||
export const useDictStore = defineStore('dict', {
|
||||
state: () => ({
|
||||
dictMap: new Map<string, any[]>(),
|
||||
}),
|
||||
actions: {
|
||||
async getDict(code: string) {
|
||||
if (this.dictMap.has(code)) {
|
||||
return this.dictMap.get(code);
|
||||
}
|
||||
const res = await getDictItemList(code);
|
||||
this.dictMap.set(code, res);
|
||||
return res;
|
||||
},
|
||||
// 清除缓存(字典数据变更时调用)
|
||||
clearDict() {
|
||||
this.dictMap.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 常用字典示例
|
||||
|
||||
| 字典编码 | 说明 | 字典项 |
|
||||
|---------|------|--------|
|
||||
| `sys_status` | 系统状态 | 正常(1)、停用(2) |
|
||||
| `sys_gender` | 性别 | 男(1)、女(2) |
|
||||
| `operation_type` | 操作类型 | 新增(1)、修改(2)、删除(3) 等 |
|
||||
| `menu_type` | 菜单类型 | 菜单(0)、节点(1) |
|
||||
|
||||
## Redis 缓存机制
|
||||
|
||||
字典数据支持 Redis 缓存,减少数据库查询:
|
||||
|
||||
```
|
||||
1. 首次查询:从数据库读取 → 写入 Redis 缓存 → 返回
|
||||
2. 后续查询:从 Redis 缓存读取 → 返回
|
||||
3. 数据变更:更新数据库 → 清除 Redis 缓存
|
||||
4. 缓存 Key:dict:{code},如 dict:sys_status
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
数据字典模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理
|
||||
2. 前端集成:useDictStore 统一管理,el-select 直接绑定
|
||||
3. 值为字符串:字典项 value 统一为字符串类型
|
||||
4. 避免硬编码:枚举值统一走字典,修改无需改代码
|
||||
5. Redis 缓存:减少数据库查询,提升性能
|
||||
6. 批量导入导出:支持 Excel 批量导入字典项
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
:::tip 全局异常与响应
|
||||
定义了统一的响应格式和全局异常处理机制。所有 API 返回 `{code, data, msg, ok}` 标准格式。响应函数位于 `utils/R.py`。
|
||||
:::
|
||||
|
||||
## 统一响应格式
|
||||
|
||||
`utils/R.py` 提供统一的响应生成函数:
|
||||
|
||||
```python
|
||||
from utils import R
|
||||
|
||||
# 成功响应
|
||||
R.ok(data={"id": 1}, msg="操作成功")
|
||||
# {"code": 0, "data": {"id": 1}, "msg": "操作成功", "ok": true}
|
||||
|
||||
# 失败响应
|
||||
R.failed(msg="参数错误")
|
||||
# {"code": 1, "data": null, "msg": "参数错误", "ok": false}
|
||||
|
||||
# 带扩展参数的响应
|
||||
R.ok(data=list, total=100)
|
||||
# {"code": 0, "data": [...], "msg": "操作成功", "ok": true, "total": 100}
|
||||
```
|
||||
|
||||
### 响应函数说明
|
||||
|
||||
| 函数 | 说明 | code | ok |
|
||||
|------|------|------|------|
|
||||
| `R.ok()` | 成功响应 | 0 | true |
|
||||
| `R.failed()` | 失败响应 | 1 | false |
|
||||
| `R.response()` | 通用响应 | 自定义 | 自定义 |
|
||||
|
||||
函数签名:
|
||||
|
||||
```python
|
||||
def ok(data=None, msg="操作成功", code=0, **kwargs) -> JsonResponse:
|
||||
"""生成成功响应,支持通过 kwargs 添加额外字段(如 total)"""
|
||||
|
||||
def failed(msg="操作失败", code=1, data=None, **kwargs) -> JsonResponse:
|
||||
"""生成失败响应"""
|
||||
|
||||
def response(data=None, msg="操作成功", code=0, success=True, **kwargs) -> JsonResponse:
|
||||
"""通用响应生成器(高级用法),允许完全自定义字段"""
|
||||
```
|
||||
|
||||
### 分页响应
|
||||
|
||||
分页数据通过 `R.ok()` 传递:
|
||||
|
||||
```python
|
||||
# application/level/services.py
|
||||
def get_level_page(request):
|
||||
# ... 分页查询逻辑 ...
|
||||
return R.ok(data={
|
||||
"records": records,
|
||||
"total": total,
|
||||
"size": size,
|
||||
"current": current,
|
||||
"pages": pages
|
||||
})
|
||||
```
|
||||
|
||||
## 状态码常量
|
||||
|
||||
```python
|
||||
# utils/R.py
|
||||
class Codes:
|
||||
SUCCESS = 0
|
||||
FAILED = 1
|
||||
UNAUTHORIZED = 401 # 未认证
|
||||
FORBIDDEN = 403 # 无权限
|
||||
NOT_FOUND = 404 # 资源不存在
|
||||
VALIDATE_ERROR = 422 # 验证错误
|
||||
SERVER_ERROR = 500 # 服务器错误
|
||||
```
|
||||
|
||||
## Services 层异常处理
|
||||
|
||||
在 service 层使用 try/except 捕获异常:
|
||||
|
||||
```python
|
||||
# application/level/services.py
|
||||
def add_level(request):
|
||||
try:
|
||||
data = parse_request_body(request)
|
||||
form = LevelForm(data)
|
||||
|
||||
if not form.is_valid():
|
||||
return R.failed(msg=regular.get_err(form))
|
||||
|
||||
# 业务逻辑...
|
||||
Level.objects.create(**form.cleaned_data)
|
||||
return R.ok(msg="添加成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加职级失败: {str(e)}")
|
||||
return R.failed(msg="添加失败")
|
||||
```
|
||||
|
||||
## 全局异常处理
|
||||
|
||||
Django 通过中间件处理全局异常:
|
||||
|
||||
```python
|
||||
# middleware/exception_middleware.py
|
||||
class ExceptionMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"系统异常: {str(e)}")
|
||||
return R.failed(msg="服务器内部错误", code=500)
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
全局异常与响应模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 统一格式:所有 API 返回 {code, data, msg, ok} 标准格式
|
||||
2. R.ok()/R.failed():简洁的响应生成函数
|
||||
3. Codes 常量:定义常用状态码
|
||||
4. kwargs 扩展:支持通过 kwargs 添加额外字段
|
||||
5. Services try/except:在 service 层捕获异常,返回友好提示
|
||||
6. 全局异常:中间件捕获未处理异常,返回统一格式
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
:::tip 防重复提交
|
||||
防重复提交是保障数据一致性的重要措施。通过前端防抖和后端唯一性校验双重保护,防止用户重复点击导致的数据重复提交。
|
||||
:::
|
||||
|
||||
## 前端防抖
|
||||
|
||||
### useLockFn
|
||||
|
||||
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
|
||||
|
||||
```javascript
|
||||
import { useLockFn } from '@/utils/lock'
|
||||
|
||||
// 包装提交函数,防止重复点击
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
await axios.post('/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('/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 后端唯一性校验
|
||||
|
||||
### Django Form 唯一性
|
||||
|
||||
在 service 层查询数据库校验唯一性:
|
||||
|
||||
```python
|
||||
# application/level/services.py
|
||||
def add_level(request):
|
||||
data = parse_request_body(request)
|
||||
form = LevelForm(data)
|
||||
|
||||
if not form.is_valid():
|
||||
return R.failed(msg=regular.get_err(form))
|
||||
|
||||
# 唯一性校验
|
||||
name = data.get('name')
|
||||
if Level.objects.filter(name=name, is_delete=False).exists():
|
||||
return R.failed("职级名称不能重复")
|
||||
|
||||
# 保存数据...
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
### 软删除 + 唯一索引
|
||||
|
||||
软删除场景下,唯一索引需要包含 `is_delete` 字段:
|
||||
|
||||
```sql
|
||||
-- MySQL: 唯一索引包含 is_delete 字段
|
||||
ALTER TABLE django_level ADD UNIQUE INDEX uk_name (name, is_delete);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
```
|
||||
1. 前端防抖:useLockFn 或按钮 loading,防止用户重复点击
|
||||
2. 后端校验:service 层查询数据库校验唯一性
|
||||
3. 数据库约束:唯一索引兜底,防止并发重复插入
|
||||
4. 错误提示:重复提交时返回明确的错误提示
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
防重复提交方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 前端防抖:useLockFn 函数级防抖,按钮 loading 状态控制
|
||||
2. 后端校验:service 层查询数据库校验唯一性
|
||||
3. 数据库约束:唯一索引兜底,防止并发重复插入
|
||||
4. 双重保护:前端防抖 + 后端校验,确保数据一致性
|
||||
5. 用户体验:重复点击时按钮禁用,返回明确提示
|
||||
```
|
||||
@@ -0,0 +1,334 @@
|
||||
:::tip JWT认证
|
||||
JWT(JSON Web Token)是系统的核心认证机制。用户登录成功后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头携带 Token 进行身份验证。项目内置了 Token 生成、解析、刷新和黑名单管理功能,源码位于 `utils/jwt.py`。
|
||||
:::
|
||||
|
||||
## 认证流程
|
||||
|
||||
```
|
||||
1. 前端发送 POST /login/login(username + password + captcha)
|
||||
2. 后端校验验证码、用户名密码
|
||||
3. 登录成功 → create_token() 生成 JWT
|
||||
4. 前端存储 Token,后续请求携带 Authorization: Bearer <token>
|
||||
5. check_login 装饰器解析 Token → 验证有效性
|
||||
6. 退出登录 → Token 加入 Redis 黑名单立即失效
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `JWT_SALT` | 内置默认密钥 | JWT 签名密钥,建议至少32字节 |
|
||||
| `DEFAULT_TIMEOUT_MINUTES` | `20` | Token 过期时间(分钟) |
|
||||
|
||||
:::warning 密钥安全
|
||||
生产环境务必通过 `.env` 设置 `JWT_SALT` 为强随机密钥。生成方式:
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
:::
|
||||
|
||||
## Token 生成
|
||||
|
||||
`utils/jwt.py` 中的 `create_token()` 函数负责生成 JWT:
|
||||
|
||||
```python
|
||||
from utils.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
|
||||
|
||||
返回:
|
||||
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 utils.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
|
||||
|
||||
# 检查Token是否在黑名单中
|
||||
if is_token_blacklisted(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 | 算法不匹配等 |
|
||||
|
||||
## Token 刷新
|
||||
|
||||
Token 支持无感刷新,刷新时会生成新令牌:
|
||||
|
||||
```python
|
||||
from utils.jwt import refresh_token
|
||||
|
||||
result = refresh_token(old_token, extend_minutes=30)
|
||||
if result['code'] == 0:
|
||||
new_token = result['data']['access_token']
|
||||
expires_in = result['data']['expires_in'] # 秒
|
||||
```
|
||||
|
||||
刷新核心逻辑:
|
||||
|
||||
```python
|
||||
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. 移除JWT标准字段,保留业务数据
|
||||
payload = result['data']
|
||||
clean_payload = {k: v for k, v in payload.items() if k not in ['exp', 'iat', 'nbf']}
|
||||
|
||||
# 3. 生成新令牌
|
||||
new_token = create_token(clean_payload, timeout=extend_minutes)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {"access_token": new_token, "expires_in": extend_minutes * 60},
|
||||
"msg": "令牌刷新成功"
|
||||
}
|
||||
```
|
||||
|
||||
## Token 黑名单
|
||||
|
||||
退出登录时将 Token 加入 Redis 黑名单,使其立即失效:
|
||||
|
||||
```python
|
||||
from utils.jwt import add_token_to_blacklist, is_token_blacklisted
|
||||
|
||||
# 退出登录:加入黑名单
|
||||
add_token_to_blacklist(token)
|
||||
|
||||
# 中间件校验:检查是否已注销
|
||||
if 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()
|
||||
|
||||
def add_token_to_blacklist(token: str) -> bool:
|
||||
"""退出登录时调用,TTL 与 Token 剩余有效期一致"""
|
||||
from django_redis import get_redis_connection
|
||||
redis = get_redis_connection("default")
|
||||
|
||||
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}'
|
||||
redis.set(key, '1', ex=ttl_seconds)
|
||||
|
||||
def is_token_blacklisted(token: str) -> bool:
|
||||
"""检查 Token 是否已注销,Redis 不可用时 fail-open 放行"""
|
||||
try:
|
||||
from django_redis import get_redis_connection
|
||||
redis = get_redis_connection("default")
|
||||
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
return 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 utils.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.META.get('HTTP_AUTHORIZATION', '')
|
||||
stripped = auth_header.strip()
|
||||
if stripped.lower().startswith('bearer '):
|
||||
access_token = stripped[7:].strip()
|
||||
else:
|
||||
access_token = stripped
|
||||
return True, access_token, 'success'
|
||||
```
|
||||
|
||||
## check_login 装饰器
|
||||
|
||||
`middleware/login_middleware.py` 中的 `check_login` 是全局认证装饰器,应用于所有需要登录的视图:
|
||||
|
||||
```python
|
||||
from middleware.login_middleware import check_login
|
||||
|
||||
@method_decorator(check_login, name='dispatch')
|
||||
class UserPageView(PermissionRequired, View):
|
||||
def get(self, request):
|
||||
# 只有已登录用户才能访问
|
||||
...
|
||||
```
|
||||
|
||||
认证成功后,业务代码通过 `utils/security.py` 获取当前用户信息:
|
||||
|
||||
```python
|
||||
from utils.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字节)
|
||||
2. 黑名单管理:SHA256 指纹存储,TTL 自动清理,Redis 故障时 fail-open 降级
|
||||
3. 全局统一:check_login 装饰器 + security 工具函数,业务代码零侵入
|
||||
4. Bearer 标准:RFC 7235 规范,前缀大小写不敏感
|
||||
5. Django 原生:基于 django-redis 连接池,与 Django ORM 无缝集成
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
:::tip 登录日志
|
||||
登录日志记录每次登录尝试(成功/失败),包含登录账号、IP地址、浏览器信息、登录时间等。登录逻辑位于 `application/login/services.py`,日志记录位于 `application/login_log/` 模块。
|
||||
:::
|
||||
|
||||
## 记录时机
|
||||
|
||||
登录日志在以下时机自动记录:
|
||||
|
||||
```
|
||||
1. 登录成功 -> 记录成功日志(status=0, logType=1)
|
||||
2. 登录失败(密码错误)-> 记录失败日志(status=1, logType=1)
|
||||
3. 登录失败(用户不存在)-> 记录失败日志(status=1, logType=1)
|
||||
4. 登录失败(验证码错误)-> 记录失败日志(status=1, logType=1)
|
||||
5. 退出登录 -> 记录成功日志(status=0, logType=2)
|
||||
```
|
||||
|
||||
## 日志字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `username` | str | 登录账号 |
|
||||
| `logType` | int | 日志类型:1-登录 2-退出 |
|
||||
| `source` | int | 登录来源:0-后台系统 1-手机站 2-客户端 |
|
||||
| `method` | str | 请求方法 |
|
||||
| `requestMethod` | str | HTTP 请求方法 |
|
||||
| `url` | str | 请求路径 |
|
||||
| `param` | str | 请求参数(截取前2000字符) |
|
||||
| `result` | str | 响应结果(截取前2000字符) |
|
||||
| `ip` | str | 客户端 IP 地址 |
|
||||
| `location` | str | IP 地理位置(ip2region 解析) |
|
||||
| `os` | str | 操作系统 |
|
||||
| `browser` | str | 浏览器类型 |
|
||||
| `consumeTime` | int | 请求耗时(毫秒) |
|
||||
| `status` | int | 操作状态:0-成功 1-异常 |
|
||||
| `error` | str | 错误信息 |
|
||||
| `createUser` | str | 操作人 |
|
||||
| `createTime` | datetime | 记录时间 |
|
||||
|
||||
## 记录实现
|
||||
|
||||
登录日志通过 `record_login_log()` 函数记录,自动提取客户端信息:
|
||||
|
||||
```python
|
||||
# application/login_log/services.py
|
||||
from utils.ip2region import get_ip_location
|
||||
from utils.common import get_client_ip, parse_user_agent, format_params, format_result
|
||||
|
||||
def record_login_log(request, username, log_type, status, source=0,
|
||||
error_msg=None, consume_time=0, result=None):
|
||||
"""记录登录日志"""
|
||||
# 获取客户端 IP
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# 获取地理位置信息(ip2region 直接返回字符串)
|
||||
location_str = get_ip_location(client_ip)
|
||||
|
||||
# 解析 User-Agent 信息
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
ua_info = parse_user_agent(user_agent)
|
||||
|
||||
# 构建日志数据
|
||||
log_data = {
|
||||
'username': username,
|
||||
'log_type': log_type,
|
||||
'source': source,
|
||||
'method': request.method,
|
||||
'request_method': request.method,
|
||||
'url': request.path,
|
||||
'ip': client_ip,
|
||||
'location': location_str,
|
||||
'os': ua_info['os'],
|
||||
'browser': ua_info['browser'],
|
||||
'consume_time': consume_time,
|
||||
'status': status,
|
||||
'error': error_msg,
|
||||
'create_user': username,
|
||||
'update_user': username,
|
||||
}
|
||||
|
||||
LoginLog.objects.create(**log_data)
|
||||
```
|
||||
|
||||
## IP 地理位置解析
|
||||
|
||||
框架集成 ip2region 库,自动将 IP 解析为地理位置:
|
||||
|
||||
```python
|
||||
# utils/ip2region.py
|
||||
from utils.ip2region import get_ip_location
|
||||
|
||||
location = get_ip_location("116.25.146.177")
|
||||
# 返回: "广东省深圳市"
|
||||
```
|
||||
|
||||
:::tip ip2region
|
||||
ip2region 是离线 IP 地理位置解析库,数据文件存储在 `utils/ip2region.xdb`,无需网络请求,解析速度极快。支持 IPv4 地址,覆盖国内省市和运营商信息。
|
||||
:::
|
||||
|
||||
## User-Agent 解析
|
||||
|
||||
自动从 User-Agent 字符串中提取操作系统和浏览器信息:
|
||||
|
||||
```python
|
||||
# utils/common.py
|
||||
def parse_user_agent(user_agent):
|
||||
"""解析 User-Agent,返回操作系统和浏览器信息"""
|
||||
# 示例返回值
|
||||
return {
|
||||
'os': 'Windows 10',
|
||||
'browser': 'Chrome 120.0'
|
||||
}
|
||||
```
|
||||
|
||||
## 分页查询接口
|
||||
|
||||
```
|
||||
GET /login/log/page?pageNo=1&pageSize=10&username=admin&type=1&status=0
|
||||
```
|
||||
|
||||
### 查询参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `pageNo` | int | 页码(默认 1) |
|
||||
| `pageSize` | int | 每页条数(默认 10) |
|
||||
| `username` | str | 登录账号(模糊查询) |
|
||||
| `type` | int | 日志类型:1-登录 2-退出 |
|
||||
| `status` | int | 操作状态:0-成功 1-异常 |
|
||||
| `startTime` | str | 开始时间 |
|
||||
| `endTime` | str | 结束时间 |
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"records": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "用户登录",
|
||||
"username": "admin",
|
||||
"logType": 1,
|
||||
"logTypeText": "登录",
|
||||
"ip": "127.0.0.1",
|
||||
"location": "本机地址",
|
||||
"os": "Windows 10",
|
||||
"browser": "Chrome 120.0",
|
||||
"status": 0,
|
||||
"statusText": "成功",
|
||||
"createTime": "2026-09-08 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"size": 10,
|
||||
"current": 1,
|
||||
"pages": 10
|
||||
},
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## 详情查询接口
|
||||
|
||||
```
|
||||
GET /login/log/detail/<int:log_id>
|
||||
```
|
||||
|
||||
详情接口额外返回 `param`(请求参数)和 `result`(响应结果)字段,参数会自动格式化为 JSON 便于前端展示。
|
||||
|
||||
## 删除接口
|
||||
|
||||
```
|
||||
DELETE /login/log/delete/<str:log_id>
|
||||
```
|
||||
|
||||
支持批量删除,多个 ID 用逗号分隔:`DELETE /login/log/delete/1,2,3`
|
||||
|
||||
:::warning 权限要求
|
||||
- 分页查询:`sys:loginlog:page`
|
||||
- 详情查询:`sys:loginlog:detail`
|
||||
- 删除:`sys:loginlog:delete`
|
||||
:::
|
||||
|
||||
## 与操作日志的区别
|
||||
|
||||
| 维度 | 登录日志 | 操作日志 |
|
||||
|------|---------|---------|
|
||||
| 记录内容 | 登录/退出行为 | 业务操作行为 |
|
||||
| 记录方式 | 登录/退出时手动调用 | `@operation_log` 装饰器自动记录 |
|
||||
| 触发时机 | 登录成功/失败、退出登录 | 任何标记了装饰器的视图方法 |
|
||||
| 数据来源 | `application/login_log/` | `application/operation_log/` |
|
||||
| 权限标识 | `sys:loginlog:*` | `sys:operationLog:*` |
|
||||
|
||||
## 安全审计场景
|
||||
|
||||
### 异常登录检测
|
||||
|
||||
通过查询登录日志,可识别以下异常场景:
|
||||
|
||||
```
|
||||
1. 同一账号短时间内多次登录失败 -> 可能是暴力破解
|
||||
2. 同一 IP 大量不同账号登录 -> 可能是撞库攻击
|
||||
3. 异地登录(IP 地理位置变化)-> 可能是账号被盗
|
||||
4. 非工作时间登录 -> 可能是异常行为
|
||||
```
|
||||
|
||||
### 排查命令
|
||||
|
||||
```bash
|
||||
# 查询最近 1 小时内登录失败的记录
|
||||
GET /login/log/page?status=1&type=1&startTime=2026-09-08 09:00:00&endTime=2026-09-08 10:00:00
|
||||
|
||||
# 查询特定账号的登录记录
|
||||
GET /login/log/page?username=admin
|
||||
|
||||
# 查询特定 IP 的登录记录
|
||||
GET /login/log/page?ip=192.168.1.100
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
登录日志模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 自动记录:登录成功/失败/退出自动记录,无需手动埋点
|
||||
2. IP 解析:ip2region 离线解析,速度快、无网络依赖
|
||||
3. UA 解析:自动提取浏览器和操作系统信息
|
||||
4. 地理位置:自动解析 IP 所在地区
|
||||
5. 安全审计:完整记录登录行为,支持异常登录检测
|
||||
6. 批量管理:支持按条件筛选、批量删除、清空操作
|
||||
```
|
||||
@@ -0,0 +1,207 @@
|
||||
:::tip 操作日志
|
||||
操作日志通过 `@operation_log` 装饰器标记需要记录日志的视图方法,自动记录请求参数、响应结果、耗时、操作类型等信息。源码位于 `application/operation_log/decorators.py`。
|
||||
:::
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
1. 视图方法通过 @operation_log(title=..., log_type=LogType.XXX) 标记
|
||||
2. 装饰器在方法执行前记录开始时间、提取请求参数
|
||||
3. 执行原方法,获取响应
|
||||
4. 检查响应是否成功(解析 JSON 中的 code 字段)
|
||||
5. 记录操作日志到数据库(含参数、结果、耗时、状态)
|
||||
6. 异常时记录错误信息并重新抛出
|
||||
```
|
||||
|
||||
## 装饰器使用
|
||||
|
||||
```python
|
||||
# application/level/views.py
|
||||
from application.operation_log.decorators import operation_log
|
||||
from application.operation_log.constants import LogType
|
||||
|
||||
class LevelAddView(PermissionRequired, View):
|
||||
permission_required = ("sys:level:add",)
|
||||
|
||||
@operation_log(
|
||||
title="职级管理-添加记录",
|
||||
log_type=LogType.ADD
|
||||
)
|
||||
def post(self, request):
|
||||
result = services.add_level(request)
|
||||
return result
|
||||
```
|
||||
|
||||
### 装饰器参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `title` | str | 操作标题,如"职级管理-添加职级" |
|
||||
| `log_type` | int | 操作类型,参考 LogType 常量 |
|
||||
| `source` | int | 操作来源,0-后台系统 1-手机站 2-客户端 3-其他 |
|
||||
|
||||
### LogType 常量
|
||||
|
||||
```python
|
||||
# application/operation_log/constants.py
|
||||
class LogType:
|
||||
OTHER = 0 # 其它
|
||||
ADD = 1 # 新增
|
||||
UPDATE = 2 # 修改
|
||||
DELETE = 3 # 删除
|
||||
BATCH_DELETE = 4 # 批量删除
|
||||
STATUS = 5 # 状态设置
|
||||
RESET_PWD = 6 # 重置密码
|
||||
UPDATE_PWD = 7 # 修改密码
|
||||
IMPORT = 8 # 导入
|
||||
EXPORT = 9 # 导出
|
||||
QUERY = 10 # 查询
|
||||
```
|
||||
|
||||
## 核心实现
|
||||
|
||||
```python
|
||||
# application/operation_log/decorators.py
|
||||
def operation_log(title: str, log_type: int = LogType.OTHER, source: int = 0):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, request, *args, **kwargs):
|
||||
# 记录开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 保存请求参数用于日志记录
|
||||
request.log_params = _extract_params(request)
|
||||
|
||||
try:
|
||||
# 执行原方法
|
||||
response = func(self, request, *args, **kwargs)
|
||||
|
||||
# 计算耗时(毫秒)
|
||||
consume_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查响应是否成功
|
||||
is_success, error_msg = _check_response_success(response)
|
||||
status = 0 if is_success else 1
|
||||
|
||||
# 记录操作日志
|
||||
record_operation_log(
|
||||
request=request,
|
||||
title=title,
|
||||
log_type=log_type,
|
||||
source=source,
|
||||
status=status,
|
||||
error_msg=error_msg,
|
||||
consume_time=consume_time,
|
||||
result=response
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
consume_time = int((time.time() - start_time) * 1000)
|
||||
record_operation_log(
|
||||
request=request,
|
||||
title=title,
|
||||
log_type=log_type,
|
||||
source=source,
|
||||
status=1,
|
||||
error_msg=str(e),
|
||||
consume_time=consume_time,
|
||||
result=None
|
||||
)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
## 参数提取
|
||||
|
||||
装饰器自动提取请求参数,支持 GET/POST/PUT/DELETE/PATCH:
|
||||
|
||||
```python
|
||||
def _extract_params(request):
|
||||
"""提取请求参数,自动过滤敏感信息"""
|
||||
params = {}
|
||||
|
||||
if request.method == 'GET':
|
||||
params = request.GET.dict()
|
||||
elif request.method in ['POST', 'PUT', 'DELETE', 'PATCH']:
|
||||
try:
|
||||
params = json.loads(request.body.decode('utf-8'))
|
||||
except:
|
||||
params = request.POST.dict()
|
||||
|
||||
# 过滤敏感信息
|
||||
sensitive_keys = ['password', 'pwd', 'token', 'secret', 'access_token']
|
||||
for key in sensitive_keys:
|
||||
if key in params:
|
||||
params[key] = '******'
|
||||
|
||||
return params
|
||||
```
|
||||
|
||||
## 响应检查
|
||||
|
||||
装饰器自动检查响应是否成功:
|
||||
|
||||
```python
|
||||
def _check_response_success(response):
|
||||
"""检查响应是否成功"""
|
||||
is_success = True
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
# 检查 HTTP 状态码
|
||||
if hasattr(response, 'status_code') and response.status_code >= 400:
|
||||
return False, f"HTTP {response.status_code}"
|
||||
|
||||
# 解析 JSON 响应内容
|
||||
if hasattr(response, 'content') and response.content:
|
||||
content = response.content.decode('utf-8')
|
||||
data = json.loads(content)
|
||||
if isinstance(data, dict):
|
||||
code = data.get('code')
|
||||
if code is not None and code != 0:
|
||||
is_success = False
|
||||
error_msg = data.get('msg', '操作失败')
|
||||
except:
|
||||
pass
|
||||
|
||||
return is_success, error_msg
|
||||
```
|
||||
|
||||
## 日志字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `title` | 操作标题 |
|
||||
| `log_type` | 操作类型 |
|
||||
| `url` | 请求路径 |
|
||||
| `method` | 请求方法 |
|
||||
| `param` | 请求参数(截取前2000字符) |
|
||||
| `result` | 响应结果(截取前2000字符) |
|
||||
| `consume_time` | 耗时(毫秒) |
|
||||
| `status` | 状态:0-成功 1-失败 |
|
||||
| `error_msg` | 错误信息 |
|
||||
| `create_user` | 操作人 |
|
||||
|
||||
## 查询接口
|
||||
|
||||
```
|
||||
GET /operation/log/page?pageNo=1&pageSize=10
|
||||
GET /operation/log/detail/<int:log_id>
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
操作日志模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 装饰器模式:@operation_log 标记需要记录日志的视图方法
|
||||
2. 自动记录:参数、结果、耗时、操作人等完整信息
|
||||
3. 类型常量:LogType 定义标准操作类型
|
||||
4. 敏感过滤:自动过滤密码、token 等敏感信息
|
||||
5. 异常捕获:异常时记录错误信息并重新抛出
|
||||
6. 响应检查:自动解析 JSON 响应判断操作是否成功
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
:::tip 请求限流
|
||||
请求限流可通过中间件实现 IP 级别的限流,防止恶意请求和 DoS 攻击。项目中限流可通过 Nginx 反向代理或自定义中间件实现。
|
||||
:::
|
||||
|
||||
## 限流策略
|
||||
|
||||
项目支持两种限流实现方式,可根据部署环境选择:
|
||||
|
||||
| 方式 | 适用场景 | 性能 | 精度 |
|
||||
|------|---------|------|------|
|
||||
| Nginx 限流 | 生产环境(推荐) | 高(不占用应用进程) | 中 |
|
||||
| Django 中间件 | 开发/测试环境 | 中(占用应用进程) | 高 |
|
||||
|
||||
## 方式 1:Nginx 限流(推荐)
|
||||
|
||||
Nginx 限流在反向代理层实现,不占用 Django 进程资源,适合生产环境:
|
||||
|
||||
```nginx
|
||||
# nginx.conf
|
||||
http {
|
||||
# 定义限流区域:按 IP 维度,每秒 10 个请求
|
||||
# 10m 共享内存区域可存储约 16 万个 IP 地址
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
location /api/ {
|
||||
# 应用限流:突发允许 20 个请求排队,不延迟处理
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
# 限流时返回 429 状态码
|
||||
limit_req_status 429;
|
||||
|
||||
# 限流日志级别
|
||||
limit_req_log_level warn;
|
||||
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx 限流参数说明
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `rate` | 每秒允许的请求数 | `10r/s`(每秒 10 个) |
|
||||
| `burst` | 突发请求数(排队等待) | `burst=20` |
|
||||
| `nodelay` | 突发请求不延迟处理 | 有此参数则立即处理 |
|
||||
| `limit_req_status` | 限流时返回的状态码 | `429` |
|
||||
| `limit_req_log_level` | 限流日志级别 | `warn` |
|
||||
|
||||
### 不同接口差异化限流
|
||||
|
||||
```nginx
|
||||
http {
|
||||
# API 接口限流:每秒 10 个请求
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
# 登录接口限流:每分钟 5 个请求(防暴力破解)
|
||||
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
|
||||
|
||||
# 上传接口限流:每秒 2 个请求
|
||||
limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s;
|
||||
|
||||
server {
|
||||
location /api/login {
|
||||
limit_req zone=login_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
|
||||
location /api/upload {
|
||||
limit_req zone=upload_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 方式 2:Django 中间件限流
|
||||
|
||||
Django 中间件限流在应用层实现,支持更灵活的限流规则:
|
||||
|
||||
```python
|
||||
# middleware/rate_limit_middleware.py
|
||||
import time
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
|
||||
class RateLimitMiddleware:
|
||||
"""基于缓存的 IP 限流中间件"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.rate_limit = 100 # 每分钟最大请求数
|
||||
self.window = 60 # 窗口时间(秒)
|
||||
|
||||
def __call__(self, request):
|
||||
# 获取客户端 IP
|
||||
ip = self._get_client_ip(request)
|
||||
cache_key = f'rate_limit:{ip}'
|
||||
|
||||
# 获取当前计数
|
||||
current = cache.get(cache_key, 0)
|
||||
|
||||
if current >= self.rate_limit:
|
||||
return JsonResponse({
|
||||
'code': 1,
|
||||
'msg': '请求过于频繁,请稍后再试',
|
||||
'data': None,
|
||||
'ok': False
|
||||
}, status=429)
|
||||
|
||||
# 递增计数
|
||||
cache.set(cache_key, current + 1, self.window)
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def _get_client_ip(self, request):
|
||||
"""获取客户端 IP,支持代理头"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(',')[0].strip()
|
||||
return request.META.get('REMOTE_ADDR')
|
||||
```
|
||||
|
||||
### 注册中间件
|
||||
|
||||
```python
|
||||
# application/settings.py
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'middleware.rate_limit_middleware.RateLimitMiddleware', # 限流中间件
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### 按接口差异化限流
|
||||
|
||||
```python
|
||||
class RateLimitMiddleware:
|
||||
"""支持按接口差异化限流的中间件"""
|
||||
|
||||
# 接口限流配置
|
||||
RATE_LIMITS = {
|
||||
'/api/login': {'limit': 5, 'window': 60}, # 登录:每分钟 5 次
|
||||
'/api/captcha': {'limit': 10, 'window': 60}, # 验证码:每分钟 10 次
|
||||
'/api/upload': {'limit': 5, 'window': 60}, # 上传:每分钟 5 次
|
||||
'default': {'limit': 100, 'window': 60}, # 默认:每分钟 100 次
|
||||
}
|
||||
|
||||
def __call__(self, request):
|
||||
ip = self._get_client_ip(request)
|
||||
path = request.path
|
||||
|
||||
# 获取当前接口的限流配置
|
||||
config = self.RATE_LIMITS.get(path, self.RATE_LIMITS['default'])
|
||||
cache_key = f'rate_limit:{ip}:{path}'
|
||||
|
||||
current = cache.get(cache_key, 0)
|
||||
if current >= config['limit']:
|
||||
return JsonResponse({
|
||||
'code': 1,
|
||||
'msg': '请求过于频繁,请稍后再试',
|
||||
'data': None,
|
||||
'ok': False
|
||||
}, status=429)
|
||||
|
||||
cache.set(cache_key, current + 1, config['window'])
|
||||
return self.get_response(request)
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `rate_limit` | `100` | 每分钟最大请求数 |
|
||||
| `window` | `60` | 统计窗口时长(秒) |
|
||||
|
||||
:::warning 生产环境建议
|
||||
生产环境建议使用 Nginx 限流,性能更好且不占用 Django 进程资源。Django 中间件限流适合开发环境和需要精细控制的场景。
|
||||
:::
|
||||
|
||||
## 限流响应格式
|
||||
|
||||
当请求被限流时,返回 429 状态码:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "请求过于频繁,请稍后再试",
|
||||
"data": null,
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
前端可通过 HTTP 状态码 429 识别限流,并提示用户稍后重试。
|
||||
|
||||
## 降级策略
|
||||
|
||||
```
|
||||
1. Redis 不可用:直接放行,不阻塞请求
|
||||
2. 限流计数异常:降级放行,避免全站不可用
|
||||
3. 限流阈值配置错误:使用默认值兜底
|
||||
```
|
||||
|
||||
:::tip fail-open 策略
|
||||
限流采用 fail-open 策略:缓存不可用时放行所有请求,优先保证服务可用性。这是安全与可用性的权衡——宁可放过一些请求,也不能因为限流故障导致全站不可用。
|
||||
:::
|
||||
|
||||
## 验证码防刷
|
||||
|
||||
验证码接口有独立的限流策略,按 IP 频率控制:
|
||||
|
||||
```python
|
||||
# 验证码防刷:单 IP 每分钟最多获取 5 次验证码
|
||||
def check_captcha_rate_limit(ip):
|
||||
cache_key = f'captcha_rate:{ip}'
|
||||
count = cache.get(cache_key, 0)
|
||||
if count >= 5:
|
||||
return False # 超过限制
|
||||
cache.set(cache_key, count + 60)
|
||||
return True
|
||||
```
|
||||
|
||||
## 前端配合
|
||||
|
||||
前端可通过 Axios 拦截器处理 429 响应:
|
||||
|
||||
```typescript
|
||||
// 响应拦截器
|
||||
responseInterceptorsCatch: (error: any) => {
|
||||
if (error.response?.status === 429) {
|
||||
ElMessage.warning('请求过于频繁,请稍后再试');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// ... 其他错误处理
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
请求限流模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 多种实现:Nginx 限流(推荐)或 Django 中间件限流
|
||||
2. IP 维度:按客户端 IP 限流,支持代理头
|
||||
3. 滑动窗口:基于缓存的计数器实现
|
||||
4. 降级策略:缓存故障时 fail-open 放行
|
||||
5. 灵活配置:可调整限流阈值和窗口时间
|
||||
6. 差异化限流:支持按接口独立配置限流策略
|
||||
7. 验证码防刷:验证码接口有独立的频率控制
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
:::tip RBAC权限
|
||||
采用基于角色的访问控制(RBAC)模型,通过用户-角色-菜单三层关系实现细粒度的权限管理。权限控制贯穿前后端:后端通过 `PermissionRequired` 混入类校验(`middleware/permission_middleware.py`),前端通过 `v-perm` 指令控制按钮显隐。
|
||||
:::
|
||||
|
||||
## 权限模型
|
||||
|
||||
```
|
||||
用户(User) --N:N-- 角色(Role) --N:N-- 菜单(Menu)
|
||||
|
|
||||
type=0: 菜单(页面路由)
|
||||
type=1: 节点(权限按钮)
|
||||
```
|
||||
|
||||
数据库表关系:
|
||||
|
||||
| 关联表 | 说明 |
|
||||
|--------|------|
|
||||
| `django_user_role` | 用户-角色关联表 |
|
||||
| `django_role_menu` | 角色-菜单关联表 |
|
||||
| `django_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 岗位列表
|
||||
```
|
||||
|
||||
## 后端权限校验
|
||||
|
||||
### PermissionRequired 混入类
|
||||
|
||||
`middleware/permission_middleware.py` 中的 `PermissionRequired` 混入类用于基于类的视图权限校验:
|
||||
|
||||
```python
|
||||
from middleware.permission_middleware import PermissionRequired
|
||||
|
||||
@method_decorator(check_login, name="get")
|
||||
class UserPageView(PermissionRequired, View):
|
||||
permission_required = ("sys:user:page",)
|
||||
|
||||
def get(self, request):
|
||||
result = services.get_user_page(request)
|
||||
return result
|
||||
```
|
||||
|
||||
校验逻辑(完整实现):
|
||||
|
||||
```python
|
||||
# middleware/permission_middleware.py
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
|
||||
class PermissionRequired(PermissionRequiredMixin):
|
||||
def has_permission(self):
|
||||
permissions = self.get_permission_required()
|
||||
user_id = get_user_id(self.request)
|
||||
|
||||
# 超级管理员自动放行
|
||||
if user_id and user_id != 1:
|
||||
from application.menu import services
|
||||
permission_list = services.get_user_permissions(user_id)
|
||||
|
||||
for permission in permissions:
|
||||
if permission not in permission_list:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def handle_no_permission(self):
|
||||
return R.failed("暂无操作权限", 401)
|
||||
```
|
||||
|
||||
:::tip 管理员免检
|
||||
用户ID为1的管理员账号(admin)自动跳过所有权限校验,拥有全部权限。
|
||||
:::
|
||||
|
||||
### 权限数据查询
|
||||
|
||||
权限列表通过角色-菜单关联查询:
|
||||
|
||||
```python
|
||||
# application/menu/services.py
|
||||
def get_user_permissions(user_id):
|
||||
"""获取用户的权限节点列表"""
|
||||
if user_id == 1:
|
||||
# 超管拥有全部权限
|
||||
menu_list = Menu.objects.filter(
|
||||
type=1, is_delete=False
|
||||
)
|
||||
else:
|
||||
# 通过 user -> role -> menu 关联查询
|
||||
menu_list = Menu.objects.filter(
|
||||
type=1,
|
||||
is_delete=False,
|
||||
status=0,
|
||||
rolemenu__role__userrole__user_id=user_id,
|
||||
rolemenu__role__is_delete=False,
|
||||
).distinct()
|
||||
|
||||
return [item.permission for item in menu_list]
|
||||
```
|
||||
|
||||
## 前端权限控制
|
||||
|
||||
### 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>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
前端路由守卫根据用户菜单权限动态生成路由,无权限的页面不会出现在导航菜单中。
|
||||
|
||||
## 总结
|
||||
|
||||
RBAC 权限方案具备以下特点:
|
||||
|
||||
```
|
||||
1. 三层模型:用户-角色-菜单,支持灵活的角色分配
|
||||
2. 节点权限:sys:module:action 格式,细粒度控制每个操作
|
||||
3. 前后端统一:后端 PermissionRequired + 前端 v-perm 指令
|
||||
4. 管理员免检:userId=1 自动跳过权限校验
|
||||
5. Django 原生:基于 Django PermissionRequiredMixin 扩展
|
||||
6. 菜单路由:type=0 菜单生成前端路由,type=1 节点控制按钮权限
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
:::tip 参数验证
|
||||
使用 Django ModelForm 进行请求参数验证。所有请求体参数通过 Django Form 定义,视图中调用 `form.is_valid()` 自动验证,验证失败通过 `regular.get_err()` 提取错误信息返回前端。
|
||||
:::
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义表单类
|
||||
|
||||
```python
|
||||
# application/level/forms.py
|
||||
# +======================================================================
|
||||
# | 模块: 职级表单验证
|
||||
# | 说明: 职级创建/编辑的请求数据校验
|
||||
# +======================================================================
|
||||
|
||||
from django import forms
|
||||
from application.level.models import Level
|
||||
|
||||
|
||||
class LevelForm(forms.ModelForm):
|
||||
"""职级创建/编辑表单"""
|
||||
|
||||
class Meta:
|
||||
model = Level
|
||||
fields = ['name', 'status', 'sort']
|
||||
error_messages = {
|
||||
'name': {
|
||||
'required': '职级名称不能为空',
|
||||
'max_length': '职级名称不能超过150个字符',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 端点使用
|
||||
|
||||
```python
|
||||
# application/level/views.py
|
||||
class LevelAddView(PermissionRequired, View):
|
||||
permission_required = ("sys:level:add",)
|
||||
|
||||
def post(self, request):
|
||||
form = LevelForm(parse_request_body(request))
|
||||
if not form.is_valid():
|
||||
return R.failed(msg=regular.get_err(form))
|
||||
# 保存数据...
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
:::tip 自动验证
|
||||
`form.is_valid()` 自动完成参数验证。验证失败时通过 `regular.get_err(form)` 提取错误信息:
|
||||
```json
|
||||
{"code": 1, "data": null, "msg": "职级名称不能为空", "ok": false}
|
||||
```
|
||||
:::
|
||||
|
||||
## error_messages 自定义
|
||||
|
||||
Django Form 支持通过 `error_messages` 自定义中文错误提示:
|
||||
|
||||
```python
|
||||
class UserForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'realname', 'mobile']
|
||||
error_messages = {
|
||||
'username': {
|
||||
'required': '用户名不能为空',
|
||||
'max_length': '用户名不能超过30个字符',
|
||||
},
|
||||
'realname': {
|
||||
'required': '姓名不能为空',
|
||||
},
|
||||
'mobile': {
|
||||
'required': '手机号不能为空',
|
||||
'invalid': '手机号格式不正确',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## regular.get_err() 错误提取
|
||||
|
||||
`utils/regular.py` 中的 `get_err()` 函数从 Form 对象中提取所有错误信息:
|
||||
|
||||
```python
|
||||
# utils/regular.py
|
||||
def get_err(form):
|
||||
"""
|
||||
获取表单错误文本
|
||||
|
||||
从Django表单对象中提取所有错误信息,并将其合并为单个字符串。
|
||||
多个错误用'/'分隔。
|
||||
|
||||
使用示例:
|
||||
form = UserForm(request.POST)
|
||||
if not form.is_valid():
|
||||
error_msg = get_err(form)
|
||||
return R.failed(msg=error_msg)
|
||||
"""
|
||||
error_list = []
|
||||
for item in form.errors.get_json_data().values():
|
||||
error_list.append(item[0].get('message'))
|
||||
err_str = '/'.join(error_list)
|
||||
return err_str
|
||||
```
|
||||
|
||||
错误格式示例:
|
||||
|
||||
```python
|
||||
# 单个错误
|
||||
"职级名称不能为空"
|
||||
|
||||
# 多个错误
|
||||
"职级名称不能为空/排序不能为空"
|
||||
```
|
||||
|
||||
## 字段约束
|
||||
|
||||
Django Form 提供丰富的字段约束:
|
||||
|
||||
| 约束 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `required` | 是否必填 | `forms.CharField(required=True)` |
|
||||
| `max_length` | 字符串最大长度 | `forms.CharField(max_length=150)` |
|
||||
| `min_length` | 字符串最小长度 | `forms.CharField(min_length=1)` |
|
||||
| `min_value` | 最小值 | `forms.IntegerField(min_value=0)` |
|
||||
| `max_value` | 最大值 | `forms.IntegerField(max_value=99999)` |
|
||||
|
||||
## 唯一性校验
|
||||
|
||||
在 service 层查询数据库校验唯一性:
|
||||
|
||||
```python
|
||||
# application/level/services.py
|
||||
def add_level(request):
|
||||
data = parse_request_body(request)
|
||||
form = LevelForm(data)
|
||||
|
||||
if not form.is_valid():
|
||||
return R.failed(msg=regular.get_err(form))
|
||||
|
||||
# 唯一性校验
|
||||
name = data.get('name')
|
||||
if Level.objects.filter(name=name, is_delete=False).exists():
|
||||
return R.failed("职级名称不能重复")
|
||||
|
||||
# 保存数据...
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
## parse_request_body
|
||||
|
||||
项目提供 `parse_request_body()` 工具函数,统一处理请求体解析:
|
||||
|
||||
```python
|
||||
# utils/common.py
|
||||
def parse_request_body(request):
|
||||
"""
|
||||
解析请求体,支持 JSON 和表单数据
|
||||
|
||||
Args:
|
||||
request: Django 请求对象
|
||||
|
||||
Returns:
|
||||
dict: 请求参数字典
|
||||
"""
|
||||
try:
|
||||
return json.loads(request.body.decode('utf-8'))
|
||||
except:
|
||||
return request.POST.dict()
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
参数验证模块具备以下特点:
|
||||
|
||||
```
|
||||
1. Django ModelForm:类型安全、自动解析请求体
|
||||
2. 中文错误:error_messages 自定义中文提示
|
||||
3. regular.get_err():统一提取错误信息,多个错误用 / 分隔
|
||||
4. 唯一性校验:在 service 层查询数据库,确保字段唯一性
|
||||
5. parse_request_body:统一处理 JSON 和表单数据
|
||||
```
|
||||
Reference in New Issue
Block a user