签入版本

This commit is contained in:
2026-09-16 13:28:20 +08:00
parent 6cd5e80de0
commit c97d87847f
410 changed files with 68726 additions and 0 deletions
@@ -0,0 +1,147 @@
# 请求上下文(ContextVar)
:::tip 说明
`src/core/context.py` 使用 Python 标准库 `contextvars` 管理请求级别的上下文信息,包括当前用户 ID、用户名、姓名和链路追踪 ID。这些变量在整个请求处理链路中自动传递,无需手动传参。
:::
## 核心变量
```python
# src/core/context.py
from contextvars import ContextVar
# 当前登录用户 ID
current_user_id: ContextVar[int] = ContextVar("current_user_id", default=0)
# 当前登录用户名
current_username: ContextVar[str] = ContextVar("current_username", default="")
# 当前登录用户姓名
current_realname: ContextVar[str] = ContextVar("current_realname", default="")
# 当前请求的链路追踪 ID
current_trace_id: ContextVar[str] = ContextVar("current_trace_id", default="")
```
## 工作原理
```
请求进入
│
├─ authentication.py: 解析 JWT
│ ├─ current_user_id.set(user_id)
│ ├─ current_username.set(username)
│ └─ current_realname.set(realname)
├─ trace_id.py: 生成 TraceID → current_trace_id.set(trace_id)
│
├─ Handler → Service → Repository
│ └─ 任意层可通过 current_realname.get() 获取用户姓名
│
└─ 请求结束: contextvars 自动清理
```
## 使用场景
### 1. Repository 层自动填充审计字段
```python
# src/core/base_repository.py
class BaseRepository:
def _uid(self) -> str:
"""获取当前登录用户姓名(从上下文变量读取)"""
return current_realname.get()
def create(self, obj):
# 自动填充创建人(姓名)
if hasattr(obj, "create_user"):
obj.create_user = self._uid()
# ...
def update(self, pk, data):
# 自动填充更新人(姓名)
data["update_user"] = self._uid()
# ...
```
### 2. 操作日志记录
```python
# src/common/middleware/operation_log.py
# 操作日志通过异步落库,显式传入 realname(上下文在异步回调中可能已失效)
tornado.ioloop.IOLoop.current().spawn_callback(
record_operation_log,
realname=current_realname.get(),
# ...
)
```
### 3. 日志输出中携带上下文
```python
# src/core/logger.py
# JSON 日志自动携带 trace_id 和 user_id
{
"timestamp": "2024-01-15T10:30:00",
"level": "INFO",
"trace_id": current_trace_id.get(),
"user_id": current_user_id.get(),
"message": "..."
}
```
## 设置时机
### current_user_id / current_username / current_realname
在 `src/common/middleware/authentication.py` 的 `login_required` 装饰器中设置:
```python
def login_required(func):
def wrapper(self, *args, **kwargs):
# 解析 JWT Token
result = parse_payload(access_token)
data = result["data"]
# 设置上下文(userId / username / realname 均从 JWT payload 读取)
current_user_id.set(int(data["userId"]))
current_username.set(data.get("username", ""))
current_realname.set(data.get("realname", ""))
return func(self, *args, **kwargs)
return wrapper
```
:::tip JWT 载荷
登录签发 JWT 时已将 `userId`、`username`、`realname` 写入 payload,认证中间件解析后一并写入上下文。旧 token 中可能不含 `username` / `realname`,`.get()` 会返回空字符串,重新登录后生效。
:::
### current_trace_id
在 `src/common/middleware/trace_id.py` 的 `apply_trace_id` 中设置:
```python
def apply_trace_id(handler):
trace_id = uuid.uuid4().hex[:16]
current_trace_id.set(trace_id)
handler.set_header("X-Trace-Id", trace_id)
```
## 生命周期
| 阶段 | 状态 |
|:-----|:-----|
| 请求进入 | `current_user_id` = 0, `current_username` = "", `current_realname` = "", `current_trace_id` = "" |
| `set_default_headers` | `current_trace_id` 被设置 |
| `prepare` (login_required) | `current_user_id` / `current_username` / `current_realname` 被设置 |
| Handler → Service → Repository | 可随时读取 |
| `on_finish` | contextvars 自动清理(下一个请求重置) |
## 与 Tornado 的区别
| 方面 | Tornado | Tornado |
|:-----|:--------|:--------|
| 实现方式 | `contextvars` + 中间件注入 | `contextvars` + 装饰器注入 |
| DB 会话 | 中间件注入 `contextvars` | `scoped_session` 线程本地 |
| 清理时机 | 中间件 finally | `on_finish` |
## 总结
请求上下文通过 `contextvars` 实现请求级别的全局变量传递。`current_user_id` / `current_username` / `current_realname` 在认证装饰器中设置,`current_trace_id` 在请求头处理中设置。Repository 层自动读取 `current_realname` 填充审计字段(`create_user` / `update_user` 写入操作者姓名),日志系统自动读取上下文变量输出链路信息。
@@ -0,0 +1,86 @@
# 数据库会话
:::tip 说明
数据库会话通过 `scoped_session` 实现线程级隔离,Repository 层的写操作自动 commit,`BaseHandler.on_finish()` 兜底关闭会话。核心代码位于 `src/core/database.py`。
:::
## 核心架构
数据库会话管理位于 `src/core/database.py`。
### scoped_session 隔离
```python
from sqlalchemy.orm import scoped_session, sessionmaker
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = scoped_session(SessionLocal)
```
`scoped_session` 基于 thread-local 实现线程级隔离,每个线程持有独立的数据库会话,互不干扰。
### 会话使用
业务代码通过 `BaseRepository` 的 `self.db` 属性获取当前会话:
```python
class BaseRepository:
def __init__(self, db=None):
self.db = db or _default_db # 默认使用 scoped_session
```
## 会话生命周期
### 创建
Repository 实例化时自动获取会话(`scoped_session` 按线程自动创建)。
### 提交
Repository 层的写操作内部自动 `db.commit()`:
```python
class BaseRepository:
def create(self, obj):
if hasattr(obj, "create_user"):
obj.create_user = str(self._uid())
self.db.add(obj)
self.db.commit() # 自动提交
return obj
def update(self, pk, data):
data["update_user"] = str(self._uid())
data["update_time"] = datetime.now()
result = self.db.query(self.model).filter_by(id=pk).update(data)
self.db.commit() # 自动提交
return bool(result)
```
### 关闭
Service 层的 `try/finally` 块确保 `repo.close()` 释放会话:
```python
class BaseService:
async def get_page(self, handler):
try:
# ... 业务逻辑 ...
return R.page(handler, data=result, total=total, current=pageNo, size=pageSize)
finally:
self.repo.close() # 释放会话
```
`BaseHandler.on_finish()` 兜底关闭会话,避免长连接泄漏:
```python
class BaseHandler(tornado.web.RequestHandler):
def on_finish(self):
try:
db.close() # 兜底关闭(Service 层已关闭时为幂等 no-op)
except Exception:
pass
```
## 总结
数据库会话通过 `scoped_session` 实现线程级隔离,Repository 层写操作自动 `commit()`,Service 层 `try/finally` 确保 `close()`,`BaseHandler.on_finish()` 兜底关闭。业务代码无需手动管理事务。
@@ -0,0 +1,160 @@
# 字典缓存工具
:::tip 说明
`src/common/utils/dict_util.py` 提供业务字典的缓存读取能力,从 Redis 缓存中获取字典数据,缓存未命中时从数据库加载。字典维护时自动失效缓存。
:::
## 核心接口
### get_dict_data — 获取字典下拉数据
```python
from common.utils.dict_util import get_dict_data
# 获取字典编码对应的下拉选项列表
options = get_dict_data("link_type")
# 返回: [{"label": "友情链接", "value": "1"}, {"label": "合作伙伴", "value": "2"}]
```
### get_dict_label — 获取字典项标签
```python
from common.utils.dict_util import get_dict_label
# 根据字典编码和值获取显示标签
label = get_dict_label("link_type", "1")
# 返回: "友情链接"
```
### get_dict_map — 获取字典值→标签映射
```python
from common.utils.dict_util import get_dict_map
# 获取字典编码的值→标签映射字典
mapping = get_dict_map("link_type")
# 返回: {"1": "友情链接", "2": "合作伙伴"}
```
## 缓存策略
```
1. 读取 Redis 缓存: dict:{code}
├─ 命中 → 直接返回
└─ 未命中 → 从 DB 查询 → 写入 Redis → 返回
2. 字典维护(增删改)→ 删除 Redis 缓存 → 下次读取时自动重建
```
### Redis Key 设计
| Key | 说明 | TTL |
|:----|:-----|:----|
| `dict:{code}` | 字典编码对应的选项列表 | 不过期(主动失效) |
| `dict:version` | 字典版本号(缓存失效触发器) | 不过期 |
### 缓存失效
字典数据变更时,通过递增版本号使所有缓存失效:
```python
def invalidate_dict_cache():
"""字典数据变更时调用,递增版本号使缓存失效。"""
redis = get_redis()
redis.incr("dict:version")
```
## 在 BaseService 中使用
通过 `serialize_maps` 声明字段→字典编码映射,序列化时自动转换:
```python
class LinkService(BaseService):
serialize_maps = {
"type": "link_type", # type 字段按 link_type 字典解析
"form": "link_form", # form 字段按 link_form 字典解析
}
```
序列化时自动将 `type="1"` 转换为 `type_label="友情链接"`。
## 与字典模块的关系
| 模块 | 职责 |
|:-----|:-----|
| `modules/dictionary/` | 字典数据的 CRUD 维护 |
| `common/utils/dict_util.py` | 字典数据的缓存读取 |
字典维护接口在增删改时自动调用 `invalidate_dict_cache()`,读取端无需关心缓存一致性。
## 使用场景
### 场景一:Service 序列化自动转换
通过 `serialize_maps` 声明字段→字典编码映射,列表/详情序列化时自动追加 `xxxText` 字段:
```python
class LinkService(BaseService):
serialize_maps = {
"type": "link_type", # type → typeText
"form": "link_form", # form → formText
}
```
序列化结果:
```json
{
"id": 1,
"name": "小蚂蚁云",
"type": "1",
"typeText": "友情链接",
"form": "2",
"formText": "后台添加"
}
```
### 场景二:Handler 下拉接口
```python
class DictDataHandler(BaseHandler):
async def get(self, code):
"""GET /api/v1/dict/data/{code}"""
items = get_dict_data(code)
return R.ok(self, data=items)
```
前端调用:
```javascript
const res = await request({ url: '/api/v1/dict/data/link_type' });
// res.data: [{"label": "友情链接", "value": "1"}, {"label": "合作伙伴", "value": "2"}]
```
### 场景三:后端业务校验
```python
def _before_add(self, handler, data):
"""新增前校验:字典值是否合法"""
valid_types = get_dict_map("link_type")
if str(data.get("type")) not in valid_types:
raise ValidationError("链接类型不合法")
```
### 场景四:导出 Excel 翻译
```python
def get_export_data(self, handler):
items = self.repo.all()
type_map = get_dict_map("link_type")
result = []
for item in items:
result.append({
"name": item.name,
"type": type_map.get(str(item.type), "未知"), # 数字→中文
})
return result
```
## 总结
字典缓存工具提供 `get_dict_data`、`get_dict_label`、`get_dict_map` 三个核心接口,基于 Redis 缓存实现高性能读取。字典维护时通过版本号机制自动失效缓存,保证数据一致性。广泛用于 Service 序列化、下拉接口、业务校验、导出翻译等场景。
@@ -0,0 +1,119 @@
# 导入导出
:::tip 说明
基于 `openpyxl` 实现 Excel 导入导出功能,支持通过 `file_template` 模块管理导入模板配置。端点定义在各业务模块的 endpoint 文件中(如 `src/modules/user.py`)。
:::
## 功能概述
Excel 导入导出是后台管理系统的常见需求,通过 `openpyxl` 库实现:
```
Excel 导出:
1. 将数据库中的数据导出为 Excel 文件,支持自定义表头和格式
2. 适用于生成报表、数据备份等场景
Excel 导入:
1. 将 Excel 文件中的数据导入到数据库,支持批量处理和校验
2. 适用于批量数据录入、数据迁移等场景
```
## 依赖说明
在 `requirements.txt` 中已包含:
```
openpyxl>=3.1.0
```
## 导出实现
导出接口通常在各业务模块的 Handler 中定义,返回文件流:
```python
from openpyxl import Workbook
import io
class UserExportHandler(BaseHandler):
@permission_required("sys:user:export")
async def get(self):
# 查询数据
data_list = user_service.get_export_data(self)
# 创建工作簿
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)
self.set_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.set_header("Content-Disposition", "attachment; filename=export.xlsx")
self.finish(output.getvalue())
```
## 导入实现
导入接口接收上传的 Excel 文件,解析后逐行校验并入库:
```python
class UserImportHandler(BaseHandler):
@permission_required("sys:user:import")
@check_demo
async def post(self):
file = self.request.files.get('file', [None])[0]
if not file:
return R.failed(self, "请上传文件")
wb = load_workbook(io.BytesIO(file.body))
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` 实现,导出通过 `self.finish()` 返回文件流避免内存溢出,导入逐行校验并提供详细错误信息。`file_template` 模块可管理导入模板配置,便于维护字段映射和校验规则。
@@ -0,0 +1,75 @@
# 通用 Helper 函数
:::tip 说明
`src/common/utils/helpers.py` 提供项目中广泛使用的通用工具函数,包括 ID 解析、OS/浏览器检测、参数脱敏等。
:::
## parse_id_list — ID 列表解析
将逗号分隔的 ID 字符串解析为整数列表:
```python
from common.utils.helpers import parse_id_list
parse_id_list("1,2,3")
# 返回: [1, 2, 3]
parse_id_list("1,2,abc,3")
# 返回: [1, 2, 3] # 自动跳过非数字项
parse_id_list("")
# 返回: []
```
## detect_os — 操作系统检测
从 User-Agent 字符串提取操作系统信息:
```python
from common.utils.helpers import detect_os
detect_os("Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...")
# 返回: "Windows 10.0"
```
支持 Windows、macOS、Android、iOS、Linux 等主流操作系统。
## detect_browser — 浏览器检测
从 User-Agent 字符串提取浏览器信息:
```python
from common.utils.helpers import detect_browser
detect_browser("Mozilla/5.0 ... Chrome/120.0.0.0 ...")
# 返回: "Chrome 120.0"
```
支持 Edge、Chrome、Firefox、Safari、IE 等主流浏览器。Edge 优先匹配,避免被误判为 Chrome。
## redact_params — 参数脱敏
对敏感参数进行脱敏处理,用于操作日志记录:
```python
from common.utils.helpers import redact_params
params = {"username": "admin", "password": "123456", "name": "张三"}
redact_params(params)
# 返回: {"username": "admin", "password": "******", "name": "张三"}
```
自动对 `password`、`salt`、`token`、`secret` 等敏感字段脱敏。
## 使用场景
| 函数 | 主要使用场景 |
|:-----|:------------|
| `parse_id_list` | 批量删除、批量状态变更 |
| `detect_os` | 操作日志、登录日志 |
| `detect_browser` | 操作日志、登录日志 |
| `redact_params` | 操作日志参数记录 |
## 总结
通用 Helper 函数覆盖了 ID 解析、设备检测、参数脱敏等常见场景。这些函数在各业务模块中广泛使用,是项目的基础工具集。
@@ -0,0 +1,133 @@
# IP 归属地解析
:::tip 说明
`src/common/utils/ip2region.py` 基于 [ip2region](https://github.com/lionsoul2014/ip2region) 离线数据库,提供 IP 地址到省市区的归属地查询能力,无需网络请求。`src/common/utils/helpers.py` 中的 `detect_os` 和 `detect_browser` 提供 User-Agent 解析。
:::
## 核心接口
### get_ip_location — 获取归属地文本
```python
from common.utils.ip2region import get_ip_location
location = get_ip_location("114.114.114.114")
# 返回: "中国 江苏省 南京市"
location = get_ip_location("8.8.8.8")
# 返回: "United States California"
location = get_ip_location("10.0.0.1")
# 返回: "内网IP"
```
### get_ip_region — 获取完整区域信息
```python
from common.utils.ip2region import get_ip_region
info = get_ip_region("114.114.114.114")
# 返回: {"country": "中国", "province": "江苏省", "city": "南京市", "isp": "南京信风", "country_code": "CN"}
```
## UA 解析
位于 `src/common/utils/helpers.py`:
```python
from common.utils.helpers import detect_os, detect_browser
# 解析操作系统
os_name = detect_os("Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...")
# 返回: "Windows 10.0"
# 解析浏览器
browser_name = detect_browser("Mozilla/5.0 ... Chrome/120.0.0.0 ...")
# 返回: "Chrome 120.0"
```
## 应用场景
### 操作日志记录
```python
# src/common/middleware/operation_log.py
ip = get_ip_location(handler.request.remote_ip)
os_name = detect_os(ua)
browser_name = detect_browser(ua)
```
### 登录日志记录
```python
# src/common/middleware/login_log.py
ip = get_ip_location(handler.request.remote_ip)
os_name = detect_os(ua)
browser_name = detect_browser(ua)
```
## 预热机制
ip2region.xdb 文件约 10MB,首次查询时加载。应用启动时预热避免首次请求延迟:
```python
# src/bootstrap.py
from common.utils.ip2region import preload
preload() # 预加载 xdb 文件到内存
```
## 降级策略
| 异常场景 | 降级行为 |
|:---------|:---------|
| xdb 文件不存在 | 返回 `"未知"` |
| xdb 加载失败 | 返回 `"未知"` |
| 畸形 IP 地址 | 返回 `"未知"` |
| 内网/保留地址 | 返回 `"内网IP"` |
所有查询接口均采用 fail-open 降级策略,查询失败绝不向上抛异常,保证日志落库不受影响。
## xdb 数据库更新
ip2region.xdb 离线数据库需要定期更新以覆盖新增的 IP 段:
```bash
# 1. 下载最新 xdb 文件
# GitHub: https://github.com/lionsoul2014/ip2region/tree/master/data
# 将 ip2region.xdb 放到 static/ 目录下
# 2. 重启应用即可生效(启动时自动加载)
poetry run python app.py --port=8041
```
:::tip 数据库格式
xdb 是 ip2region 的二进制索引格式,约 10MB,支持微秒级查询。文件路径在 `src/common/utils/ip2region.py` 中配置。
:::
## 完整日志记录示例
操作日志和登录日志中记录完整的客户端信息:
```python
from common.utils.ip2region import get_ip_location
from common.utils.helpers import detect_os, detect_browser
# 获取客户端信息
ip = handler.request.remote_ip
ua = handler.request.headers.get("User-Agent", "")
location = get_ip_location(ip)
os_name = detect_os(ua)
browser_name = detect_browser(ua)
# 记录到日志
log_data = {
"ip": ip, # "114.114.114.114"
"ip_location": location, # "中国 江苏省 南京市"
"os": os_name, # "Windows 10.0"
"browser": browser_name, # "Chrome 120.0"
}
```
## 总结
IP 归属地解析基于 ip2region 离线数据库,零网络依赖。提供 `get_ip_location`(文本)和 `get_ip_region`(结构化)两个查询接口,配合 `detect_os` / `detect_browser` 为操作日志和登录日志提供完整的客户端身份信息。xdb 文件需定期更新以覆盖新增 IP 段。
@@ -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 | user.service | 用户登录成功
```
格式说明:
| 字段 | 说明 |
|------|------|
| `asctime` | 时间戳 |
| `levelname` | 日志级别(右对齐7字符) |
| `name` | 模块名称 |
| `message` | 日志内容 |
## 使用方式
在各模块中获取 logger:
```python
import logging
logger = logging.getLogger(__name__)
logger.info("操作成功")
logger.warning("配置缺失")
logger.error(f"异常信息: {str(e)}")
```
## 框架日志集成
Tornado 的默认日志统一走 root logger:
```python
for name in ("tornado.httpserver", "tornado"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
```
:::tip 温馨提示
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
:::
## 操作日志
除了应用日志外,还通过 `src/common/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `tornado_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/common/permission.py`,供 `src/modules/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 = 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}'
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}',
]
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}')
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,154 @@
# 统一响应封装(R 对象)
:::tip 说明
`src/core/response.py` 提供统一的 API 响应封装,所有接口返回格式为 `{code, data, msg, ok}`。R 对象是全局统一的响应工具类。
:::
## 响应格式
```json
{
"code": 0,
"data": {},
"msg": "操作成功",
"ok": true
}
```
| 字段 | 类型 | 说明 |
|:-----|:-----|:-----|
| `code` | int | 状态码:0=成功,非0=失败 |
| `data` | any | 响应数据 |
| `msg` | string | 提示信息 |
| `ok` | bool | 是否成功 |
## R 对象方法
### R.ok — 成功响应
```python
from core import response as R
# 基本成功
R.ok(handler)
# 带数据
R.ok(handler, data={"id": 1, "name": "admin"})
# 自定义消息
R.ok(handler, msg="注册成功")
# 带分页
R.ok(handler, data={"list": [...], "total": 100})
```
### R.failed — 失败响应
```python
# 基本失败
R.failed(handler)
# 自定义消息
R.failed(handler, msg="用户名已存在")
# 自定义状态码
R.failed(handler, code=401, msg="未授权")
```
### R.response — 自定义响应
```python
# 完全自定义
R.response(handler, code=0, data={}, msg="自定义", ok=True)
```
## 实现原理
```python
# src/core/response.py
from core.base_handler import BaseHandler
class R:
@staticmethod
def ok(handler: BaseHandler, data=None, msg="操作成功"):
handler.finish({
"code": Codes.SUCCESS,
"data": data,
"msg": msg,
"ok": True,
})
@staticmethod
def failed(handler: BaseHandler, msg="操作失败", code=Codes.FAILED):
handler.finish({
"code": code,
"data": None,
"msg": msg,
"ok": False,
})
@staticmethod
def response(handler: BaseHandler, code=0, data=None, msg="", ok=True):
handler.finish({
"code": code,
"data": data,
"msg": msg,
"ok": ok,
})
```
## Codes 状态码
```python
class Codes:
SUCCESS = 0 # 成功
FAILED = 1 # 业务失败
UNAUTHORIZED = 401 # 未授权
FORBIDDEN = 403 # 无权限
NOT_FOUND = 404 # 资源不存在
SERVER_ERROR = 500 # 服务器错误
```
## 使用规范
### Handler 层使用
```python
class LinkDetailHandler(BaseHandler):
@permission_required("sys:link:detail")
async def get(self, id):
# Service 返回原始数据时,用 R.ok 包装
return R.ok(self, data=link_service.get_detail(id))
```
### Service 层使用
```python
class LinkService(BaseService):
async def add(self, handler):
# Service 返回 R 对象
# ...
return R.ok(handler, msg="添加成功")
```
### 异常处理中的使用
```python
# src/core/base_handler.py
def write_error(self, status_code, **kwargs):
exc = kwargs.get("exc_info", (None, None, None))[1]
if isinstance(exc, BaseAppError):
self.finish({"code": exc.code, "data": None, "msg": exc.msg, "ok": False})
```
## 与 Tornado 的区别
| 方面 | 说明 |
|:-----|:-----|
| 响应方式 | `R.ok(self, data=...)` / `R.failed(self, msg=...)` |
| 底层实现 | `self.finish(body)` 写出 JSON |
| Handler 参数 | `self`(Handler 实例) |
## 总结
R 对象是统一响应封装的核心工具,所有 API 返回 `{code, data, msg, ok}` 格式。成功用 `R.ok()`,失败用 `R.failed()`,自定义用 `R.response()`。Handler 层直接调用 `R.ok(self, data=...)` 完成响应。
@@ -0,0 +1,124 @@
# 富文本过滤
:::tip 说明
富文本编辑器内容存在存储型 XSS 风险,通过 `bleach` 库实现 HTML 白名单清洗,在落盘前统一过滤危险内容。源码位于 `src/common/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/common/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(TORNADO_FILE_URL) != -1:
return path
return TORNADO_FILE_URL + path
```
## 临时文件迁移
`save_file()` 函数将临时目录中的文件迁移到正式存储目录:
```
临时路径: {temp}/{日期}/{文件名}
正式路径: {upload_dir}/{分类}/{日期}/{文件名}
```
迁移时会做路径穿越检查,确保不会跳出 `UPLOAD_DIR` 目录。
:::tip 温馨提示
富文本内容在 service 层调用 `save_content()` 处理,而不是在 endpoint 层。所有涉及富文本的模块(文章、通知公告等)都应使用此函数清洗内容。
:::
## 总结
富文本过滤通过 `bleach` 白名单清洗 + CSS 表达式正则兜底两层防护,有效防止存储型 XSS 攻击。同时自动处理内容中的图片文件迁移和 URL 拼接,确保文件路径安全且可访问。
@@ -0,0 +1,295 @@
# 文件上传完整流程
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
## 上传架构
```
前端 Vue3 后端 Tornado 文件系统
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Upload │ multipart │ 安全校验 │ 写入 │ uploads/ │
│ 组件 │ ─────────> │ ─ 扩展名白名单│ ───────> │ 2026/ │
│ │ │ ─ 魔术字节 │ │ 09/ │
│ │ │ ─ 大小限制 │ │ 05/ │
│ │ │ ─ 路径遍历 │ │ xxx.jpg│
└──────────┘ └──────────────┘ └──────────┘
│
▼
┌──────────────┐
│ 返回 fileUrl │
│ 前端存入字段 │
└──────────────┘
```
## 环境变量配置
| 变量 | 默认值 | 说明 |
|:----|:----|:----|
| `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) |
| `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) |
| `TORNADO_FILE_URL` | `http://file.tornado.elevue2` | 文件访问域名 |
| `TORNADO_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:8041/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` 是拼接了 `TORNADO_FILE_URL` 前缀的完整 URL:
```
http://127.0.0.1:8041/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. **列表/详情时**:自动为文件字段补全 `TORNADO_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` 配置,添加所需扩展名。
### 图片上传成功但不显示
**原因**:`TORNADO_FILE_URL` 配置错误或前端未拼接域名。
**解决**:检查 `.env` 中的 `TORNADO_FILE_URL` 配置,确保前端正确拼接。
### 上传报演示环境无权限
**原因**:演示模式下写操作被拦截。
**解决**:将 `.env` 中的 `TORNADO_DEMO` 设为 `False`。
## 总结
文件上传功能通过扩展名白名单 + 魔术字节校验 + 路径遍历防护 + 大小限制四层安全机制保障安全性。文件按日期分目录存储,使用时间戳 + 随机数重命名。统一的 `uploadFile` 接口支持图片和各类文件上传。Service 层通过 `file_fields` 和 `rich_text_fields` 声明即可自动处理文件迁移和 URL 补全。
@@ -0,0 +1,128 @@
# 文件上传
:::tip 说明
提供本地文件上传功能,支持图片和通用文件上传,内置扩展名白名单、大小限制、魔术字节校验和路径遍历防护。服务层位于 `src/modules/upload/service.py`,端点位于 `src/modules/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
# 上传文件存储根目录
TORNADO_UPLOAD_DIR=E:\upload
# 文件访问域名(Nginx 或本地代理)
TORNADO_FILE_URL=http://127.0.0.1:8041/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:8041/api/file/20250306/2025030614302512345.jpg"
},
"ok": true
}
```
## 前端集成
前端使用 `Upload` 组件上传,上传成功后将返回的 `fileUrl` 存入业务字段。详见 [文件上传联调](/zh/volume4/integration/upload-sync) 章节。
## 总结
文件上传模块通过扩展名白名单、魔术字节校验、流式大小限制、路径遍历防护等多层安全机制,确保上传功能的安全性。文件自动重命名并按日期分目录存储,避免文件名冲突和信息泄露。统一的 `uploadFile` 接口同时支持图片和各类文件上传。