签入版本
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# 数据库会话
|
||||
|
||||
:::tip 说明
|
||||
通过 `contextvars` + Starlette 中间件实现请求级数据库会话管理,写操作按业务成功与否自动 commit/rollback。核心代码位于 `src/core/database.py` 和 `src/middleware/db_session.py`。
|
||||
:::
|
||||
|
||||
## 核心架构
|
||||
|
||||
数据库会话管理位于 `src/core/database.py` 和 `src/middleware/db_session.py`。
|
||||
|
||||
### contextvars 隔离
|
||||
|
||||
```python
|
||||
from contextvars import ContextVar
|
||||
|
||||
# 请求级数据库 session 上下文变量
|
||||
_db_ctx: ContextVar = ContextVar('_db_ctx')
|
||||
```
|
||||
|
||||
每个请求通过 `contextvars` 持有独立的数据库会话,协程之间互不干扰。
|
||||
|
||||
### get_db_session()
|
||||
|
||||
业务代码通过 `get_db_session()` 获取当前请求的数据库会话:
|
||||
|
||||
```python
|
||||
def get_db_session():
|
||||
"""获取当前请求的数据库 session(仅限请求上下文内调用)"""
|
||||
try:
|
||||
return _db_ctx.get()
|
||||
except LookupError:
|
||||
raise RuntimeError(
|
||||
"get_db_session() 只能在请求上下文内调用(缺少 db_session 中间件注入)。"
|
||||
"后台线程/启动阶段请使用 SessionLocal() 创建独立会话。"
|
||||
) from None
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`get_db_session()` 只能在请求上下文内调用。后台线程或启动阶段需要使用 `SessionLocal()` 创建独立会话。
|
||||
:::
|
||||
|
||||
## 会话中间件
|
||||
|
||||
`src/middleware/db_session.py` 中的中间件负责会话的创建、提交和关闭:
|
||||
|
||||
```python
|
||||
async def db_session_middleware(request: Request, call_next):
|
||||
"""请求级 DB 会话:写操作按业务成功与否 commit/rollback,finally 关闭"""
|
||||
db = SessionLocal()
|
||||
_db_ctx.set(db)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||||
body, consumed = await read_response_body(response)
|
||||
if consumed:
|
||||
response = rebuild_response(response, body)
|
||||
if _is_business_success(body):
|
||||
db.commit()
|
||||
else:
|
||||
db.rollback()
|
||||
return response
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
```
|
||||
|
||||
### 业务成功判断
|
||||
|
||||
中间件通过响应体 JSON 的 `code` 字段判断业务是否成功:
|
||||
|
||||
```python
|
||||
def _is_business_success(body: bytes) -> bool:
|
||||
"""仅当响应体为 JSON 且业务码 code == 0 视为成功"""
|
||||
if not body:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return data.get('code') == 0
|
||||
```
|
||||
|
||||
## 独立提交
|
||||
|
||||
`commit_independently()` 函数用于审计日志等需要无论业务成功与否都持久化的场景:
|
||||
|
||||
```python
|
||||
def commit_independently(obj):
|
||||
"""
|
||||
使用独立session提交对象,不受请求事务影响
|
||||
|
||||
日志等审计数据无论业务成功或失败都应持久化,
|
||||
因此需要绕过 AOP 事务中间件的 commit/rollback 机制。
|
||||
"""
|
||||
independent_session = SessionLocal()
|
||||
try:
|
||||
independent_session.add(obj)
|
||||
independent_session.commit()
|
||||
except Exception:
|
||||
independent_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
independent_session.close()
|
||||
```
|
||||
|
||||
:::warning 使用场景
|
||||
`commit_independently()` 创建独立会话,不受请求级事务影响。适用于操作日志、登录日志等审计数据的持久化。普通业务数据应使用 `get_db_session()` 获取请求级会话,由中间件统一管理事务。
|
||||
:::
|
||||
|
||||
## 会话生命周期
|
||||
|
||||
```
|
||||
请求进入
|
||||
→ 中间件创建 SessionLocal 实例
|
||||
→ 注入 _db_ctx
|
||||
→ 业务代码通过 get_db_session() 获取会话
|
||||
→ 中间件根据响应码 commit/rollback
|
||||
→ finally 关闭会话
|
||||
```
|
||||
|
||||
## 中间件注册顺序
|
||||
|
||||
在 `src/core/app.py` 中,中间件按以下顺序注册:
|
||||
|
||||
```
|
||||
CORS → redis → rate_limit → db_session → operation_log → logger
|
||||
```
|
||||
|
||||
`db_session` 在 `rate_limit` 之后,确保限流拦截的请求不会创建数据库会话。
|
||||
|
||||
## 总结
|
||||
|
||||
数据库会话管理通过 `contextvars` 实现请求级隔离,中间件自动根据业务响应码 commit/rollback,`commit_independently()` 提供独立事务能力用于审计数据持久化。这种设计确保了事务边界清晰,且不会阻塞事件循环。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 导入导出
|
||||
|
||||
:::tip 说明
|
||||
基于 `openpyxl` 实现 Excel 导入导出功能,支持通过 `file_template` 模块管理导入模板配置。端点定义在各业务模块的 endpoint 文件中(如 `src/api/v1/endpoints/user.py`)。
|
||||
:::
|
||||
|
||||
## 功能概述
|
||||
|
||||
Excel 导入导出是后台管理系统的常见需求,通过 `openpyxl` 库实现:
|
||||
|
||||
```
|
||||
Excel 导出:
|
||||
1. 将数据库中的数据导出为 Excel 文件,支持自定义表头和格式
|
||||
2. 适用于生成报表、数据备份等场景
|
||||
|
||||
Excel 导入:
|
||||
1. 将 Excel 文件中的数据导入到数据库,支持批量处理和校验
|
||||
2. 适用于批量数据录入、数据迁移等场景
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
在 `requirements.txt` 中已包含:
|
||||
|
||||
```
|
||||
openpyxl>=3.1.0
|
||||
```
|
||||
|
||||
## 导出实现
|
||||
|
||||
导出接口通常在各业务模块的 endpoint 中定义,返回 `StreamingResponse`:
|
||||
|
||||
```python
|
||||
from openpyxl import Workbook
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
|
||||
@router.get('/export')
|
||||
@permission_required("sys:user:export")
|
||||
def export(request: Request):
|
||||
# 查询数据
|
||||
data_list = user_service.get_export_data(request)
|
||||
|
||||
# 创建工作簿
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "用户信息"
|
||||
|
||||
# 写入表头
|
||||
ws.append(["用户名", "姓名", "手机号", "邮箱", "状态"])
|
||||
|
||||
# 写入数据
|
||||
for item in data_list:
|
||||
ws.append([item.username, item.realname, item.phone, item.email, item.status_name])
|
||||
|
||||
# 返回流式响应
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=export.xlsx"}
|
||||
)
|
||||
```
|
||||
|
||||
## 导入实现
|
||||
|
||||
导入接口接收上传的 Excel 文件,解析后逐行校验并入库:
|
||||
|
||||
```python
|
||||
@router.post('/import')
|
||||
@permission_required("sys:user:import")
|
||||
@check_demo
|
||||
async def import_excel(request: Request, file: UploadFile):
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
|
||||
# 跳过表头,逐行解析
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
username, realname, phone, email, status = row
|
||||
# 校验并入库 ...
|
||||
```
|
||||
|
||||
## file_template 模块
|
||||
|
||||
`file_template` 模块用于管理导入模板的配置信息,定义模板的字段映射、校验规则等。
|
||||
|
||||
模块路径:`src/modules/file_template/`
|
||||
|
||||
```
|
||||
模板配置包含:
|
||||
- 模板名称
|
||||
- 关联模块
|
||||
- 字段映射关系
|
||||
- 校验规则
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
导入时建议先下载模板文件,按模板格式填写数据后再导入,可减少格式错误。
|
||||
:::
|
||||
|
||||
## 错误处理
|
||||
|
||||
导入过程中遇到校验错误时,返回详细的错误信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "导入失败:第3行用户名已存在,第5行手机号格式错误",
|
||||
"data": null,
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Excel 导入导出功能基于 `openpyxl` 实现,导出使用 `StreamingResponse` 流式返回避免内存溢出,导入逐行校验并提供详细错误信息。`file_template` 模块可管理导入模板配置,便于维护字段映射和校验规则。
|
||||
@@ -0,0 +1,477 @@
|
||||
# IP/UA 解析
|
||||
|
||||
:::tip 说明
|
||||
提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/utils/ip2region.py`),UA 解析位于 `src/utils/ua.py`。三者组合使用,为登录日志、操作日志、限流、登录锁定等场景提供客户端身份信息。
|
||||
:::
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ get_client_ip(request) │
|
||||
│ ├─ 直连对端命中可信代理白名单? │
|
||||
│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │
|
||||
│ │ └─ 否 → request.client.host(fail-closed) │
|
||||
│ └─ client 为 None → 回退 127.0.0.1 │
|
||||
└──────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
▼ ▼
|
||||
┌─────────┐ ┌──────────────────────────────────┐
|
||||
│ 限流 │ │ get_ip_region(ip) │
|
||||
│ 登录锁定 │ │ ├─ 内网/保留 IP → "内网IP" │
|
||||
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
|
||||
└─────────┘ │ └─ 异常降级 → "未知" │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ parse_user_agent(ua) │
|
||||
│ ├─ 操作系统(正则) │
|
||||
│ └─ 浏览器(正则) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 写入日志表 │
|
||||
│ ip / location / │
|
||||
│ os / browser │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## 一、IP 获取
|
||||
|
||||
位于 `src/utils/ip.py`,从 FastAPI Request 获取客户端真实 IP。
|
||||
|
||||
### get_client_ip(request)
|
||||
|
||||
```python
|
||||
def get_client_ip(request) -> str:
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
|
||||
X-Forwarded-For 头:
|
||||
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
|
||||
直接使用 request.client.host(fail-closed,防止被直接暴露时伪造 XFF)
|
||||
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
|
||||
"""
|
||||
```
|
||||
|
||||
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
|
||||
|
||||
### 代理链解析原理
|
||||
|
||||
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.client.host` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
|
||||
|
||||
```
|
||||
客户端 (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
|
||||
│
|
||||
▼
|
||||
FastAPI 应用
|
||||
request.client.host = 10.0.0.2
|
||||
X-Forwarded-For = "1.2.3.4, 10.0.0.1"
|
||||
```
|
||||
|
||||
**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。
|
||||
|
||||
### 安全设计:fail-closed
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.client.host` |
|
||||
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
|
||||
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
|
||||
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
|
||||
| `request.client` 为 None | 回退到 `127.0.0.1` |
|
||||
|
||||
### 代理配置
|
||||
|
||||
在 `.env` 中配置可信代理:
|
||||
|
||||
```bash
|
||||
# 信任的代理层数(0 = 不信任代理头)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
|
||||
# 可信代理IP白名单(逗号分隔)
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
**常见部署配置**:
|
||||
|
||||
| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS |
|
||||
|:---------|:----|:----|
|
||||
| 直连(无代理) | `0` | 留空 |
|
||||
| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP |
|
||||
| Nginx + CDN | `2` | Nginx 内网 IP |
|
||||
| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) |
|
||||
|
||||
:::warning 安全提示
|
||||
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。
|
||||
:::
|
||||
|
||||
## 二、IP 地理位置查询
|
||||
|
||||
位于 `src/utils/ip2region.py`,基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,无需网络请求。
|
||||
|
||||
### 核心接口
|
||||
|
||||
#### get_ip_region(ip) → dict
|
||||
|
||||
获取完整区域信息字典,是最底层的查询接口。
|
||||
|
||||
```python
|
||||
def get_ip_region(ip) -> dict:
|
||||
"""
|
||||
根据IP获取完整区域信息字典
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"country": "中国", # 国家
|
||||
"province": "浙江省", # 省份(无数据时为空字符串)
|
||||
"city": "杭州市", # 城市(无数据时为空字符串)
|
||||
"isp": "阿里", # 运营商(无数据时为空字符串)
|
||||
"country_code": "CN" # 国家代码(无数据时为空字符串)
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
**返回值示例**:
|
||||
|
||||
| IP | 返回值 |
|
||||
|:----|:----|
|
||||
| `8.8.8.8` | `{"country": "United States", "province": "California", "city": "", "isp": "Google LLC", "country_code": "US"}` |
|
||||
| `114.114.114.114` | `{"country": "中国", "province": "江苏省", "city": "南京市", "isp": "南京信风网络科技", "country_code": "CN"}` |
|
||||
| `10.0.0.1` | `{"country": "内网IP", "province": "", "city": "", "isp": "", "country_code": ""}` |
|
||||
| `172.abc` | `{"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}` |
|
||||
|
||||
#### get_ip_location(ip) → str
|
||||
|
||||
获取地理位置文本描述,与原 `utils/ip.py` 的返回格式兼容。
|
||||
|
||||
```python
|
||||
def get_ip_location(ip) -> str:
|
||||
"""
|
||||
格式: "国家 省份 城市"
|
||||
内网IP返回 "内网IP",查询失败返回 "未知"
|
||||
|
||||
示例:
|
||||
>>> get_ip_location('114.114.114.114')
|
||||
'中国 江苏省 南京市'
|
||||
>>> get_ip_location('8.8.8.8')
|
||||
'United States California'
|
||||
>>> get_ip_location('10.0.0.1')
|
||||
'内网IP'
|
||||
"""
|
||||
```
|
||||
|
||||
:::tip 去重逻辑
|
||||
当城市与省份相同时,城市不会重复拼接。例如 `province="北京", city="北京"` 时返回 `"中国 北京"` 而非 `"中国 北京 北京"`。
|
||||
:::
|
||||
|
||||
#### 便捷函数
|
||||
|
||||
| 函数 | 返回值 | 示例 |
|
||||
|:-----|:-------|:-----|
|
||||
| `get_ip_country(ip)` | 国家 | `"中国"` |
|
||||
| `get_ip_province(ip)` | 省份 | `"浙江省"` |
|
||||
| `get_ip_city(ip)` | 城市 | `"杭州市"` |
|
||||
| `get_ip_isp(ip)` | 运营商 | `"阿里"` |
|
||||
|
||||
### 离线数据库
|
||||
|
||||
ip2region.xdb 文件位于 `src/utils/ip2region.xdb`,采用懒加载单例模式:
|
||||
|
||||
```python
|
||||
_XDB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ip2region.xdb")
|
||||
_searcher = None
|
||||
|
||||
def _get_searcher():
|
||||
"""获取搜索器单例(懒加载,首次调用时初始化)"""
|
||||
global _searcher
|
||||
if _searcher is not None:
|
||||
return _searcher
|
||||
c_buff = util.load_content_from_file(_XDB_PATH)
|
||||
_searcher = searcher.new_with_buffer(util.IPv4, c_buff)
|
||||
return _searcher
|
||||
```
|
||||
|
||||
- **首次查询时**加载 xdb 文件到内存(约 10MB),后续查询零 IO 延迟
|
||||
- **xdb 文件不存在**时返回 `None`,查询降级为 `"未知"`
|
||||
- xdb 数据定期更新可从 ip2region 官方仓库获取
|
||||
|
||||
### 内网 IP 判断
|
||||
|
||||
内网/保留 IP 直接返回 `"内网IP"`,不查询数据库:
|
||||
|
||||
| 类型 | 匹配规则 | 示例 |
|
||||
|:-----|:---------|:-----|
|
||||
| 回环地址 | `127.0.0.1`、`localhost`、`0.0.0.0` | `127.0.0.1` |
|
||||
| A 类私有 | `10.x.x.x` | `10.0.0.1` |
|
||||
| B 类私有 | `172.16.x.x` ~ `172.31.x.x` | `172.16.5.5` |
|
||||
| C 类私有 | `192.168.x.x` | `192.168.1.1` |
|
||||
|
||||
:::warning 畸形 IP 防御
|
||||
畸形输入(如 `172.abc`)不抛异常,按非内网处理交由查询侧兜底返回 `"未知"`。这避免了 `int(parts[1])` 抛出 `ValueError` 冒泡到 `record_login_log` / `record_operation_log`,导致日志整段丢失。
|
||||
:::
|
||||
|
||||
### 异常降级策略
|
||||
|
||||
所有查询接口均采用 **fail-open** 降级策略——查询失败绝不向上抛异常,保证日志落库不受影响:
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.warning(f"IP地理位置查询失败 [{ip}]: {e}")
|
||||
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
```
|
||||
|
||||
| 异常场景 | 降级行为 |
|
||||
|:---------|:---------|
|
||||
| xdb 文件不存在 | `get_ip_region` 返回 `"未知"` |
|
||||
| xdb 加载失败 | 同上 |
|
||||
| 畸形 IP 地址 | 同上 |
|
||||
| 查询运行时异常 | 同上 + warning 日志 |
|
||||
|
||||
## 三、UA 解析
|
||||
|
||||
位于 `src/utils/ua.py`,通过正则匹配从 User-Agent 字符串提取操作系统和浏览器信息。
|
||||
|
||||
### parse_user_agent(ua_string)
|
||||
|
||||
```python
|
||||
def parse_user_agent(ua_string):
|
||||
"""
|
||||
从 User-Agent 字符串提取 OS 和浏览器信息
|
||||
|
||||
Args:
|
||||
ua_string: HTTP User-Agent 头部字符串
|
||||
|
||||
Returns:
|
||||
tuple: (os_name, browser_name)
|
||||
|
||||
示例:
|
||||
>>> parse_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
('Windows 10.0', 'Chrome 120.0')
|
||||
>>> parse_user_agent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1")
|
||||
('iOS 17.2', 'Safari 605.1')
|
||||
>>> parse_user_agent("")
|
||||
('', '')
|
||||
"""
|
||||
```
|
||||
|
||||
### 支持的操作系统
|
||||
|
||||
| 操作系统 | UA 关键字 | 输出示例 |
|
||||
|:---------|:---------|:---------|
|
||||
| Windows | `Windows NT 10.0` | `Windows 10.0` |
|
||||
| macOS | `Mac OS X 14_2_1` | `Mac OS X 14.2.1` |
|
||||
| Android | `Android 14` | `Android 14` |
|
||||
| iPhone | `iPhone OS 17_2` | `iOS 17.2` |
|
||||
| iPad | `iPad; CPU OS 17_2` | `iOS 17.2` |
|
||||
| Linux | `Linux` | `Linux` |
|
||||
| Ubuntu | `Ubuntu` | `Ubuntu` |
|
||||
|
||||
### 支持的浏览器
|
||||
|
||||
按匹配优先级排列(Edge 优先于 Chrome,避免 Edg/ 被误判为 Chrome):
|
||||
|
||||
| 浏览器 | UA 关键字 | 输出示例 |
|
||||
|:-------|:---------|:---------|
|
||||
| Edge | `Edg/120.0.0.0` | `Edge 120.0` |
|
||||
| Chrome | `Chrome/120.0.0.0` | `Chrome 120.0` |
|
||||
| Firefox | `Firefox/121.0` | `Firefox 121.0` |
|
||||
| Safari | `Safari/605.1.15` | `Safari 605.1` |
|
||||
| IE 11 | `Trident/...rv:11.0` | `IE 11.0` |
|
||||
| IE 10- | `MSIE 10.0` | `IE 10.0` |
|
||||
|
||||
:::warning 注意
|
||||
Safari 的匹配模式 `Safari/(\d+\.?\d*)` 也会匹配 Chrome UA 中的 Safari 版本号。但由于 Chrome 的匹配在 Safari 之前,实际不会误判。如果需要支持更多浏览器(如 Opera、微信浏览器等),可在 `_BROWSER_PATTERNS` 列表头部追加。
|
||||
:::
|
||||
|
||||
## 四、应用场景
|
||||
|
||||
### 1. 登录日志
|
||||
|
||||
登录/退出时自动记录 IP、地理位置、操作系统、浏览器:
|
||||
|
||||
```python
|
||||
# src/modules/login_log/service.py
|
||||
ip = get_client_ip(request)
|
||||
location = get_ip_location(ip)
|
||||
ua = request.headers.get('User-Agent', '')
|
||||
os_name, browser_name = parse_user_agent(ua)
|
||||
|
||||
log = LoginLog(
|
||||
username=username,
|
||||
ip=ip, # "1.2.3.4"
|
||||
location=location, # "中国 浙江省 杭州市"
|
||||
os=os_name, # "Windows 10.0"
|
||||
browser=browser_name, # "Chrome 120.0"
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**数据库字段**(`fastapi_login_log` 表):
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|:-----|:-----|:-----|:-----|
|
||||
| `ip` | `VARCHAR(50)` | 客户端 IP | `1.2.3.4` |
|
||||
| `location` | `VARCHAR(100)` | IP 归属地 | `中国 浙江省 杭州市` |
|
||||
| `os` | `VARCHAR(100)` | 操作系统 | `Windows 10.0` |
|
||||
| `browser` | `VARCHAR(100)` | 浏览器 | `Chrome 120.0` |
|
||||
|
||||
### 2. 操作日志
|
||||
|
||||
写操作(add/update/delete/status)自动记录操作者 IP 和设备信息:
|
||||
|
||||
```python
|
||||
# src/modules/operation_log/service.py
|
||||
ip = get_client_ip(request)
|
||||
location = get_ip_location(ip)
|
||||
ua = request.headers.get('User-Agent', '')
|
||||
os_name, browser_name = parse_user_agent(ua)
|
||||
```
|
||||
|
||||
**数据库字段**(`fastapi_operation_log` 表):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|:-----|:-----|:-----|
|
||||
| `ip` | `VARCHAR(100)` | 请求 IP |
|
||||
| `location` | `VARCHAR(255)` | 请求所在区域 |
|
||||
| `os` | `VARCHAR(100)` | 操作系统 |
|
||||
| `browser` | `VARCHAR(100)` | 浏览器 |
|
||||
|
||||
### 3. 请求限流
|
||||
|
||||
基于 IP 的 Redis 滑动窗口限流,防止接口被刷:
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
client_ip = get_client_ip(request)
|
||||
current_key = f"rl:{client_ip}:{window_index}"
|
||||
count = await sliding_window_incr(redis, current_key, ...)
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
return JSONResponse({"code": 1, "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. 登录失败锁定
|
||||
|
||||
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
|
||||
|
||||
```python
|
||||
# src/modules/auth/service.py
|
||||
ip = get_client_ip(request)
|
||||
locked, remaining = await _is_locked(redis, ip, data.username)
|
||||
if locked:
|
||||
return R.failed(f"登录失败次数过多,请{remaining}秒后再试")
|
||||
```
|
||||
|
||||
**Redis Key 设计**:
|
||||
|
||||
| Key | 格式 | 说明 |
|
||||
|:----|:-----|:-----|
|
||||
| 失败计数 | `login:fail:{ip}:{username}` | 累计失败次数 |
|
||||
| 锁定状态 | `login:lock:{ip}:{username}` | 锁定时自动设置 TTL |
|
||||
|
||||
### 5. 验证码防刷
|
||||
|
||||
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
|
||||
|
||||
```python
|
||||
# src/utils/captcha.py
|
||||
client_ip = get_client_ip(request)
|
||||
rate_key = f"captcha:rate:{client_ip}"
|
||||
count = await redis.get(rate_key)
|
||||
if count and int(count) >= CAPTCHA_RATE_LIMIT:
|
||||
raise BusinessException(msg="请求过于频繁,请等待 N 秒后再试")
|
||||
```
|
||||
|
||||
## 五、配置参考
|
||||
|
||||
### 完整 .env 配置
|
||||
|
||||
```bash
|
||||
# ============================================================
|
||||
# 可信代理配置
|
||||
# ============================================================
|
||||
# 信任的代理层数(0 = 不信任代理头,直接使用 request.client.host)
|
||||
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:
|
||||
```
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### 开发环境日志中 IP 显示为 127.0.0.1
|
||||
|
||||
**原因**:本地直连无代理,`request.client.host` 就是 `127.0.0.1`。
|
||||
|
||||
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
|
||||
|
||||
### 地理位置显示为"未知"
|
||||
|
||||
**可能原因**:
|
||||
|
||||
1. ip2region.xdb 文件不存在或加载失败(检查启动日志)
|
||||
2. 查询的 IP 是 IPv6(当前仅支持 IPv4)
|
||||
3. 畸形 IP 地址
|
||||
|
||||
**解决**:检查 `src/utils/ip2region.xdb` 文件是否存在,启动日志中是否有加载成功提示。
|
||||
|
||||
### 地理位置显示为"内网IP"
|
||||
|
||||
**原因**:客户端 IP 在内网/保留地址段内(`10.x`、`172.16-31.x`、`192.168.x`、`127.0.0.1`)。
|
||||
|
||||
**说明**:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
|
||||
|
||||
### 限流不生效
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True`
|
||||
2. 检查 Redis 连接是否正常
|
||||
3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.client.host`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设
|
||||
|
||||
### 如何更新 ip2region 数据库
|
||||
|
||||
从 [ip2region 官方仓库](https://github.com/lionsoul2014/ip2region) 下载最新的 `ip2region.xdb` 文件,替换 `src/utils/ip2region.xdb`,重启应用即可。首次查询时自动加载新数据。
|
||||
|
||||
## 七、总结
|
||||
|
||||
| 模块 | 文件 | 核心能力 | 降级策略 |
|
||||
|:-----|:-----|:---------|:---------|
|
||||
| IP 获取 | `src/utils/ip.py` | 从 Request 提取真实客户端 IP,支持可信代理白名单 | client 为 None 时回退 `127.0.0.1` |
|
||||
| 地理位置 | `src/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` |
|
||||
| UA 解析 | `src/utils/ua.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` |
|
||||
|
||||
三者组合为登录日志、操作日志、限流、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 日志系统
|
||||
|
||||
:::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)}")
|
||||
```
|
||||
|
||||
## 框架日志集成
|
||||
|
||||
uvicorn 和 fastapi 的默认日志统一走 root logger:
|
||||
|
||||
```python
|
||||
for name in ("uvicorn", "fastapi"):
|
||||
lg = logging.getLogger(name)
|
||||
lg.handlers.clear()
|
||||
lg.propagate = True
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
|
||||
:::
|
||||
|
||||
## 操作日志
|
||||
|
||||
除了应用日志外,还通过 `src/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `fastapi_operation_log` 表中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
|
||||
|
||||
## 总结
|
||||
|
||||
日志系统通过 `RotatingFileHandler` 实现按大小轮转,单文件上限 10MB 保留 5 份备份,防止日志无限增长撑爆磁盘。控制台和文件双输出,格式统一包含时间戳、级别、模块名和消息内容。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 密码工具
|
||||
|
||||
:::tip 说明
|
||||
采用 bcrypt 双重加盐方案存储密码,源码位于 `src/core/password.py`。工作因子 12(约 250ms),在安全性和性能之间取得平衡。
|
||||
:::
|
||||
|
||||
## 加密方案
|
||||
|
||||
密码存储采用「外部随机盐 + bcrypt 内部盐」双重加盐方案:
|
||||
|
||||
```
|
||||
用户密码 + 外部盐 → bcrypt.hashpw → 存储哈希值
|
||||
```
|
||||
|
||||
### 生成盐值
|
||||
|
||||
```python
|
||||
import secrets
|
||||
|
||||
BCRYPT_ROUNDS = 12 # 工作因子,约250ms
|
||||
|
||||
def generate_salt(length: int = 10) -> str:
|
||||
"""生成随机盐值"""
|
||||
return secrets.token_urlsafe(length)[:length]
|
||||
```
|
||||
|
||||
### 加密密码
|
||||
|
||||
```python
|
||||
import bcrypt
|
||||
|
||||
def encrypt_password(password: str, salt: str) -> str:
|
||||
"""使用 bcrypt 加密密码(外部 salt 双重加盐)"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
|
||||
return hashed.decode('utf-8')
|
||||
```
|
||||
|
||||
### 验证密码
|
||||
|
||||
```python
|
||||
def verify_password(password: str, salt: str, hashed: str) -> bool:
|
||||
"""验证密码是否正确"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
|
||||
```
|
||||
|
||||
## 工作因子
|
||||
|
||||
`BCRYPT_ROUNDS = 12` 表示 bcrypt 的工作因子为 12,即需要 2^12 = 4096 次迭代。值越大计算越慢越安全,12 是当前推荐值(约 250ms)。
|
||||
|
||||
:::tip 温馨提示
|
||||
bcrypt 的工作因子决定了加密速度。增加 1 会使计算时间翻倍。建议保持默认值 12,在安全性和性能之间取得平衡。
|
||||
:::
|
||||
|
||||
## 存储结构
|
||||
|
||||
用户表中密码相关字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `password` | bcrypt 哈希后的密码 |
|
||||
| `salt` | 外部随机盐值 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 注册/创建用户
|
||||
|
||||
```python
|
||||
from core.password import generate_salt, encrypt_password
|
||||
|
||||
salt = generate_salt()
|
||||
hashed = encrypt_password("mypassword", salt)
|
||||
# 将 hashed 和 salt 存入数据库
|
||||
```
|
||||
|
||||
### 登录验证
|
||||
|
||||
登录验证位于 `src/modules/auth/service.py`,bcrypt 为 CPU 密集操作,使用 `asyncio.to_thread` 放入线程池避免阻塞事件循环:
|
||||
|
||||
```python
|
||||
from core.password import verify_password
|
||||
|
||||
# 从数据库获取用户记录
|
||||
user = get_user_by_username(username)
|
||||
|
||||
# bcrypt 为 CPU 密集操作,放入线程池
|
||||
password_ok = await asyncio.to_thread(
|
||||
password.verify_password, data.password, user.salt or '', user.password or ''
|
||||
)
|
||||
if password_ok:
|
||||
# 密码正确
|
||||
...
|
||||
```
|
||||
|
||||
### 修改密码
|
||||
|
||||
```python
|
||||
# 验证旧密码
|
||||
if not verify_password(old_password, user.salt, user.password):
|
||||
return R.failed("原密码错误")
|
||||
|
||||
# 生成新盐值并加密
|
||||
new_salt = generate_salt()
|
||||
new_hashed = encrypt_password(new_password, new_salt)
|
||||
# 更新数据库
|
||||
```
|
||||
|
||||
## 安全特性
|
||||
|
||||
```
|
||||
1. 双重加盐:外部随机盐 + bcrypt 内部盐,即使两个用户使用相同密码,存储的哈希值也不同
|
||||
2. 抗彩虹表:bcrypt 自带盐值,无法预计算彩虹表
|
||||
3. 抗暴力破解:工作因子 12 使每次验证约 250ms,大幅增加暴力破解成本
|
||||
4. 自适应性:工作因子可随硬件提升而增加
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
密码工具采用 bcrypt 双重加盐方案,外部盐值由 `secrets.token_urlsafe()` 生成,bcrypt 内部盐自动处理。工作因子 12 在安全性和性能之间取得平衡,有效抵御彩虹表和暴力破解攻击。
|
||||
@@ -0,0 +1,154 @@
|
||||
# 权限缓存
|
||||
|
||||
:::tip 说明
|
||||
通过 Redis 实现用户权限和菜单的缓存,采用用户级缓存 + 全局版本号双层策略。源码位于 `src/utils/perm_cache.py`,供 `src/modules/system/menu/service.py` 中的权限查询函数调用。
|
||||
:::
|
||||
|
||||
## 缓存策略
|
||||
|
||||
采用「用户级缓存 + 全局版本号」双层策略:
|
||||
|
||||
```
|
||||
1. 每个用户的缓存 Key 带版本号后缀
|
||||
2. 版本号存全局 Key(perm:version)
|
||||
3. 菜单变更时递增全局版本号,所有用户缓存自然失效
|
||||
4. 用户角色变更时,直接删除该用户缓存
|
||||
5. 角色菜单变更时,查询该角色下所有用户 ID,批量删除缓存
|
||||
```
|
||||
|
||||
## 缓存 Key 结构
|
||||
|
||||
```python
|
||||
_VERSION_KEY = 'perm:version' # 全局版本号
|
||||
_PERM_PREFIX = 'perm:list:' # 用户权限列表
|
||||
_MENU_PREFIX = 'perm:menu:' # 用户菜单树
|
||||
_USER_ACTIVE_PREFIX = 'user:active:' # 用户有效状态
|
||||
```
|
||||
|
||||
Key 格式示例:
|
||||
|
||||
```
|
||||
perm:version → 5
|
||||
perm:list:1001:5 → ["sys:user:page", "sys:user:add", ...]
|
||||
perm:menu:1001:5 → [{id: 1, name: "系统管理", children: [...]}]
|
||||
user:active:1001 → "1"
|
||||
```
|
||||
|
||||
## 权限缓存读写
|
||||
|
||||
### get_cached_permissions()
|
||||
|
||||
```python
|
||||
async def get_cached_permissions(user_id: int):
|
||||
"""获取缓存的用户权限列表,未命中返回 None"""
|
||||
redis = get_redis()
|
||||
ver = await _get_version()
|
||||
key = f'{_PERM_PREFIX}{user_id}:{ver}'
|
||||
data = await redis.get(key)
|
||||
if data is not None:
|
||||
return json.loads(data)
|
||||
return None
|
||||
```
|
||||
|
||||
### set_cached_permissions()
|
||||
|
||||
```python
|
||||
async def set_cached_permissions(user_id: int, perm_list: list):
|
||||
"""缓存用户权限列表(空列表不缓存,避免缓存空壳后无法感知后续数据变更)"""
|
||||
if not perm_list:
|
||||
return
|
||||
redis = get_redis()
|
||||
ver = await _get_version()
|
||||
key = f'{_PERM_PREFIX}{user_id}:{ver}'
|
||||
await redis.set(key, json.dumps(perm_list), ex=_CACHE_TTL)
|
||||
```
|
||||
|
||||
## 缓存失效
|
||||
|
||||
### 菜单变更
|
||||
|
||||
```python
|
||||
async def invalidate_menu_change():
|
||||
"""菜单增删改时调用,递增全局版本号使所有用户缓存失效"""
|
||||
await _incr_version()
|
||||
```
|
||||
|
||||
递增版本号后,旧版本的缓存 Key 不再被访问,自然过期(TTL 2小时)。
|
||||
|
||||
### 用户角色变更
|
||||
|
||||
```python
|
||||
async def invalidate_user_perms(user_id: int):
|
||||
"""用户角色变更时调用,删除该用户的权限和菜单缓存"""
|
||||
redis = get_redis()
|
||||
ver = await _get_version()
|
||||
keys = [
|
||||
f'{_PERM_PREFIX}{user_id}:{ver}',
|
||||
f'{_MENU_PREFIX}{user_id}:{ver}',
|
||||
]
|
||||
await redis.delete(*keys)
|
||||
```
|
||||
|
||||
### 角色菜单变更
|
||||
|
||||
```python
|
||||
async def invalidate_role_perms(role_id: int):
|
||||
"""角色菜单变更时调用,清理该角色下所有用户的权限和菜单缓存"""
|
||||
# 查询该角色下所有用户 ID
|
||||
user_ids = await asyncio.to_thread(_query_user_ids)
|
||||
|
||||
# 批量删除缓存
|
||||
redis = get_redis()
|
||||
ver = await _get_version()
|
||||
keys = []
|
||||
for uid in user_ids:
|
||||
keys.append(f'{_PERM_PREFIX}{uid}:{ver}')
|
||||
keys.append(f'{_MENU_PREFIX}{uid}:{ver}')
|
||||
await redis.delete(*keys)
|
||||
```
|
||||
|
||||
## 用户有效状态缓存
|
||||
|
||||
用于 `login_required` 每请求校验,避免每次请求都查库:
|
||||
|
||||
```python
|
||||
_USER_ACTIVE_TTL = 300 # 5分钟
|
||||
|
||||
async def get_cached_user_active(user_id: int):
|
||||
"""读取用户有效状态缓存;未命中返回 None(调用方需回源 DB 校验)"""
|
||||
|
||||
async def set_cached_user_active(user_id: int, active: bool):
|
||||
"""写入用户有效状态缓存"""
|
||||
|
||||
async def invalidate_user_active(user_id: int):
|
||||
"""用户状态/删除变更时调用,删除有效状态缓存"""
|
||||
```
|
||||
|
||||
## 异常降级
|
||||
|
||||
所有缓存操作都包裹 try/except,Redis 不可用时降级直查 DB:
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.warning(f"读取权限缓存异常: {e}")
|
||||
return None # 调用方回源 DB
|
||||
```
|
||||
|
||||
删除失败时递增全局版本号孤立旧缓存(fail-closed):
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.error(f"清理用户权限缓存异常,降级递增全局版本号: {e}")
|
||||
try:
|
||||
await _incr_version()
|
||||
except Exception as e2:
|
||||
logger.error(f"降级递增版本号失败: {e2}")
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
权限缓存的 TTL 为 2小时,用户有效状态缓存 TTL 为 5分钟。正常情况下缓存由业务操作即时失效,TTL 仅作为兜底防止 Redis 故障时缓存永久残留。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
权限缓存采用用户级缓存 + 全局版本号双层策略,菜单变更通过递增版本号使所有缓存自然失效,无需遍历删除。所有缓存操作都有异常降级机制,Redis 不可用时自动回源数据库查询。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 请求解析工具
|
||||
|
||||
:::tip 说明
|
||||
`src/utils/request.py` 提供请求数据解析相关的工具函数,包括批量 ID 解析、SQL LIKE 安全处理、分页参数解析等。这些工具函数在各业务模块中广泛使用。
|
||||
:::
|
||||
|
||||
## parse_batch_ids
|
||||
|
||||
从请求体解析批量删除的 ID 数组,校验并转为逗号分隔字符串。
|
||||
|
||||
```python
|
||||
async def parse_batch_ids(request):
|
||||
"""
|
||||
请求体格式: [1, 2, 3]
|
||||
返回: (id_str, error_response)
|
||||
成功时 id_str 为 "1,2,3",error_response 为 None
|
||||
失败时 id_str 为 None,error_response 为 R.failed 响应
|
||||
"""
|
||||
ids = await request.json()
|
||||
if not ids or not isinstance(ids, list) or len(ids) == 0:
|
||||
return None, R.failed("参数错误:ids必须为非空数组")
|
||||
try:
|
||||
id_list = [int(id_val) for id_val in ids]
|
||||
except (ValueError, TypeError):
|
||||
return None, R.failed("参数错误:数组元素必须为数字")
|
||||
return ','.join(str(id_val) for id_val in id_list), None
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```python
|
||||
@router.delete('/batchDelete')
|
||||
async def batch_delete(request: Request):
|
||||
id_str, err = await parse_batch_ids(request)
|
||||
if err:
|
||||
return err
|
||||
return position_service.batch_delete_by_ids(id_str)
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`parse_batch_ids` 是异步函数(需要 `await request.json()`),因此批量删除端点必须声明为 `async def`。
|
||||
:::
|
||||
|
||||
## parse_id_list
|
||||
|
||||
将逗号分隔的 ID 字符串解析为整数列表。
|
||||
|
||||
```python
|
||||
def parse_id_list(id_str: str, separator: str = ',') -> list:
|
||||
"""
|
||||
示例:
|
||||
>>> parse_id_list("1,2,3")
|
||||
[1, 2, 3]
|
||||
>>> parse_id_list("1,2,abc,3")
|
||||
[1, 2, 3] # 自动跳过非数字项
|
||||
"""
|
||||
```
|
||||
|
||||
## like_escape
|
||||
|
||||
转义 SQL LIKE 通配符,防止用户输入被当作通配符。
|
||||
|
||||
```python
|
||||
def like_escape(value, escape_char='\\'):
|
||||
"""
|
||||
示例:
|
||||
>>> like_escape('100%')
|
||||
'100\\%'
|
||||
>>> like_escape('test_data')
|
||||
'test\\_data'
|
||||
"""
|
||||
escaped = str(value).replace(escape_char, escape_char * 2)
|
||||
escaped = escaped.replace('%', escape_char + '%')
|
||||
escaped = escaped.replace('_', escape_char + '_')
|
||||
return escaped
|
||||
```
|
||||
|
||||
## like_contains
|
||||
|
||||
构建安全的 LIKE 包含查询值(前后加 `%`)。
|
||||
|
||||
```python
|
||||
def like_contains(value, escape_char='\\'):
|
||||
"""
|
||||
SQLAlchemy 用法:
|
||||
query = query.filter(User.name.like(like_contains(name), escape='\\\\'))
|
||||
"""
|
||||
return f'%{like_escape(value, escape_char)}%'
|
||||
```
|
||||
|
||||
## parse_pagination
|
||||
|
||||
安全解析分页参数 `pageNo` / `pageSize`。
|
||||
|
||||
```python
|
||||
def parse_pagination(request, default_page_size: int = PAGE_SIZE, max_page_size: int = 500) -> tuple:
|
||||
"""
|
||||
非数字、小于1、超上限等一律回退/夹取
|
||||
返回: (page, page_size),page>=1,1<=page_size<=max_page_size
|
||||
"""
|
||||
```
|
||||
|
||||
## get_json_data
|
||||
|
||||
从 FastAPI 请求中解析 JSON 数据。
|
||||
|
||||
```python
|
||||
async def get_json_data(request, required=True):
|
||||
"""获取请求中的 JSON 数据,失败返回 None 或空字典"""
|
||||
```
|
||||
|
||||
## parse_request_body
|
||||
|
||||
解析请求体 JSON 数据,返回 `(data, error)` 元组。
|
||||
|
||||
```python
|
||||
async def parse_request_body(request):
|
||||
"""成功时 data 为字典,error 为 None;失败时 data 为 None,error 为错误描述"""
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_batch_ids` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 富文本过滤
|
||||
|
||||
:::tip 说明
|
||||
富文本编辑器内容存在存储型 XSS 风险,通过 `bleach` 库实现 HTML 白名单清洗,在落盘前统一过滤危险内容。源码位于 `src/utils/rich_text.py`,涉及富文本的模块(文章、通知公告等)均调用 `save_content()` 处理。
|
||||
:::
|
||||
|
||||
## 安全威胁
|
||||
|
||||
富文本编辑器允许用户输入 HTML 内容,攻击者可能注入:
|
||||
|
||||
```
|
||||
1. <script> 标签 —— 执行任意 JavaScript
|
||||
2. on* 事件属性 —— 如 onerror="alert(1)"
|
||||
3. javascript: 协议 —— 如 <a href="javascript:alert(1)">
|
||||
4. CSS 表达式 —— 如 style="background:expression(alert(1))"
|
||||
```
|
||||
|
||||
## 白名单配置
|
||||
|
||||
位于 `src/utils/rich_text.py`,定义了严格的白名单:
|
||||
|
||||
### 允许的标签
|
||||
|
||||
```python
|
||||
_RICH_TEXT_TAGS = {
|
||||
'p', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'sub', 'sup',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
|
||||
'span', 'div', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
||||
}
|
||||
```
|
||||
|
||||
### 允许的属性
|
||||
|
||||
```python
|
||||
_RICH_TEXT_ATTRS = {
|
||||
'*': ['class', 'style'],
|
||||
'a': ['href', 'title', 'target', 'rel', 'name'],
|
||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
||||
'td': ['colspan', 'rowspan', 'width', 'height', 'align'],
|
||||
'th': ['colspan', 'rowspan', 'width', 'height', 'align'],
|
||||
'table': ['width', 'border', 'cellspacing', 'cellpadding', 'align'],
|
||||
}
|
||||
```
|
||||
|
||||
### 允许的协议
|
||||
|
||||
```python
|
||||
_RICH_TEXT_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
||||
```
|
||||
|
||||
## 清洗流程
|
||||
|
||||
`save_content()` 函数处理富文本内容:
|
||||
|
||||
```python
|
||||
def save_content(content, title, directory):
|
||||
# 1. 白名单清洗:剥离 script/事件属性/javascript: 协议等 XSS 向量
|
||||
content = _RICH_TEXT_CLEANER.clean(content)
|
||||
|
||||
# 2. 兜底:剥离 style 中遗留的 CSS 表达式等危险向量
|
||||
content = _strip_dangerous_style(content)
|
||||
|
||||
# 3. 提取并迁移内容中的本地图片
|
||||
image_urls = re.findall('img src="(.*?)"', content, re.S)
|
||||
for url in image_urls:
|
||||
image = save_file(url, directory)
|
||||
if image:
|
||||
content = content.replace(url, "[IMG_URL]" + image)
|
||||
|
||||
# 4. 设置图片 alt 属性为文章标题
|
||||
title = html.escape(str(title) if title else "", quote=True)
|
||||
if "alt=\"\"" in content and title:
|
||||
content = content.replace("alt=\"\"", "alt=\"" + title + "\"")
|
||||
|
||||
return content
|
||||
```
|
||||
|
||||
## CSS 表达式防护
|
||||
|
||||
`bleach` 仅按属性白名单过滤,对 `style` 值中的 CSS 表达式不做检查。额外增加正则兜底:
|
||||
|
||||
```python
|
||||
_DANGEROUS_CSS_RE = re.compile(r'expression\s*\(|javascript\s*:', re.IGNORECASE)
|
||||
|
||||
def _strip_dangerous_style(content):
|
||||
"""丢弃含危险 CSS 表达式的 style 属性"""
|
||||
def _drop(match):
|
||||
return '' if _DANGEROUS_CSS_RE.search(match.group(0)) else match.group(0)
|
||||
return _STYLE_ATTR_RE.sub(_drop, content)
|
||||
```
|
||||
|
||||
## 文件 URL 处理
|
||||
|
||||
`get_file_url()` 函数拼接文件域名生成完整访问 URL:
|
||||
|
||||
```python
|
||||
def get_file_url(path):
|
||||
if not path:
|
||||
return ""
|
||||
if path.find(FASTAPI_FILE_URL) != -1:
|
||||
return path
|
||||
return FASTAPI_FILE_URL + path
|
||||
```
|
||||
|
||||
## 临时文件迁移
|
||||
|
||||
`save_file()` 函数将临时目录中的文件迁移到正式存储目录:
|
||||
|
||||
```
|
||||
临时路径: {temp}/{日期}/{文件名}
|
||||
正式路径: {upload_dir}/{分类}/{日期}/{文件名}
|
||||
```
|
||||
|
||||
迁移时会做路径穿越检查,确保不会跳出 `UPLOAD_DIR` 目录。
|
||||
|
||||
:::tip 温馨提示
|
||||
富文本内容在 service 层调用 `save_content()` 处理,而不是在 endpoint 层。所有涉及富文本的模块(文章、通知公告等)都应使用此函数清洗内容。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
富文本过滤通过 `bleach` 白名单清洗 + CSS 表达式正则兜底两层防护,有效防止存储型 XSS 攻击。同时自动处理内容中的图片文件迁移和 URL 拼接,确保文件路径安全且可访问。
|
||||
@@ -0,0 +1,295 @@
|
||||
# 文件上传完整流程
|
||||
|
||||
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
|
||||
|
||||
## 上传架构
|
||||
|
||||
```
|
||||
前端 Vue3 后端 FastAPI 文件系统
|
||||
┌──────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ Upload │ multipart │ 安全校验 │ 写入 │ uploads/ │
|
||||
│ 组件 │ ─────────> │ ─ 扩展名白名单│ ───────> │ 2026/ │
|
||||
│ │ │ ─ 魔术字节 │ │ 09/ │
|
||||
│ │ │ ─ 大小限制 │ │ 05/ │
|
||||
│ │ │ ─ 路径遍历 │ │ xxx.jpg│
|
||||
└──────────┘ └──────────────┘ └──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ 返回 fileUrl │
|
||||
│ 前端存入字段 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) |
|
||||
| `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) |
|
||||
| `FASTAPI_FILE_URL` | `http://file.fastapi.elevue` | 文件访问域名 |
|
||||
| `FASTAPI_UPLOAD_DIR` | `uploads` | 上传文件存储目录 |
|
||||
|
||||
## 上传接口
|
||||
|
||||
系统提供统一的文件上传入口,支持图片、文档、压缩包等各类文件:
|
||||
|
||||
### 上传文件
|
||||
|
||||
```
|
||||
POST /api/v1/upload/uploadFile
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <token>
|
||||
|
||||
file: <文件>
|
||||
```
|
||||
|
||||
支持格式由 `UPLOAD_ALLOWED_EXTS` 配置控制,默认包含:
|
||||
|
||||
```
|
||||
图片:.jpg, .jpeg, .png, .gif, .bmp, .ico
|
||||
文档:.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt
|
||||
音视频:.mp3, .mp4
|
||||
压缩:.zip, .rar, .7z
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "上传成功",
|
||||
"data": {
|
||||
"originalName": "photo.jpg",
|
||||
"fileExtension": "jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 306995,
|
||||
"fileName": "2025030614302512345.jpg",
|
||||
"filePath": "/20250306/2025030614302512345.jpg",
|
||||
"fileUrl": "http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg"
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 富文本编辑器集成
|
||||
富文本编辑器(如 TinyMCE、CKEditor、WangEditor 等)的图片上传同样使用此接口。编辑器配置上传地址为 `/api/v1/upload/uploadFile`,上传成功后从 `response.data.fileUrl` 获取图片 URL 即可。
|
||||
:::
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 扩展名白名单
|
||||
|
||||
只允许 `UPLOAD_ALLOWED_EXTS` 中配置的扩展名。配置的扩展名会与内置安全集合取交集,确保不会绕过安全校验。留空时回落到内置智能集合。
|
||||
|
||||
禁止上传的扩展名(硬编码黑名单,无论配置如何一律拒绝):
|
||||
|
||||
```
|
||||
.exe, .bat, .cmd, .sh, .ps1, .php, .asp, .aspx, .jsp, .py, .rb, .js, .html, .htm
|
||||
.svg(无魔数签名,且存在存储型 XSS 风险)
|
||||
.webp(魔数仅 RIFF 前缀,无可靠二进制签名)
|
||||
```
|
||||
|
||||
### 魔术字节校验
|
||||
|
||||
不仅检查扩展名,还读取文件头部魔术字节验证真实类型:
|
||||
|
||||
| 类型 | 魔术字节 |
|
||||
|:----|:----|
|
||||
| JPEG | `FF D8 FF` |
|
||||
| PNG | `89 50 4E 47` |
|
||||
| GIF | `47 49 46 38` |
|
||||
| PDF | `25 50 44 46` |
|
||||
| ZIP | `50 4B 03 04` |
|
||||
|
||||
防止攻击者通过修改扩展名上传恶意文件。
|
||||
|
||||
### 路径遍历防护
|
||||
|
||||
文件名经过安全校验,拒绝包含 `..` 或路径分隔符的文件名:
|
||||
|
||||
```python
|
||||
def _is_safe_filename(filename: str) -> bool:
|
||||
normalized = os.path.normpath(filename)
|
||||
if '..' in normalized.split(os.sep):
|
||||
return False
|
||||
basename = os.path.basename(normalized)
|
||||
return basename == normalized
|
||||
```
|
||||
|
||||
### 大小限制
|
||||
|
||||
上传文件通过流式读取校验大小,超过 `UPLOAD_MAX_SIZE_MB` 限制立即中断,避免整文件读入内存:
|
||||
|
||||
```python
|
||||
async def _read_upload_limited(file, max_size_bytes):
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_size_bytes:
|
||||
return None, R.failed(msg="文件大小超过限制")
|
||||
chunks.append(chunk)
|
||||
return b''.join(chunks), None
|
||||
```
|
||||
|
||||
## 文件存储策略
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
uploads/
|
||||
├── 2026/
|
||||
│ ├── 09/
|
||||
│ │ ├── 05/
|
||||
│ │ │ ├── 2026090514302512345.jpg
|
||||
│ │ │ ├── 2026090515011278901.pdf
|
||||
│ │ │ └── ...
|
||||
│ │ └── 06/
|
||||
│ └── 10/
|
||||
└── 2025/
|
||||
```
|
||||
|
||||
- 按日期分目录存储(`{UPLOAD_DIR}/{YYYYMMDD}/`),避免单目录文件过多
|
||||
- 文件名使用 `年月日时分秒 + 5位随机数` 重命名,避免中文文件名和冲突
|
||||
- 保留原始扩展名
|
||||
|
||||
### 文件访问 URL
|
||||
|
||||
上传成功后返回的 `fileUrl` 是拼接了 `FASTAPI_FILE_URL` 前缀的完整 URL:
|
||||
|
||||
```
|
||||
http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg
|
||||
```
|
||||
|
||||
Service 层的 `BaseService._enrich_file_urls()` 会自动为文件字段补此前缀。
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 文件上传组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="headers"
|
||||
:on-success="handleSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const uploadUrl = '/api/v1/upload/uploadFile';
|
||||
const userStore = useUserStore();
|
||||
const headers = {
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
};
|
||||
|
||||
function beforeUpload(file) {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
}
|
||||
return isLt10M;
|
||||
}
|
||||
|
||||
function handleSuccess(response) {
|
||||
if (response.code === 0) {
|
||||
// 保存 fileUrl 到业务字段
|
||||
form.value.cover = response.data.fileUrl;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 图片预览
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-image
|
||||
v-if="form.cover"
|
||||
:src="getFileUrl(form.cover)"
|
||||
:preview-src-list="[getFileUrl(form.cover)]"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function getFileUrl(path) {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return import.meta.env.VITE_FILE_URL + path;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 业务模块中的文件字段
|
||||
|
||||
在 Service 中声明 `file_fields`,基类自动处理文件迁移和 URL 补全:
|
||||
|
||||
```python
|
||||
class ArticleService(BaseService[Article]):
|
||||
# 文件字段:add/update 时自动迁移临时文件,列表/详情自动补全 URL
|
||||
file_fields = ('cover', 'attachment')
|
||||
```
|
||||
|
||||
### 自动处理逻辑
|
||||
|
||||
1. **新增/更新时**:检测文件字段值是否为临时路径,如果是则迁移到正式目录
|
||||
2. **列表/详情时**:自动为文件字段补全 `FASTAPI_FILE_URL` 前缀
|
||||
|
||||
## 富文本中的图片
|
||||
|
||||
在 Service 中声明 `rich_text_fields`,基类自动处理:
|
||||
|
||||
```python
|
||||
class ArticleService(BaseService[Article]):
|
||||
# 富文本字段:自动迁移嵌入图片 + XSS 清洗
|
||||
rich_text_fields = ('content',)
|
||||
```
|
||||
|
||||
### 自动处理逻辑
|
||||
|
||||
1. **新增/更新时**:解析富文本中的 `<img src="...">` 标签
|
||||
2. 将临时路径的图片迁移到正式目录
|
||||
3. 替换富文本中的图片路径
|
||||
4. 进行 XSS 清洗(使用 bleach 库)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 上传报 413 Request Entity Too Large
|
||||
|
||||
**原因**:文件大小超过 `UPLOAD_MAX_SIZE_MB` 限制。
|
||||
|
||||
**解决**:修改 `.env` 中的 `UPLOAD_MAX_SIZE_MB` 值。
|
||||
|
||||
### 上传报 403 Forbidden
|
||||
|
||||
**原因**:文件扩展名不在白名单中。
|
||||
|
||||
**解决**:检查 `UPLOAD_ALLOWED_EXTS` 配置,添加所需扩展名。
|
||||
|
||||
### 图片上传成功但不显示
|
||||
|
||||
**原因**:`FASTAPI_FILE_URL` 配置错误或前端未拼接域名。
|
||||
|
||||
**解决**:检查 `.env` 中的 `FASTAPI_FILE_URL` 配置,确保前端正确拼接。
|
||||
|
||||
### 上传报演示环境无权限
|
||||
|
||||
**原因**:演示模式下写操作被拦截。
|
||||
|
||||
**解决**:将 `.env` 中的 `FASTAPI_DEMO` 设为 `False`。
|
||||
|
||||
## 总结
|
||||
|
||||
文件上传功能通过扩展名白名单 + 魔术字节校验 + 路径遍历防护 + 大小限制四层安全机制保障安全性。文件按日期分目录存储,使用时间戳 + 随机数重命名。统一的 `uploadFile` 接口支持图片和各类文件上传。Service 层通过 `file_fields` 和 `rich_text_fields` 声明即可自动处理文件迁移和 URL 补全。
|
||||
@@ -0,0 +1,128 @@
|
||||
# 文件上传
|
||||
|
||||
:::tip 说明
|
||||
提供本地文件上传功能,支持图片和通用文件上传,内置扩展名白名单、大小限制、魔术字节校验和路径遍历防护。服务层位于 `src/modules/upload/service.py`,端点位于 `src/api/v1/endpoints/upload.py`。
|
||||
:::
|
||||
|
||||
## 功能概述
|
||||
|
||||
文件上传模块提供统一的文件上传接口 `upload_file`,支持图片、文档、压缩包等各类文件,通过 `UPLOAD_ALLOWED_EXTS` 配置控制允许上传的格式。
|
||||
|
||||
```
|
||||
POST /api/v1/upload/uploadFile —— 统一文件上传入口
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
在 `.env` 中配置上传相关参数:
|
||||
|
||||
```bash
|
||||
# 允许上传的文件扩展名(逗号分隔,留空使用内置智能集合)
|
||||
UPLOAD_ALLOWED_EXTS=.jpg,.png,.gif,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar
|
||||
|
||||
# 单文件大小上限(MB)
|
||||
UPLOAD_MAX_SIZE_MB=10
|
||||
|
||||
# 上传文件存储根目录
|
||||
FASTAPI_UPLOAD_DIR=E:\upload
|
||||
|
||||
# 文件访问域名(Nginx 或本地代理)
|
||||
FASTAPI_FILE_URL=http://127.0.0.1:8031/api/file
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`UPLOAD_ALLOWED_EXTS` 配置的扩展名会与内置安全集合取交集,确保不会绕过安全校验。留空时回落到内置智能集合。
|
||||
:::
|
||||
|
||||
## 安全机制
|
||||
|
||||
上传模块内置多层安全防护:
|
||||
|
||||
### 1. 路径遍历防护
|
||||
|
||||
```python
|
||||
def _is_safe_filename(filename: str) -> bool:
|
||||
"""拒绝包含路径分隔符或 .. 的文件名"""
|
||||
normalized = os.path.normpath(filename)
|
||||
if '..' in normalized.split(os.sep):
|
||||
return False
|
||||
basename = os.path.basename(normalized)
|
||||
return basename == normalized or basename == filename
|
||||
```
|
||||
|
||||
### 2. 高危格式黑名单
|
||||
|
||||
`.exe`、`.bat`、`.sh`、`.ps1`、`.js`、`.vbs`、`.svg`、`.webp` 等高危格式无论配置如何一律拒绝。
|
||||
|
||||
### 3. 魔术字节校验
|
||||
|
||||
通过 `verify_file_magic()` 校验文件头部魔术字节,防止伪造扩展名绕过白名单。
|
||||
|
||||
### 4. 流式大小校验
|
||||
|
||||
```python
|
||||
async def _read_upload_limited(file, max_size_bytes):
|
||||
"""流式读取,超过大小上限立即中断,避免整文件读入内存"""
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_size_bytes:
|
||||
return None, R.failed(msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB")
|
||||
chunks.append(chunk)
|
||||
return b''.join(chunks), None
|
||||
```
|
||||
|
||||
## 文件命名规则
|
||||
|
||||
上传文件自动重命名,格式为:`年月日时分秒 + 5位随机数 + 扩展名`
|
||||
|
||||
```python
|
||||
def _generate_filename(ext_with_dot):
|
||||
timestamp = time.strftime('%Y%m%d%H%M%S')
|
||||
random_num = '%05d' % random.randint(0, 99999)
|
||||
return f"{timestamp}{random_num}{ext_with_dot}"
|
||||
```
|
||||
|
||||
文件按日期分目录存储:`{UPLOAD_DIR}/{YYYYMMDD}/{文件名}`
|
||||
|
||||
## 接口调用
|
||||
|
||||
### 上传文件
|
||||
|
||||
```
|
||||
POST /api/v1/upload/uploadFile
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <token>
|
||||
参数: file(文件字段)
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "上传成功",
|
||||
"data": {
|
||||
"originalName": "photo.jpg",
|
||||
"fileExtension": "jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 306995,
|
||||
"fileName": "2025030614302512345.jpg",
|
||||
"filePath": "/20250306/2025030614302512345.jpg",
|
||||
"fileUrl": "http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg"
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
前端使用 `Upload` 组件上传,上传成功后将返回的 `fileUrl` 存入业务字段。详见 [文件上传联调](/zh/volume4/integration/upload-sync) 章节。
|
||||
|
||||
## 总结
|
||||
|
||||
文件上传模块通过扩展名白名单、魔术字节校验、流式大小限制、路径遍历防护等多层安全机制,确保上传功能的安全性。文件自动重命名并按日期分目录存储,避免文件名冲突和信息泄露。统一的 `uploadFile` 接口同时支持图片和各类文件上传。
|
||||
Reference in New Issue
Block a user