优化更新

This commit is contained in:
2026-09-13 23:01:19 +08:00
parent 0c67b3e8b5
commit 23bd737b82
88 changed files with 2214 additions and 2503 deletions
@@ -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`,避免整数字段解析报错。
:::
## 唯一性校验