签入版本

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,237 @@
# 数据库会话
:::tip 说明
项目通过 Flask 的 `g` 对象和 `before_request` / `after_request` 钩子实现请求级数据库会话管理,写操作按业务成功与否自动 commit/rollback。核心代码位于 `src/core/database.py` 和 `src/middleware/transaction.py`。
:::
## 核心架构
数据库会话管理位于 `src/core/database.py` 和 `src/middleware/transaction.py`。
### SQLAlchemy + Flask-SQLAlchemy
项目使用 Flask-SQLAlchemy 扩展,通过 `db` 对象管理数据库会话:
```python
# src/core/database.py
from flask_sqlalchemy import SQLAlchemy
# 创建 Flask-SQLAlchemy 实例
db = SQLAlchemy()
```
在 `src/app.py` 的 `create_app()` 中初始化:
```python
def create_app():
app = Flask(__name__)
# 配置数据库 URI
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL
# 初始化 SQLAlchemy
db.init_app(app)
return app
```
业务代码通过 `db.session` 获取请求级会话:
```python
from src.core.database import db
# 添加记录
db.session.add(obj)
db.session.flush() # 发送 SQL,获取自增 ID,不提交
# 查询
result = db.session.query(Model).filter(...).all()
```
### Flask g 对象
Flask 的 `g` 对象是请求级别的全局变量,每个请求都有独立的 `g` 对象:
```python
from flask import g
# 在请求开始时设置
g.user_id = 1
# 在请求处理过程中获取
user_id = g.user_id
```
## 会话中间件
`src/middleware/transaction.py` 中的中间件负责会话的创建、提交和关闭:
```python
from flask import request, g
from src.core.database import db
def register_transaction(app):
"""注册 AOP 事务管理中间件"""
@app.before_request
def _transaction_begin():
"""请求开始前:标记事务活跃"""
g.transaction_active = True
@app.after_request
def _transaction_commit(response):
"""请求结束后:根据响应状态决定提交或回滚"""
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
if response.status_code == 200:
try:
body = response.get_json()
if body and body.get('code') == 0:
# 业务成功:提交事务
db.session.commit()
else:
# 业务失败:回滚事务
db.session.rollback()
except Exception:
db.session.rollback()
else:
# HTTP 非 200:回滚事务
db.session.rollback()
return response
@app.teardown_request
def _transaction_rollback(exception):
"""请求异常时:回滚事务"""
if exception:
db.session.rollback()
# 移除会话
db.session.remove()
```
### 业务成功判断
中间件通过响应体 JSON 的 `code` 字段判断业务是否成功:
```python
def _is_business_success(body: dict) -> bool:
"""仅当响应体为 JSON 且业务码 code == 0 视为成功"""
if not body:
return False
if not isinstance(body, dict):
return False
return body.get('code') == 0
```
## 独立提交
`commit_independently()` 函数用于审计日志等需要无论业务成功与否都持久化的场景:
```python
from src.core.database import db
def commit_independently(obj):
"""
使用独立session提交对象,不受请求事务影响
日志等审计数据无论业务成功或失败都应持久化,
因此需要绕过 AOP 事务中间件的 commit/rollback 机制。
"""
independent_session = db.session_factory()
try:
independent_session.add(obj)
independent_session.commit()
except Exception:
independent_session.rollback()
raise
finally:
independent_session.close()
```
:::warning 使用场景
`commit_independently()` 创建独立会话,不受请求级事务影响。适用于操作日志、登录日志等审计数据的持久化。普通业务数据应使用 `db.session` 获取请求级会话,由中间件统一管理事务。
:::
## 会话生命周期
```
请求进入
→ before_request 标记事务活跃
→ 业务代码通过 db.session 获取会话
→ after_request 根据响应码 commit/rollback
→ teardown_request 异常时 rollback 并移除会话
```
## 中间件注册顺序
在 `src/app.py` 的 `create_app()` 中,按以下顺序初始化:
```python
def create_app():
app = Flask(__name__)
# 1. 加载配置(数据库、Redis 等)
app.config.from_object(DatabaseConfig)
# 2. 配置 CORS 跨域
CORS(app, ...)
# 3. 可信代理配置(ProxyFix)
app.wsgi_app = ProxyFix(app.wsgi_app, ...)
# 4. 注册操作日志中间件(须在事务拦截器之前)
register_operation_log(app)
# 5. 注册 AOP 事务拦截器
register_transaction(app)
# 6. 注册 API v1 路由
register_v1(app)
# 7. 初始化扩展(数据库、Redis、调度器等)
register_extends(app)
# 8. 注册中间件(登录校验、演示模式、异常处理)
register_middleware(app)
# 9. 注入依赖(权限提供方、操作日志写入器)
_inject_dependencies(app)
return app
```
`register_operation_log` 在 `register_transaction` 之前,确保操作日志在事务提交前记录。
## BaseDB.save() 方法
`BaseDB` 基类提供的 `save()` 方法只执行 `flush`,不执行 `commit`:
```python
from src.core.database import db
from src.common.base_model import BaseModel
class BaseDB:
def save(self):
"""保存模型到数据库(仅 flush,不 commit)"""
db.session.add(self)
db.session.flush()
return self
def delete(self):
"""软删除模型(仅 flush,不 commit)"""
self.is_delete = 1
db.session.flush()
```
`flush` 的作用:
1. 将 SQL 语句发送到数据库执行
2. 获取自增 ID 等数据库生成的值
3. 但不提交事务,事务由中间件统一管理
## 注意事项
:::warning 重要提示
1. **不要手动调用 `db.session.commit()`**:事务由中间件统一管理,手动 commit 可能导致事务提前提交
2. **不要手动调用 `db.session.rollback()`**:除非在 `try-except` 块中需要手动回滚
3. **只读操作使用 GET 方法**:GET 请求不会触发事务处理
4. **写操作必须返回 R 对象**:中间件通过 `code` 字段判断是否需要提交
:::
## 总结
数据库会话管理通过 Flask 的 `g` 对象和 `before_request` / `after_request` 钩子实现请求级隔离,中间件自动根据业务响应码 commit/rollback,`commit_independently()` 提供独立事务能力用于审计数据持久化。这种设计确保了事务边界清晰,业务代码无需手动管理事务。
@@ -0,0 +1,135 @@
# 导入导出
:::tip 说明
项目基于 `openpyxl` 实现 Excel 导入导出功能,支持通过 `file_template` 模块管理导入模板配置。路由定义在各业务模块的 router 文件中(如 `src/modules/system/user/router.py`)。
:::
## 功能概述
Excel 导入导出是后台管理系统的常见需求,通过 `openpyxl` 库实现:
```
Excel 导出:
1. 将数据库中的数据导出为 Excel 文件,支持自定义表头和格式
2. 适用于生成报表、数据备份等场景
Excel 导入:
1. 将 Excel 文件中的数据导入到数据库,支持批量处理和校验
2. 适用于批量数据录入、数据迁移等场景
```
## 依赖说明
在 `requirements.txt` 中已包含:
```
openpyxl>=3.1.0
```
## 导出实现
导出接口通常在各业务模块的 router 中定义,返回文件流:
```python
from openpyxl import Workbook
from flask import send_file
import io
@user_blue.route('/export', methods=['GET'])
@check_permission('sys:user:export')
def export():
# 查询数据
from src.modules.system.user.service import get_export_data
data_list = get_export_data()
# 创建工作簿
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 send_file(
output,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
as_attachment=True,
download_name="export.xlsx"
)
```
## 导入实现
导入接口接收上传的 Excel 文件,解析后逐行校验并入库:
```python
from openpyxl import load_workbook
from flask import request
@user_blue.route('/import', methods=['POST'])
@check_permission('sys:user:import')
def import_excel():
file = request.files.get('file')
if not file:
return R.failed(msg="请上传文件")
content = file.read()
wb = load_workbook(io.BytesIO(content))
ws = wb.active
# 跳过表头,逐行解析
errors = []
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
username, realname, phone, email, status = row
# 校验并入库 ...
if error:
errors.append(f"第{row_idx}行:{error}")
if errors:
return R.failed(msg="导入失败:" + ",".join(errors))
return R.ok(msg="导入成功")
```
## file_template 模块
`file_template` 模块用于管理导入模板的配置信息,定义模板的字段映射、校验规则等。
模块路径:`src/modules/file_template/`
```
模板配置包含:
- 模板名称
- 关联模块
- 字段映射关系
- 校验规则
```
:::tip 温馨提示
导入时建议先下载模板文件,按模板格式填写数据后再导入,可减少格式错误。
:::
## 错误处理
导入过程中遇到校验错误时,返回详细的错误信息:
```json
{
"code": 1,
"msg": "导入失败:第3行用户名已存在,第5行手机号格式错误",
"data": null,
"ok": false
}
```
## 总结
Excel 导入导出功能基于 `openpyxl` 实现,导出使用 `send_file` 流式返回避免内存溢出,导入逐行校验并提供详细错误信息。`file_template` 模块可管理导入模板配置,便于维护字段映射和校验规则。
@@ -0,0 +1,182 @@
# 代码生成器
代码生成器是项目的重要工具,可以根据数据库表结构一键生成前后端 CRUD 代码,包括模型、仓库、服务、路由、表单、前端 API、前端页面等全套代码。
## 功能概述
代码生成器支持以下功能:
1. **表结构解析**:自动解析数据库表结构,提取字段信息
2. **代码生成**:根据模板生成前后端 CRUD 代码
3. **自定义配置**:支持配置生成选项,如模块名、作者、日期等
4. **模板扩展**:支持自定义模板,满足个性化需求
## 使用方式
### CLI 命令行使用
```bash
# 生成指定表的代码
python src/modules/generator/generate.py --table=flask_position
# 生成多个表的代码
python src/modules/generator/generate.py --table=flask_position,flask_level
# 生成所有表的代码
python src/modules/generator/generate.py --all
```
### Web 管理界面
登录管理后台,进入「系统工具 → 代码生成」页面:
1. 选择要生成代码的表
2. 配置生成选项(模块名、作者、日期等)
3. 点击「生成」按钮
4. 下载生成的代码文件
## 生成内容
代码生成器会生成以下文件:
### 后端文件
```
src/modules/{group}/{name}/
├── __init__.py # 模块初始化
├── models.py # 数据模型
├── repository.py # 数据仓库
├── service.py # 业务服务
├── router.py # 路由定义
└── schema.py # 表单验证
```
### 前端文件
```
ui/src/api/{group}/{name}/
└── index.ts # API 接口
ui/src/views/{group}/{name}/
├── index.vue # 列表页面
├── components/ # 组件目录
│ ├── AddModal.vue # 新增弹窗
│ ├── EditModal.vue # 编辑弹窗
│ └── DetailModal.vue # 详情弹窗
└── index.ts # 页面配置
```
## 配置文件
代码生成器的配置文件位于 `src/modules/generator/config.py`:
```python
# 代码生成配置
GENERATOR_CONFIG = {
# 作者名称
'author': 'DjangoAdmin',
# 日期格式
'date_format': '%Y-%m-%d %H:%M:%S',
# 是否覆盖已有文件
'overwrite': False,
# 模板目录
'template_dir': 'src/modules/generator/templates',
# 输出目录
'output_dir': 'src/modules',
}
```
## 模板系统
代码生成器使用 Jinja2 模板引擎,模板文件位于 `src/modules/generator/templates/` 目录:
```
templates/
├── backend/
│ ├── models.py.j2 # 模型模板
│ ├── repository.py.j2 # 仓库模板
│ ├── service.py.j2 # 服务模板
│ ├── router.py.j2 # 路由模板
│ └── schema.py.j2 # 表单模板
└── frontend/
├── api.ts.j2 # API 模板
├── index.vue.j2 # 列表页面模板
├── AddModal.vue.j2 # 新增弹窗模板
├── EditModal.vue.j2 # 编辑弹窗模板
└── DetailModal.vue.j2 # 详情弹窗模板
```
### 模板变量
模板中可用的变量:
| 变量 | 说明 | 示例 |
|:-----|:-----|:-----|
| `table_name` | 表名 | `flask_position` |
| `table_comment` | 表注释 | `岗位表` |
| `module_name` | 模块名 | `position` |
| `class_name` | 类名 | `Position` |
| `columns` | 字段列表 | `[{name, type, comment, ...}]` |
| `author` | 作者 | `DjangoAdmin` |
| `date` | 生成日期 | `2026-09-08 12:00:00` |
### 字段类型映射
| 数据库类型 | Python 类型 | WTForms 类型 | Vue 类型 |
|:-----------|:------------|:-------------|:---------|
| `INT` | `int` | `IntegerField` | `number` |
| `VARCHAR` | `str` | `StringField` | `string` |
| `TEXT` | `str` | `TextAreaField` | `string` |
| `DATETIME` | `datetime` | `DateTimeField` | `string` |
| `DECIMAL` | `Decimal` | `DecimalField` | `number` |
| `TINYINT` | `int` | `IntegerField` | `number` |
## 实战案例
### 生成岗位管理模块
```bash
# 1. 生成代码
python src/modules/generator/generate.py --table=flask_position
# 2. 查看生成的文件
ls -la src/modules/system/position/
# 3. 注册路由
# 编辑 src/routes/v1.py,添加 position_blue 到 BLUEPRINTS 列表
# 4. 添加菜单和权限节点
# 登录管理后台,进入「系统管理 → 菜单管理」添加菜单和权限节点
# 5. 测试接口
# 使用 curl 或 Postman 测试生成的接口
```
### 自定义模板
如果需要自定义生成的代码风格,可以修改模板文件:
```bash
# 编辑模型模板
vim src/modules/generator/templates/backend/models.py.j2
# 编辑服务模板
vim src/modules/generator/templates/backend/service.py.j2
# 编辑前端页面模板
vim src/modules/generator/templates/frontend/index.vue.j2
```
## 注意事项
:::warning 重要提示
1. **表名必须有前缀**:表名必须以 `DB_PREFIX`(默认 `flask_`)开头
2. **主键必须是 id**:代码生成器假设主键字段名为 `id`
3. **软删除字段**:代码生成器会自动添加 `is_delete` 字段的过滤
4. **生成后需要手动注册**:生成的路由需要手动添加到 `BLUEPRINTS` 列表
5. **菜单需要手动添加**:生成的权限节点需要手动添加到菜单管理
:::
## 总结
代码生成器是项目开发的重要工具,可以根据数据库表结构一键生成前后端 CRUD 代码。支持 CLI 命令行和 Web 管理界面两种使用方式,支持自定义模板和配置。生成的代码遵循项目的分层架构和编码规范,可以直接使用或根据需要进行修改。
@@ -0,0 +1,462 @@
# IP/UA 解析
:::tip 说明
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、限流、登录锁定等场景提供客户端身份信息。
:::
## 架构总览
```
请求进入
│
▼
┌──────────────────────────────────────────────────────┐
│ get_client_ip() │
│ ├─ 直连对端命中可信代理白名单? │
│ │ ├─ 是 → 从 X-Forwarded-For 取真实客户端 IP │
│ │ └─ 否 → request.remote_addr(fail-closed) │
│ └─ remote_addr 为 None → 回退 127.0.0.1 │
└──────────┬───────────────────────────────────────────┘
│
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────────────────────────┐
│ 限流 │ │ get_ip_region(ip) │
│ 登录锁定 │ │ ├─ 内网/保留 IP → "内网IP" │
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
└─────────┘ │ └─ 异常降级 → "未知" │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────┐
│ parse_user_agent(ua) │
│ ├─ 操作系统(正则) │
│ └─ 浏览器(正则) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 写入日志表 │
│ ip / location / │
│ os / browser │
└──────────────────────┘
```
## 一、IP 获取
位于 `src/common/utils/ip.py`,从 Flask Request 获取客户端真实 IP。
### get_client_ip()
```python
def get_client_ip() -> str:
"""
获取客户端真实IP地址
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
X-Forwarded-For 头:
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
直接使用 request.remote_addr(fail-closed,防止被直接暴露时伪造 XFF)
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
"""
```
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
### 代理链解析原理
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 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
│
▼
Flask 应用
request.remote_addr = 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.remote_addr` |
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
| `remote_addr` 为 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/common/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
获取地理位置文本描述。
```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/common/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`)不抛异常,按非内网处理交由查询侧兜底返回 `"未知"`。
:::
### 异常降级策略
所有查询接口均采用 **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/common/utils/user_agent.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')
"""
```
### 支持的操作系统
| 操作系统 | 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` |
### 支持的浏览器
按匹配优先级排列(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` |
## 四、应用场景
### 1. 登录日志
登录/退出时自动记录 IP、地理位置、操作系统、浏览器:
```python
# src/modules/login_log/service.py
ip = get_client_ip()
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"
...
)
```
**数据库字段**(`flask_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()
location = get_ip_location(ip)
ua = request.headers.get('User-Agent', '')
os_name, browser_name = parse_user_agent(ua)
```
**数据库字段**(`flask_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()
current_key = f"rl:{client_ip}:{window_index}"
count = sliding_window_incr(redis, current_key, ...)
if count > RATE_LIMIT_LIMIT:
return R.failed(msg="请求过于频繁,请 N 秒后再试")
```
**配置项**(`.env`):
| 变量 | 默认值 | 说明 |
|:-----|:-------|:-----|
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每 IP 最大请求数 |
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 窗口时长(秒) |
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis key 前缀 |
### 4. 登录失败锁定
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
```python
# src/modules/auth/service.py
ip = get_client_ip()
locked, remaining = _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/common/utils/captcha.py
client_ip = get_client_ip()
rate_key = f"captcha:rate:{client_ip}"
count = redis.get(rate_key)
if count and int(count) >= CAPTCHA_RATE_LIMIT:
raise BizError(msg="请求过于频繁,请等待 N 秒后再试")
```
## 五、配置参考
### 完整 .env 配置
```bash
# ============================================================
# 可信代理配置
# ============================================================
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
TRUSTED_PROXY_COUNT=1
# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF)
TRUSTED_PROXY_IPS=127.0.0.1
# ============================================================
# 请求限流配置
# ============================================================
# 是否启用请求限流(默认关闭)
RATE_LIMIT_ENABLED=False
# 单窗口内每个 IP 允许的最大请求数
RATE_LIMIT_LIMIT=100
# 统计窗口时长(秒)
RATE_LIMIT_WINDOW_SECONDS=60
# Redis 计数 key 前缀
RATE_LIMIT_KEY_PREFIX=rl:
```
## 六、常见问题
### 开发环境日志中 IP 显示为 127.0.0.1
**原因**:本地直连无代理,`request.remote_addr` 就是 `127.0.0.1`。
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
### 地理位置显示为"未知"
**可能原因**:
1. ip2region.xdb 文件不存在或加载失败(检查启动日志)
2. 查询的 IP 是 IPv6(当前仅支持 IPv4)
3. 畸形 IP 地址
**解决**:检查 `src/common/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.remote_addr`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设
## 七、总结
| 模块 | 文件 | 核心能力 | 降级策略 |
|:-----|:-----|:---------|:---------|
| IP 获取 | `src/common/utils/ip.py` | 从 Request 提取真实客户端 IP,支持可信代理白名单 | remote_addr 为 None 时回退 `127.0.0.1` |
| 地理位置 | `src/common/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` |
| UA 解析 | `src/common/utils/user_agent.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)}")
```
## 框架日志集成
Flask 和 SQLAlchemy 的默认日志统一走 root logger:
```python
for name in ("flask", "sqlalchemy"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
```
:::tip 温馨提示
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
:::
## 操作日志
除了应用日志外,还通过 `src/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `flask_operation_log` 表中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
## 总结
日志系统通过 `RotatingFileHandler` 实现按大小轮转,单文件上限 10MB 保留 5 份备份,防止日志无限增长撑爆磁盘。控制台和文件双输出,格式统一包含时间戳、级别、模块名和消息内容。
@@ -0,0 +1,118 @@
# 密码工具
:::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 src.core.password import generate_salt, encrypt_password
salt = generate_salt()
hashed = encrypt_password("mypassword", salt)
# 将 hashed 和 salt 存入数据库
```
### 登录验证
登录验证位于 `src/modules/auth/service.py`:
```python
from src.core.password import verify_password
# 从数据库获取用户记录
user = get_user_by_username(username)
# 验证密码
password_ok = 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/core/permission.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
def get_cached_permissions(user_id: int):
"""获取缓存的用户权限列表,未命中返回 None"""
redis = get_redis()
ver = _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
def set_cached_permissions(user_id: int, perm_list: list):
"""缓存用户权限列表(空列表不缓存,避免缓存空壳后无法感知后续数据变更)"""
if not perm_list:
return
redis = get_redis()
ver = _get_version()
key = f'{_PERM_PREFIX}{user_id}:{ver}'
redis.set(key, json.dumps(perm_list), ex=_CACHE_TTL)
```
## 缓存失效
### 菜单变更
```python
def invalidate_menu_change():
"""菜单增删改时调用,递增全局版本号使所有用户缓存失效"""
_incr_version()
```
递增版本号后,旧版本的缓存 Key 不再被访问,自然过期(TTL 2小时)。
### 用户角色变更
```python
def invalidate_user_perms(user_id: int):
"""用户角色变更时调用,删除该用户的权限和菜单缓存"""
redis = get_redis()
ver = _get_version()
keys = [
f'{_PERM_PREFIX}{user_id}:{ver}',
f'{_MENU_PREFIX}{user_id}:{ver}',
]
redis.delete(*keys)
```
### 角色菜单变更
```python
def invalidate_role_perms(role_id: int):
"""角色菜单变更时调用,清理该角色下所有用户的权限和菜单缓存"""
# 查询该角色下所有用户 ID
user_ids = _query_user_ids(role_id)
# 批量删除缓存
redis = get_redis()
ver = _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分钟
def get_cached_user_active(user_id: int):
"""读取用户有效状态缓存;未命中返回 None(调用方需回源 DB 校验)"""
def set_cached_user_active(user_id: int, active: bool):
"""写入用户有效状态缓存"""
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:
_incr_version()
except Exception as e2:
logger.error(f"降级递增版本号失败: {e2}")
```
:::tip 温馨提示
权限缓存的 TTL 为 2小时,用户有效状态缓存 TTL 为 5分钟。正常情况下缓存由业务操作即时失效,TTL 仅作为兜底防止 Redis 故障时缓存永久残留。
:::
## 总结
权限缓存采用用户级缓存 + 全局版本号双层策略,菜单变更通过递增版本号使所有缓存自然失效,无需遍历删除。所有缓存操作都有异常降级机制,Redis 不可用时自动回源数据库查询。
@@ -0,0 +1,210 @@
# 请求解析工具
:::tip 说明
`src/common/utils/request.py` 提供请求数据解析相关的工具函数,包括批量 ID 解析、SQL LIKE 安全处理、分页参数解析等。这些工具函数在各业务模块中广泛使用。
:::
## parse_id_list
从请求体解析批量删除的 ID 数组,校验并转为整数列表。
```python
from flask import request
from src.common.utils.request import parse_id_list
def parse_id_list(request) -> list:
"""
从请求体解析 ID 数组
请求体格式: [1, 2, 3]
返回: [1, 2, 3]
示例:
>>> parse_id_list(request)
[1, 2, 3]
"""
ids = request.get_json()
if not ids or not isinstance(ids, list) or len(ids) == 0:
raise BizError("参数错误:ids必须为非空数组")
try:
id_list = [int(id_val) for id_val in ids]
except (ValueError, TypeError):
raise BizError("参数错误:数组元素必须为数字")
return id_list
```
使用示例:
```python
@position_blue.route('/batchDelete', methods=['DELETE'])
@check_permission('sys:position:batchDelete')
def batch_delete():
from src.modules.system.position.service import batch_delete_positions
id_list = parse_id_list(request)
return batch_delete_positions(id_list)
```
## parse_request_body
解析请求体 JSON 数据,并使用 WTForms 进行校验。
```python
from flask import request
from src.common.utils.request import parse_request_body
def parse_request_body(request, form_class):
"""
解析请求体并校验
参数:
request: Flask 请求对象
form_class: WTForms 表单类
返回:
dict: 校验通过的数据
异常:
BizError: 校验失败时抛出
"""
data = request.get_json()
if not data:
raise BizError("请求体不能为空")
form = form_class(data=data)
if not form.validate():
# 提取第一个错误信息
errors = form.errors
first_error = list(errors.values())[0][0] if errors else "参数校验失败"
raise BizError(first_error)
return {field.name: field.data for field in form}
```
使用示例:
```python
@position_blue.route('/add', methods=['POST'])
@check_permission('sys:position:add')
def add():
from src.modules.system.position.service import add_position
from src.modules.system.position.schema import PositionForm
data = parse_request_body(request, PositionForm)
return add_position(data)
```
## parse_pagination
安全解析分页参数 `pageNo` / `pageSize`。
```python
from flask import request
from src.common.utils.request import parse_pagination
def parse_pagination(default_page_size: int = 10, max_page_size: int = 500) -> tuple:
"""
安全解析分页参数
参数:
default_page_size: 默认每页数量
max_page_size: 最大每页数量
返回:
tuple: (page, page_size),page>=1,1<=page_size<=max_page_size
示例:
>>> parse_pagination()
(1, 10)
"""
page = request.args.get('pageNo', 1, type=int)
page_size = request.args.get('pageSize', default_page_size, type=int)
# 非数字、小于1、超上限等一律回退/夹取
page = max(1, page)
page_size = max(1, min(page_size, max_page_size))
return page, page_size
```
使用示例:
```python
def get_position_page():
"""获取岗位分页列表"""
page, page_size = parse_pagination()
# 使用 page 和 page_size 进行分页查询
# ...
```
## like_escape
转义 SQL LIKE 通配符,防止用户输入被当作通配符。
```python
from src.common.utils.request import like_escape
def like_escape(value, escape_char='\\'):
"""
转义 SQL LIKE 通配符
示例:
>>> 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
from src.common.utils.request import like_contains
def like_contains(value, escape_char='\\'):
"""
构建安全的 LIKE 包含查询值
SQLAlchemy 用法:
query = query.filter(User.name.like(like_contains(name), escape='\\\\'))
"""
return f'%{like_escape(value, escape_char)}%'
```
使用示例:
```python
from src.common.utils.request import like_contains
def search_users(keyword):
"""搜索用户"""
return UserRepository.find_list(
like_filters={'username': keyword} if keyword else None,
)
```
## get_client_ip
获取客户端真实 IP 地址。
```python
from flask import request
from src.common.utils.request import get_client_ip
def get_client_ip() -> str:
"""
获取客户端真实 IP 地址
支持反向代理(X-Forwarded-For)和直接连接
"""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr or '127.0.0.1'
```
## 总结
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_id_list` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
@@ -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(FLASK_FILE_URL) != -1:
return path
return FLASK_FILE_URL + path
```
## 临时文件迁移
`save_file()` 函数将临时目录中的文件迁移到正式存储目录:
```
临时路径: {temp}/{日期}/{文件名}
正式路径: {upload_dir}/{分类}/{日期}/{文件名}
```
迁移时会做路径穿越检查,确保不会跳出 `UPLOAD_DIR` 目录。
:::tip 温馨提示
富文本内容在 service 层调用 `save_content()` 处理,而不是在 router 层。所有涉及富文本的模块(文章、通知公告等)都应使用此函数清洗内容。
:::
## 总结
富文本过滤通过 `bleach` 白名单清洗 + CSS 表达式正则兜底两层防护,有效防止存储型 XSS 攻击。同时自动处理内容中的图片文件迁移和 URL 拼接,确保文件路径安全且可访问。
@@ -0,0 +1,295 @@
# 文件上传完整流程
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
## 上传架构
```
前端 Vue3 后端 Flask 文件系统
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Upload │ multipart │ 安全校验 │ 写入 │ uploads/ │
│ 组件 │ ─────────> │ ─ 扩展名白名单│ ───────> │ 2026/ │
│ │ │ ─ 魔术字节 │ │ 09/ │
│ │ │ ─ 大小限制 │ │ 05/ │
│ │ │ ─ 路径遍历 │ │ xxx.jpg│
└──────────┘ └──────────────┘ └──────────┘
│
▼
┌──────────────┐
│ 返回 fileUrl │
│ 前端存入字段 │
└──────────────┘
```
## 环境变量配置
| 变量 | 默认值 | 说明 |
|:----|:----|:----|
| `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) |
| `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) |
| `FLASK_FILE_URL` | `http://file.flask.elevue` | 文件访问域名 |
| `FLASK_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:8021/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
def _read_upload_limited(file, max_size_bytes):
chunks = []
total = 0
while True:
chunk = 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` 是拼接了 `FLASK_FILE_URL` 前缀的完整 URL:
```
http://127.0.0.1:8021/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):
# 文件字段:add/update 时自动迁移临时文件,列表/详情自动补全 URL
file_fields = ('cover', 'attachment')
```
### 自动处理逻辑
1. **新增/更新时**:检测文件字段值是否为临时路径,如果是则迁移到正式目录
2. **列表/详情时**:自动为文件字段补全 `FLASK_FILE_URL` 前缀
## 富文本中的图片
在 Service 中声明 `rich_text_fields`,基类自动处理:
```python
class ArticleService(BaseService):
# 富文本字段:自动迁移嵌入图片 + 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` 配置,添加所需扩展名。
### 图片上传成功但不显示
**原因**:`FLASK_FILE_URL` 配置错误或前端未拼接域名。
**解决**:检查 `.env` 中的 `FLASK_FILE_URL` 配置,确保前端正确拼接。
### 上传报演示环境无权限
**原因**:演示模式下写操作被拦截。
**解决**:将 `.env` 中的 `FLASK_DEMO` 设为 `False`。
## 总结
文件上传功能通过扩展名白名单 + 魔术字节校验 + 路径遍历防护 + 大小限制四层安全机制保障安全性。文件按日期分目录存储,使用时间戳 + 随机数重命名。统一的 `uploadFile` 接口支持图片和各类文件上传。Service 层通过 `file_fields` 和 `rich_text_fields` 声明即可自动处理文件迁移和 URL 补全。
@@ -0,0 +1,128 @@
# 文件上传
:::tip 说明
项目提供本地文件上传功能,支持图片和通用文件上传,内置扩展名白名单、大小限制、魔术字节校验和路径遍历防护。服务层位于 `src/modules/upload/service.py`,路由位于 `src/modules/upload/router.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
# 上传文件存储根目录
FLASK_UPLOAD_DIR=E:\upload
# 文件访问域名(Nginx 或本地代理)
FLASK_FILE_URL=http://127.0.0.1:8021/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
def _read_upload_limited(file, max_size_bytes):
"""流式读取,超过大小上限立即中断,避免整文件读入内存"""
chunks = []
total = 0
while True:
chunk = 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:8021/api/file/20250306/2025030614302512345.jpg"
},
"ok": true
}
```
## 前端集成
前端使用 `Upload` 组件上传,上传成功后将返回的 `fileUrl` 存入业务字段。详见 [文件上传联调](/zh/volume4/integration/upload-sync) 章节。
## 总结
文件上传模块通过扩展名白名单、魔术字节校验、流式大小限制、路径遍历防护等多层安全机制,确保上传功能的安全性。文件自动重命名并按日期分目录存储,避免文件名冲突和信息泄露。统一的 `uploadFile` 接口同时支持图片和各类文件上传。