优化更新
This commit is contained in:
@@ -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 进行参数校验。
|
||||
|
||||
@@ -173,19 +173,20 @@ Service 层的 `BaseService._enrich_file_urls()` 会自动为文件字段补此
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
<a-upload
|
||||
:action="uploadUrl"
|
||||
:headers="headers"
|
||||
:on-success="handleSuccess"
|
||||
@change="handleChange"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
<a-button type="primary">上传文件</a-button>
|
||||
</a-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const uploadUrl = '/api/v1/upload/uploadFile';
|
||||
const userStore = useUserStore();
|
||||
@@ -196,7 +197,7 @@ const headers = {
|
||||
function beforeUpload(file) {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
message.error('文件大小不能超过 10MB');
|
||||
}
|
||||
return isLt10M;
|
||||
}
|
||||
@@ -214,7 +215,7 @@ function handleSuccess(response) {
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-image
|
||||
<a-image
|
||||
v-if="form.cover"
|
||||
:src="getFileUrl(form.cover)"
|
||||
:preview-src-list="[getFileUrl(form.cover)]"
|
||||
|
||||
Reference in New Issue
Block a user