优化更新
This commit is contained in:
@@ -117,15 +117,13 @@ Content-Type: application/json
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"must_change_password": false
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `access_token`:JWT 令牌,后续请求需携带
|
||||
- `must_change_password`:是否需要修改密码(检测到默认密码时为 `true`)
|
||||
|
||||
### 失败响应
|
||||
|
||||
@@ -134,10 +132,10 @@ Content-Type: application/json
|
||||
{"code": 1, "msg": "验证码不正确", "data": null, "ok": false}
|
||||
|
||||
// 账号或密码错误
|
||||
{"code": 1, "msg": "用户名或密码不正确", "data": null, "ok": false}
|
||||
{"code": 1, "msg": "用户不存在", "data": null, "ok": false}
|
||||
|
||||
// 账号被锁定
|
||||
{"code": 1, "msg": "登录失败次数过多,请280秒后再试", "data": null, "ok": false}
|
||||
{"code": 1, "msg": "登录失败次数过多,请5分钟后再试", "data": null, "ok": false}
|
||||
```
|
||||
|
||||
### 登录流程详解
|
||||
|
||||
@@ -132,7 +132,7 @@ PUT /api/v1/config/item/update
|
||||
| 用途 | 枚举值(下拉选项) | 系统参数(配置值) |
|
||||
| 值类型 | 简单值(字符串) | 可存储较长的配置内容 |
|
||||
| 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 |
|
||||
| 前端用法 | el-select 选项 | 配置表单 |
|
||||
| 前端用法 | n-select 选项 | 配置表单 |
|
||||
|
||||
## 总结
|
||||
|
||||
|
||||
@@ -1,96 +1,118 @@
|
||||
:::tip CORS跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。中间件位于 `src/middleware/cors.py`。
|
||||
# 跨域资源共享(CORS)
|
||||
|
||||
:::tip CORS 跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。项目通过 `flask-cors` 扩展实现 CORS 支持,配置入口在 `src/app.py` 的 `create_app()` 中,通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) |
|
||||
| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 |
|
||||
| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8001` | 允许的跨域源(逗号分隔),留空则允许所有源 |
|
||||
| `PROXY_NUM` | `0` | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 后设为 `1` |
|
||||
|
||||
## 实现原理
|
||||
|
||||
CORS 配置位于 `src/app.py` 的 `create_app()` 中:
|
||||
|
||||
```python
|
||||
# src/app.py
|
||||
from flask_cors import CORS
|
||||
from config import CORS_ALLOWED_ORIGINS
|
||||
|
||||
# ============================================================
|
||||
# 跨域资源共享配置
|
||||
# ============================================================
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
allowed_origins = [origin.strip() for origin in CORS_ALLOWED_ORIGINS.split(',') if origin.strip()]
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/*": {"origins": allowed_origins}},
|
||||
supports_credentials=True
|
||||
)
|
||||
else:
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/*": {"origins": "*"}},
|
||||
supports_credentials=False
|
||||
)
|
||||
```
|
||||
|
||||
- 配置了 `CORS_ALLOWED_ORIGINS` 时:仅允许指定源,支持携带凭证(Cookie 等)
|
||||
- 未配置时:允许所有源,但不携带凭证
|
||||
|
||||
## 配置示例
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 允许所有源(开发环境)
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
# 开发环境:允许前端开发服务器
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:8001
|
||||
|
||||
# 允许指定源(生产环境)
|
||||
# 生产环境:允许指定域名(多个用逗号分隔)
|
||||
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 中间件注册
|
||||
## 可信代理配置
|
||||
|
||||
Flask 使用 `flask-cors` 扩展实现 CORS 支持:
|
||||
当应用部署在 Nginx 等反向代理后,需要配置代理信任以获取真实客户端 IP:
|
||||
|
||||
```python
|
||||
# src/middleware/cors.py
|
||||
from flask_cors import CORS
|
||||
from config.app import CORS_ALLOWED_ORIGINS
|
||||
# src/app.py
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
def register_cors(app):
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
origins = [o.strip() for o in CORS_ALLOWED_ORIGINS.split(',') if o.strip()]
|
||||
CORS(app, origins=origins, supports_credentials=True)
|
||||
else:
|
||||
# 未配置时允许所有源但不携带凭证
|
||||
CORS(app, origins="*")
|
||||
# ============================================================
|
||||
# 可信代理配置(反向代理环境下获取真实客户端IP)
|
||||
# ============================================================
|
||||
proxy_num = int(os.getenv('PROXY_NUM', 0))
|
||||
if proxy_num > 0:
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)
|
||||
```
|
||||
|
||||
## 中间件顺序
|
||||
`PROXY_NUM` 说明:
|
||||
|
||||
CORS 中间件在 `src/app.py` 中注册为最外层:
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
|
||||
| `1` | 信任一层反向代理(如 Nginx) |
|
||||
| `2` | 信任两层代理 |
|
||||
|
||||
:::tip 安全提示
|
||||
仅当应用部署在反向代理后才设置 `PROXY_NUM>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过安全校验。
|
||||
:::
|
||||
|
||||
## 中间件注册顺序
|
||||
|
||||
CORS 在 `src/app.py` 中最先注册,确保所有响应都带 CORS 头:
|
||||
|
||||
```python
|
||||
# 最先注册→最外层:确保限流等直接返回的响应也带 CORS 头
|
||||
register_cors(app)
|
||||
# src/app.py - create_app() 中间件注册顺序
|
||||
# 1. CORS 配置(最先注册→最外层)
|
||||
# 2. ProxyFix 可信代理
|
||||
# 3. register_operation_log(app) # 操作日志
|
||||
# 4. register_transaction(app) # AOP 事务
|
||||
# 5. register_v1(app) # 路由注册
|
||||
# 6. register_extends(app) # 扩展初始化
|
||||
# 7. register_middleware(app) # 认证中间件
|
||||
# 8. register_error_handlers(app) # 异常处理
|
||||
```
|
||||
|
||||
中间件执行顺序(从外到内):
|
||||
|
||||
```
|
||||
CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑
|
||||
CORS → 操作日志 → AOP 事务 → 认证 → 路由 → 业务逻辑
|
||||
```
|
||||
|
||||
## 代理头处理
|
||||
|
||||
当应用部署在 Nginx 等反向代理后,需要配置代理信任:
|
||||
|
||||
```bash
|
||||
# 信任一层代理
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
`TRUSTED_PROXY_COUNT` 说明:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
|
||||
| `1` | 信任一层反向代理 |
|
||||
| `2` | 信任两层代理 |
|
||||
|
||||
:::tip 安全提示
|
||||
仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。
|
||||
:::
|
||||
|
||||
## Flask-CORS 配置选项
|
||||
|
||||
```python
|
||||
from flask_cors import CORS
|
||||
|
||||
CORS(app,
|
||||
CORS(app,
|
||||
origins=["https://admin.example.com"], # 允许的源
|
||||
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], # 允许的方法
|
||||
allow_headers=["Content-Type", "Authorization"], # 允许的头
|
||||
@@ -107,14 +129,24 @@ CORS(app,
|
||||
| `supports_credentials` | 允许携带凭证 | `False` |
|
||||
| `max_age` | 预检请求缓存时间 | `None` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
+ 跨域请求被拒绝
|
||||
|
||||
```
|
||||
1. 检查 .env 中 CORS_ALLOWED_ORIGINS 是否包含前端地址
|
||||
2. 检查前端请求的 Origin 头是否与配置一致
|
||||
3. 生产环境检查 Nginx 是否正确传递 Origin 头
|
||||
```
|
||||
|
||||
+ 携带凭证(Cookie)失败
|
||||
|
||||
```
|
||||
1. CORS_ALLOWED_ORIGINS 必须配置具体域名,不能用 *
|
||||
2. 前端请求需设置 withCredentials: true
|
||||
3. 确保 supports_credentials=True
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
CORS 跨域配置具备以下特点:
|
||||
|
||||
```
|
||||
1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源
|
||||
2. 开发/生产分离:开发环境允许所有源,生产环境指定域名
|
||||
3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数
|
||||
4. 安全默认:未配置时允许所有源但不携带凭证
|
||||
5. 最外层注册:确保所有响应都带 CORS 头
|
||||
```
|
||||
CORS 跨域配置通过 `flask-cors` 扩展在 `src/app.py` 中实现,通过 `CORS_ALLOWED_ORIGINS` 环境变量灵活配置允许的源。开发环境允许前端开发服务器(`http://localhost:8001`),生产环境应配置具体域名。反向代理场景通过 `PROXY_NUM` 配置代理信任层数,确保获取真实客户端 IP。
|
||||
|
||||
@@ -40,7 +40,7 @@ def add():
|
||||
```python
|
||||
from functools import wraps
|
||||
from flask import current_app
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
def check_demo(func):
|
||||
"""演示模式拦截装饰器,放在 @check_permission 之后、路由函数之前"""
|
||||
|
||||
@@ -116,14 +116,7 @@ timeout = get_param_value('REQUEST_TIMEOUT', '30')
|
||||
|
||||
```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>
|
||||
<n-select v-model:value="form.trainingMethod" :options="dictOptions" placeholder="请选择培训方式" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -145,17 +138,17 @@ onMounted(async () => {
|
||||
|
||||
```typescript
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { NTag } from 'naive-ui';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
label: '培训方式',
|
||||
prop: 'trainingMethod',
|
||||
title: '培训方式',
|
||||
key: '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] || '-'
|
||||
return h(NTag, { bordered: false, type: typeMap[record.trainingMethod] }, {
|
||||
default: () => map[record.trainingMethod] || '-'
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -160,15 +160,8 @@ 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>
|
||||
// 在 n-select 中使用
|
||||
<n-select v-model:value="form.status" :options="statusOptions" />
|
||||
```
|
||||
|
||||
## 常用字典示例
|
||||
@@ -187,7 +180,7 @@ const statusOptions = dictStore.getDict('sys_status')
|
||||
```
|
||||
1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理
|
||||
2. 进程内缓存:dict_util 内置 TTL 缓存(60秒兜底),变更时主动失效
|
||||
3. 前端集成:useDictStore 统一管理,el-select 直接绑定
|
||||
3. 前端集成:useDictStore 统一管理,n-select 直接绑定
|
||||
4. 值为字符串:字典项 value 统一为字符串,get_dict_label 自动转字符串匹配
|
||||
5. 避免硬编码:枚举值统一走字典,修改无需改代码
|
||||
6. 三种取值:get_dict_items(列表)、get_dict_map(映射)、get_dict_label(单值翻译)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
`src/core/response.py` 提供统一的响应生成函数:
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
# 成功响应
|
||||
R.ok(data={"id": 1}, msg="操作成功")
|
||||
|
||||
@@ -9,44 +9,47 @@
|
||||
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
|
||||
|
||||
```javascript
|
||||
import { useLockFn } from '@/utils/lock'
|
||||
import { useLockFn } from '@/utils/useLockFn'
|
||||
|
||||
// 包装提交函数,防止重复点击
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
const { lockFn: handleSubmit } = useLockFn(async () => {
|
||||
await axios.post('/api/v1/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
message.success('添加成功')
|
||||
})
|
||||
```
|
||||
|
||||
`useLockFn` 原理:
|
||||
|
||||
```javascript
|
||||
function useLockFn(fn) {
|
||||
let isLocked = false
|
||||
return async (...args) => {
|
||||
if (isLocked) return
|
||||
isLocked = true
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useLockFn(fn) {
|
||||
const isLock = ref(false)
|
||||
const lockFn = async (...args) => {
|
||||
if (isLock.value) return
|
||||
isLock.value = true
|
||||
try {
|
||||
return await fn(...args)
|
||||
await fn(...args)
|
||||
} finally {
|
||||
isLocked = false
|
||||
isLock.value = false
|
||||
}
|
||||
}
|
||||
return { isLock, lockFn }
|
||||
}
|
||||
```
|
||||
|
||||
### 按钮 loading
|
||||
|
||||
ElementPlus 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
|
||||
NaiveUI 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交
|
||||
</el-button>
|
||||
</n-button>
|
||||
|
||||
<script setup>
|
||||
const submitting = ref(false)
|
||||
@@ -55,7 +58,7 @@ async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await axios.post('/api/v1/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
message.success('添加成功')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ def login(data):
|
||||
|
||||
return R.ok(msg="登录成功", data={
|
||||
"access_token": access_token,
|
||||
"must_change_password": is_default_pwd
|
||||
"access_token": access_token
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -17,50 +17,42 @@
|
||||
以下路径不记录操作日志:
|
||||
|
||||
```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',
|
||||
)
|
||||
# src/middleware/operation_log.py
|
||||
_EXCLUDED_PATH_SEGMENTS = ("/login", "/captcha", "/logout", "/sendSms")
|
||||
```
|
||||
|
||||
排除逻辑使用 `endswith` 匹配路径末段,而非精确匹配完整路径。
|
||||
|
||||
## 操作类型自动推断
|
||||
|
||||
中间件根据 URL 路径末段自动推断操作类型,映射表定义在 `_TYPE_MAP` 中:
|
||||
中间件根据 HTTP 方法和 URL 路径自动推断操作类型:
|
||||
|
||||
```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, # 开账号
|
||||
# src/middleware/operation_log.py
|
||||
|
||||
# HTTP 方法 → 日志类型
|
||||
_METHOD_LOG_TYPE = {
|
||||
"POST": 3, # 新增
|
||||
"PUT": 4, # 修改
|
||||
"DELETE": 5, # 删除
|
||||
}
|
||||
|
||||
def _infer_type(request) -> int:
|
||||
"""从 URL 路径末段推断操作类型,匹配不到返回 0(其它)"""
|
||||
path = request.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)
|
||||
# URL 路径关键字 → 日志类型(优先级高于方法)
|
||||
_PATH_ACTION_TYPE = {
|
||||
"status": 4, # 状态变更
|
||||
"import": 3, # 导入
|
||||
"export": 5, # 导出
|
||||
"batchDelete": 5, # 批量删除
|
||||
}
|
||||
|
||||
def _infer_log_type(request) -> int:
|
||||
"""从 HTTP 方法和 URL 路径推断操作类型"""
|
||||
# 先按路径关键字匹配
|
||||
for keyword, log_type in _PATH_ACTION_TYPE.items():
|
||||
if keyword in request.path:
|
||||
return log_type
|
||||
# 再按 HTTP 方法匹配
|
||||
return _METHOD_LOG_TYPE.get(request.method, 18) # 18=其它
|
||||
```
|
||||
|
||||
| 路径末段 | 操作类型 | 类型ID |
|
||||
@@ -136,7 +128,7 @@ def register_operation_log(app):
|
||||
return response
|
||||
|
||||
# 排除特定路径
|
||||
if request.path in _EXCLUDE_PATHS:
|
||||
if any(seg in request.path for seg in _EXCLUDED_PATH_SEGMENTS):
|
||||
return response
|
||||
|
||||
# 计算耗时
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
:::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
|
||||
from flask import request, g
|
||||
import time
|
||||
|
||||
def register_rate_limit(app):
|
||||
"""注册限流中间件"""
|
||||
|
||||
@app.before_request
|
||||
def rate_limit():
|
||||
"""按客户端 IP 做滑动窗口限流,超限直接返回统一 200 + code 响应"""
|
||||
from flask import current_app
|
||||
|
||||
if not current_app.config.get('RATE_LIMIT_ENABLED'):
|
||||
return
|
||||
|
||||
# CORS 预检请求不计数,避免浏览器预检占用限流额度
|
||||
if request.method == 'OPTIONS':
|
||||
return
|
||||
|
||||
# Redis 未就绪时降级放行
|
||||
redis = getattr(g, 'redis', None)
|
||||
if redis is None:
|
||||
return
|
||||
|
||||
client_ip = get_client_ip()
|
||||
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}"
|
||||
|
||||
# 原子滑动窗口计数
|
||||
try:
|
||||
count = sliding_window_incr(
|
||||
redis, current_key, prev_key, RATE_LIMIT_WINDOW_SECONDS, window_index, now)
|
||||
except Exception as e:
|
||||
app.logger.warning(f"限流计数异常,降级放行: {e}")
|
||||
return
|
||||
|
||||
# 直接返回而非抛异常
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
try:
|
||||
ttl = redis.ttl(current_key)
|
||||
except Exception:
|
||||
ttl = RATE_LIMIT_WINDOW_SECONDS
|
||||
from src.core.response import R
|
||||
return R.failed(msg=f"请求过于频繁,请 {ttl} 秒后再试")
|
||||
```
|
||||
|
||||
### 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
|
||||
"""
|
||||
|
||||
def sliding_window_incr(redis, current_key, prev_key,
|
||||
window_seconds, window_index, now) -> float:
|
||||
"""滑动窗口限流计数:返回当前滑动窗口内的加权请求数(近似精确)"""
|
||||
return 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 不可用时放行所有请求,优先保证服务可用性。这是安全与可用性的权衡。
|
||||
:::
|
||||
|
||||
## 中间件位置
|
||||
|
||||
限流中间件在 `src/app.py` 中注册为最外层(CORS 内层):
|
||||
|
||||
```python
|
||||
# 最外层:限流(被限流的请求不进入 DB 会话与操作日志)
|
||||
register_rate_limit(app)
|
||||
# CORS(确保限流响应也带 CORS 头)
|
||||
register_cors(app)
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
请求限流模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 滑动窗口:消除固定窗口边界处的 2x 突发问题
|
||||
2. IP 维度:按客户端 IP 限流,支持代理头
|
||||
3. 默认关闭:opt-in 模式,按需开启
|
||||
4. 降级策略:Redis 故障时 fail-open 放行
|
||||
5. 最外层拦截:限流请求不进入 DB 会话和操作日志
|
||||
6. Lua 原子操作:计数和过期设置原子执行,避免并发问题
|
||||
```
|
||||
@@ -118,59 +118,108 @@ def add():
|
||||
校验逻辑(完整实现):
|
||||
|
||||
```python
|
||||
# src/core/permission.py
|
||||
import fnmatch
|
||||
import hmac
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, g
|
||||
from src.core.response import R
|
||||
from src.core.security import get_user_id
|
||||
from typing import List, Protocol
|
||||
|
||||
def check_permission(permission: str):
|
||||
"""节点权限鉴权装饰器,userId==1(管理员)直接放行"""
|
||||
from flask import request, g, current_app
|
||||
from src.core import response as R
|
||||
from src.core.security import get_user_id
|
||||
from config import PERMISSION_ENABLED, INTERNAL_API_KEY, INTERNAL_ALLOWED_IPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionProvider(Protocol):
|
||||
"""权限数据提供方接口:core 只认接口,不认实现。"""
|
||||
|
||||
def get_user_permissions(self, user_id: int) -> List[str]:
|
||||
"""返回用户拥有的权限节点列表。"""
|
||||
|
||||
|
||||
def get_provider() -> "PermissionProvider":
|
||||
"""获取注入的权限提供方(应用工厂注入)。"""
|
||||
return current_app.extensions.get("permission_provider")
|
||||
|
||||
|
||||
def check_permission(permission: str, log: bool = False):
|
||||
"""
|
||||
权限验证装饰器
|
||||
|
||||
权限验证规则:
|
||||
1. 定时任务等内部调用 → 跳过权限验证,直接放行
|
||||
2. 超级管理员(user_id=1) → 拥有所有权限
|
||||
3. 普通用户 → 通过注入的 PermissionProvider 验证权限
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 获取当前用户ID
|
||||
user_id = get_user_id()
|
||||
# 定时任务等内部调用:跳过权限验证
|
||||
if is_internal_call():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 管理员免检
|
||||
# 获取用户ID
|
||||
user_id = getattr(g, "user_id", None)
|
||||
if user_id is None:
|
||||
try:
|
||||
user_id = get_user_id()
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
# 超级管理员放行(user_id=1 为系统内置管理员)
|
||||
if user_id == 1:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 获取用户权限列表
|
||||
from src.modules.system.menu.service import get_permissions_list
|
||||
permission_list = get_permissions_list(user_id)
|
||||
# 普通用户权限验证
|
||||
if user_id:
|
||||
provider = get_provider()
|
||||
if provider is None:
|
||||
return R.failed(msg="权限验证失败", code=500)
|
||||
|
||||
# 检查权限
|
||||
if permission not in permission_list:
|
||||
return R.failed("权限不足")
|
||||
permission_list = provider.get_user_permissions(user_id)
|
||||
if permission not in permission_list:
|
||||
return R.failed(msg="权限不足!", code=403)
|
||||
else:
|
||||
return R.failed(msg="请先登录", code=401)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
:::warning Request 提取
|
||||
装饰器通过 `flask.request` 获取当前请求对象,无需显式传递 request 参数。
|
||||
:::tip 依赖注入设计
|
||||
权限校验采用依赖注入设计(解决 core → modules 依赖倒置):
|
||||
- core 定义 `PermissionProvider` 协议,不 import 任何业务模块
|
||||
- 应用工厂启动时注入 menu 服务的实现(见 `src/app.py` 的 `_inject_dependencies`)
|
||||
- `check_permission` 通过 `current_app.extensions['permission_provider']` 获取权限数据
|
||||
:::
|
||||
|
||||
:::tip 管理员免检
|
||||
用户ID为1的管理员账号(admin)自动跳过所有权限校验,拥有全部权限。
|
||||
:::
|
||||
|
||||
### 权限缓存
|
||||
### 权限数据注入
|
||||
|
||||
权限列表通过 Redis 缓存,提高查询性能:
|
||||
权限数据通过应用工厂注入,避免 core 层直接依赖 modules 层:
|
||||
|
||||
```python
|
||||
# 获取用户的权限节点列表(带 Redis 缓存)
|
||||
def get_permissions_list(user_id: int) -> list:
|
||||
# 1. 先查 Redis 缓存
|
||||
cache_key = f"user:permissions:{user_id}"
|
||||
cached = redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
# src/app.py - _inject_dependencies()
|
||||
from src.modules.system.menu.service import get_user_permissions
|
||||
|
||||
# 2. 缓存未命中 → 查 DB(user → role → menu where type=1)
|
||||
permission_list = _query_permissions_from_db(user_id)
|
||||
class MenuPermissionProvider:
|
||||
"""权限数据提供方(适配 menu 服务)。"""
|
||||
def get_user_permissions(self, user_id):
|
||||
return get_user_permissions(user_id)
|
||||
|
||||
app.extensions['permission_provider'] = MenuPermissionProvider()
|
||||
```
|
||||
|
||||
### 权限缓存
|
||||
|
||||
权限列表通过 Redis 缓存,提高查询性能。缓存在角色菜单变更时主动失效。
|
||||
|
||||
# 3. 写入缓存
|
||||
redis.set(cache_key, json.dumps(permission_list), ex=3600)
|
||||
@@ -186,10 +235,10 @@ def get_permissions_list(user_id: int) -> list:
|
||||
|
||||
```html
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
<n-button v-perm="'sys:user:add'" type="primary">新增</n-button>
|
||||
|
||||
<!-- 多个权限,满足其一即可 -->
|
||||
<el-button v-perm="['sys:user:update', 'sys:user:delete']">操作</el-button>
|
||||
<n-button v-perm="['sys:user:update', 'sys:user:delete']">操作</n-button>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
@@ -98,7 +98,7 @@ def add_position(data):
|
||||
### 批量操作
|
||||
|
||||
```python
|
||||
def batch_delete_positions(id_list):
|
||||
def delete_positions(id_list):
|
||||
"""批量删除岗位"""
|
||||
# 批量软删除(仅 flush,不 commit)
|
||||
PositionRepository.batch_delete(id_list)
|
||||
|
||||
@@ -123,7 +123,7 @@ from wtforms import Form, IntegerField
|
||||
from wtforms.validators import Optional
|
||||
|
||||
|
||||
class BaseForm(Form):
|
||||
class BaseForm(FlaskForm):
|
||||
"""
|
||||
表单基类
|
||||
|
||||
@@ -135,7 +135,7 @@ class BaseForm(Form):
|
||||
```
|
||||
|
||||
:::warning 前端空字符串
|
||||
前端某些组件(如 el-select)在未选择时可能传空字符串 `""`,`parse_request_body` 函数会自动将其转为 `None`,避免整数字段解析报错。
|
||||
前端某些组件(如 n-select)在未选择时可能传空字符串 `""`,`parse_request_body` 函数会自动将其转为 `None`,避免整数字段解析报错。
|
||||
:::
|
||||
|
||||
## 唯一性校验
|
||||
|
||||
Reference in New Issue
Block a user