签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -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` 接口同时支持图片和各类文件上传。