优化更新

This commit is contained in:
2026-09-13 20:29:19 +08:00
parent d7a081e8b7
commit 74f41f651f
65 changed files with 1061 additions and 1321 deletions
@@ -1,5 +1,5 @@
:::tip 用户管理
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。与简单 CRUD 模块不同,用户管理使用自定义 service 函数而非继承 BaseService 的类方法。
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。用户管理继承 `BaseService` 获得标准 CRUD 能力,并通过覆写钩子方法实现头像保存、密码加密、角色关联等定制逻辑。
:::
## 模块结构
@@ -237,7 +237,7 @@ def _enrich_user_records(records):
role_map = user_role.get_user_roles_batch(user_ids)
for data in records:
data["genderName"] = gender_map.get(str(data.get("gender")))
data["genderText"] = gender_map.get(str(data.get("gender")))
data["deptName"] = dept_map.get(data.get("deptId"))
data["levelName"] = level_map.get(data.get("levelId"))
data["positionName"] = position_map.get(data.get("positionId"))
@@ -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}
```
### 登录流程详解
@@ -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 之后、路由函数之前"""
@@ -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,10 +9,10 @@
前端提供 `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('添加成功')
})
@@ -21,17 +21,20 @@ const handleSubmit = useLockFn(async () => {
`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 }
}
```
@@ -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)
@@ -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):
"""
表单基类
@@ -13,7 +13,7 @@
| Vite | 3.x | 下一代前端构建工具 |
| Pinia | 2.x | 状态管理(Vuex 替代方案) |
| Vue Router | 4.x | 路由管理 |
| Axios | 1.x | HTTP 请求库 |
| Axios | 0.27.x | HTTP 请求库 |
| TypeScript | 5.x | 类型安全的 JavaScript 超集 |
| Tailwind CSS | 3.x | 原子化 CSS 框架 |
| SCSS | - | CSS 预处理器 |
@@ -74,7 +74,7 @@ class R:
### 使用示例
```python
from src.core.response import R
from src.core import response as R
# 成功
return R.ok(data=user_info)
@@ -1,7 +1,7 @@
# IP/UA 解析
:::tip 说明
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、限流、登录锁定等场景提供客户端身份信息。
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、登录锁定等场景提供客户端身份信息。
:::
## 架构总览
@@ -21,8 +21,8 @@
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────────────────────────┐
│ 限流 │ │ get_ip_region(ip) │
│ 登录锁定 │ │ ├─ 内网/保留 IP → "内网IP" │
│ 登录锁定 │ │ get_ip_region(ip) │
│ 验证码防刷│ │ ├─ 内网/保留 IP → "内网IP" │
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
└─────────┘ │ └─ 异常降级 → "未知" │
└──────────┬───────────────────────┘
@@ -49,49 +49,40 @@
### get_client_ip()
```python
def get_client_ip() -> str:
# src/common/utils/ip.py
def get_client_ip():
"""
获取客户端真实IP地址
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
X-Forwarded-For 头:
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
直接使用 request.remote_addr(fail-closed,防止被直接暴露时伪造 XFF)
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
request.remote_addr为真实客户端IP。未配置ProxyFix时,
request.remote_addr即为直连IP,避免客户端伪造。
"""
from flask import request
return request.remote_addr
```
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
### 代理链解析原理
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。项目通过 Flask 的 `ProxyFix` 中间件自动处理,在 `src/app.py` 中通过 `PROXY_NUM` 配置:
```
客户端 (1.2.3.4)
│
▼
外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4
│
▼
内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1
│
▼
Flask 应用
request.remote_addr = 10.0.0.2
X-Forwarded-For = "1.2.3.4, 10.0.0.1"
```
```python
# src/app.py - create_app()
from werkzeug.middleware.proxy_fix import ProxyFix
**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 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)
```
### 安全设计:fail-closed
| 场景 | 行为 |
|------|------|
| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.remote_addr` |
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
| `PROXY_NUM=0`(默认) | 不信任任何代理头,直接返回 `request.remote_addr` |
| `PROXY_NUM>=1` | 信任 N 层代理,从 XFF 右侧取真实客户端 IP |
| `remote_addr` 为 None | 回退到 `127.0.0.1` |
### 代理配置
@@ -100,15 +91,15 @@ Flask 应用
```bash
# 信任的代理层数(0 = 不信任代理头)
TRUSTED_PROXY_COUNT=1
PROXY_NUM=1
# 可信代理IP白名单(逗号分隔)
TRUSTED_PROXY_IPS=127.0.0.1
PROXY_NUM=127.0.0.1
```
**常见部署配置**:
| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS |
| 部署方式 | PROXY_NUM | PROXY_NUM |
|:---------|:----|:----|
| 直连(无代理) | `0` | 留空 |
| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP |
@@ -116,7 +107,7 @@ TRUSTED_PROXY_IPS=127.0.0.1
| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) |
:::warning 安全提示
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `PROXY_NUM=0`。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定。
:::
## 二、IP 地理位置查询
@@ -339,29 +330,7 @@ os_name, browser_name = parse_user_agent(ua)
| `os` | `VARCHAR(100)` | 操作系统 |
| `browser` | `VARCHAR(100)` | 浏览器 |
### 3. 请求限流
基于 IP 的 Redis 滑动窗口限流,防止接口被刷:
```python
# src/middleware/rate_limit.py
client_ip = get_client_ip()
current_key = f"rl:{client_ip}:{window_index}"
count = sliding_window_incr(redis, current_key, ...)
if count > RATE_LIMIT_LIMIT:
return R.failed(msg="请求过于频繁,请 N 秒后再试")
```
**配置项**(`.env`):
| 变量 | 默认值 | 说明 |
|:-----|:-------|:-----|
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每 IP 最大请求数 |
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 窗口时长(秒) |
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis key 前缀 |
### 4. 登录失败锁定
### 3. 登录失败锁定
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
@@ -380,7 +349,7 @@ if locked:
| 失败计数 | `login:fail:{ip}:{username}` | 累计失败次数 |
| 锁定状态 | `login:lock:{ip}:{username}` | 锁定时自动设置 TTL |
### 5. 验证码防刷
### 4. 验证码防刷
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
@@ -402,21 +371,7 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT:
# 可信代理配置
# ============================================================
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
TRUSTED_PROXY_COUNT=1
# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF)
TRUSTED_PROXY_IPS=127.0.0.1
# ============================================================
# 请求限流配置
# ============================================================
# 是否启用请求限流(默认关闭)
RATE_LIMIT_ENABLED=False
# 单窗口内每个 IP 允许的最大请求数
RATE_LIMIT_LIMIT=100
# 统计窗口时长(秒)
RATE_LIMIT_WINDOW_SECONDS=60
# Redis 计数 key 前缀
RATE_LIMIT_KEY_PREFIX=rl:
PROXY_NUM=1
```
## 六、常见问题
@@ -425,7 +380,7 @@ RATE_LIMIT_KEY_PREFIX=rl:
**原因**:本地直连无代理,`request.remote_addr` 就是 `127.0.0.1`。
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
**说明**:这是正常行为。生产环境配置 `PROXY_NUM` 后即可获取真实客户端 IP。
### 地理位置显示为"未知"
@@ -443,14 +398,6 @@ RATE_LIMIT_KEY_PREFIX=rl:
**说明**:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
### 限流不生效
**排查步骤**:
1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True`
2. 检查 Redis 连接是否正常
3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.remote_addr`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设
## 七、总结
| 模块 | 文件 | 核心能力 | 降级策略 |
@@ -459,4 +406,4 @@ RATE_LIMIT_KEY_PREFIX=rl:
| 地理位置 | `src/common/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` |
| UA 解析 | `src/common/utils/user_agent.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` |
三者组合为登录日志、操作日志、限流、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
三者组合为登录日志、操作日志、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
@@ -1,124 +0,0 @@
# 日志系统
:::tip 说明
项目通过 Python 标准库 `logging` 实现结构化日志,支持控制台 + 文件双输出,日志文件按大小自动轮转。配置入口位于 `src/core/logger.py`。
:::
## 配置入口
日志配置位于 `src/core/logger.py`:
```python
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 日志目录
LOG_DIR = Path(__file__).resolve().parents[2] / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# 日志格式
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
# 轮转配置
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
LOG_BACKUP_COUNT = 5
```
## setup_logging()
```python
def setup_logging(level: int = logging.INFO, log_file: str = "app.log") -> None:
"""
配置全局日志:root logger 输出到控制台与文件
Args:
level: 日志级别(默认 INFO)
log_file: 日志文件名(位于 logs/ 目录下)
"""
```
### 控制台输出
```python
console = logging.StreamHandler(sys.stdout)
console.setFormatter(formatter)
root.addHandler(console)
```
### 文件输出(按大小轮转)
```python
file_handler = RotatingFileHandler(
LOG_DIR / log_file,
maxBytes=LOG_MAX_BYTES, # 单文件上限 10MB
backupCount=LOG_BACKUP_COUNT, # 保留 5 份备份
encoding="utf-8",
)
```
## 日志轮转
当日志文件达到 10MB 时自动轮转:
```
logs/app.log ← 当前日志
logs/app.log.1 ← 最近一次轮转
logs/app.log.2 ← 倒数第二次
logs/app.log.3
logs/app.log.4
logs/app.log.5 ← 最早的备份(超过自动删除)
```
## 日志格式
```
2025-03-06 14:30:25,123 | INFO | modules.system.user.service | 用户登录成功
```
格式说明:
| 字段 | 说明 |
|------|------|
| `asctime` | 时间戳 |
| `levelname` | 日志级别(右对齐7字符) |
| `name` | 模块名称 |
| `message` | 日志内容 |
## 使用方式
在各模块中获取 logger:
```python
import logging
logger = logging.getLogger(__name__)
logger.info("操作成功")
logger.warning("配置缺失")
logger.error(f"异常信息: {str(e)}")
```
## 框架日志集成
Flask 和 SQLAlchemy 的默认日志统一走 root logger:
```python
for name in ("flask", "sqlalchemy"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
```
:::tip 温馨提示
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
:::
## 操作日志
除了应用日志外,还通过 `src/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `flask_operation_log` 表中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
## 总结
日志系统通过 `RotatingFileHandler` 实现按大小轮转,单文件上限 10MB 保留 5 份备份,防止日志无限增长撑爆磁盘。控制台和文件双输出,格式统一包含时间戳、级别、模块名和消息内容。
@@ -4,22 +4,22 @@
`src/common/utils/request.py` 提供请求数据解析相关的工具函数,包括批量 ID 解析、SQL LIKE 安全处理、分页参数解析等。这些工具函数在各业务模块中广泛使用。
:::
## parse_id_list
## parse_batch_ids
从请求体解析批量删除的 ID 数组,校验并转为整数列表。
```python
from flask import request
from src.common.utils.request import parse_id_list
from src.common.utils.request import parse_batch_ids
def parse_id_list(request) -> list:
def parse_batch_ids(request) -> list:
"""
从请求体解析 ID 数组
请求体格式: [1, 2, 3]
返回: [1, 2, 3]
示例:
>>> parse_id_list(request)
>>> parse_batch_ids(request)
[1, 2, 3]
"""
ids = request.get_json()
@@ -38,9 +38,9 @@ def parse_id_list(request) -> list:
@position_blue.route('/batchDelete', methods=['DELETE'])
@check_permission('sys:position:batchDelete')
def batch_delete():
from src.modules.system.position.service import batch_delete_positions
id_list = parse_id_list(request)
return batch_delete_positions(id_list)
from src.modules.system.position.service import delete_positions
id_list = parse_batch_ids(request)
return delete_positions(id_list)
```
## parse_request_body
@@ -207,4 +207,4 @@ def get_client_ip() -> str:
## 总结
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_id_list` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_batch_ids` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。