优化更新
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -496,11 +496,7 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "5.1.14 请求限流",
|
||||
link: "/zh/volume4/core/rate-limit.html",
|
||||
},
|
||||
{
|
||||
text: "5.1.15 防重复提交",
|
||||
text: "5.1.14 防重复提交",
|
||||
link: "/zh/volume4/core/idempotent.html",
|
||||
},
|
||||
],
|
||||
@@ -511,11 +507,11 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "5.1.16 AOP 事务管理",
|
||||
text: "5.1.15 AOP 事务管理",
|
||||
link: "/zh/volume4/core/transaction.html",
|
||||
},
|
||||
{
|
||||
text: "5.1.17 事务装饰器",
|
||||
text: "5.1.16 事务装饰器",
|
||||
link: "/zh/volume4/core/transaction-decorator.html",
|
||||
},
|
||||
],
|
||||
@@ -624,10 +620,6 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
text: "5.3.6 密码加密与验证",
|
||||
link: "/zh/volume4/tools/password.html",
|
||||
},
|
||||
{
|
||||
text: "5.3.7 日志系统",
|
||||
link: "/zh/volume4/tools/logger.html",
|
||||
},
|
||||
],
|
||||
},
|
||||
// 🧩 辅助工具
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 2.5.0 | 2025-06-09 | Vue3 + ElementPlus + TypeScript + Vite |
|
||||
| 2.6.0 | 2025-09-22 | 代码生成器 + Excel 导入导出 |
|
||||
| 2.7.0 | 2025-12-16 | 定时任务调度 + 操作日志 + 登录日志 |
|
||||
| 2.8.0 | 2026-03-28 | 接口限流 + IP 地理位置 + 安全防护增强 |
|
||||
| 2.8.0 | 2026-03-28 | IP 地理位置 + UA 解析 + 安全防护增强 |
|
||||
| 2.9.0 | 2026-06-07 | 技术文档体系 + 契约测试 + 缓存优化 |
|
||||
| **3.0.0** | **2026-09-01**** | **模块化分层 + Repository 层 + AOP 事务 + 数据迁移工具** |
|
||||
|
||||
@@ -86,13 +86,12 @@
|
||||
|
||||
## [2.8.0] - 2026-03-28
|
||||
|
||||
**新增接口限流与 IP 地理位置查询,安全防护全面增强。**
|
||||
**新增 IP 地理位置查询与 UA 解析,安全防护全面增强。**
|
||||
|
||||
### 新增
|
||||
|
||||
- **IP 地理位置查询**:基于离线 GeoIP 数据库,服务启动时加载内存,查询零延迟,无外部 API 依赖
|
||||
- **User-Agent 解析**:自动识别客户端操作系统类型与版本、浏览器名称与版本
|
||||
- **接口限流**:基于 Redis 滑动窗口的按 IP 限流策略,支持按接口独立配置阈值,防刷防滥用
|
||||
- **操作日志自动记录**:所有写操作(POST / PUT / DELETE)自动捕获请求参数与响应结果,无需手动埋点
|
||||
- **验证码防刷**:按 IP 频率控制,单 IP 每分钟最多获取 5 次验证码,超限拒绝生成
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|------|----------|------|
|
||||
| Python | 3.12+ | 后端运行环境 |
|
||||
| MySQL | 8.0+ | 数据库 |
|
||||
| Redis | 3.2+ | 缓存(验证码、JWT黑名单、限流) |
|
||||
| Redis | 3.2+ | 缓存(验证码、JWT黑名单) |
|
||||
| Node.js | 22+ | 前端构建环境 |
|
||||
| pnpm | 最新版 | 前端包管理器 |
|
||||
| Git | 最新版 | 版本控制 |
|
||||
@@ -130,7 +130,7 @@ python scripts/init_db.py
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
以上命令是推荐的直接执行方式,适用于所有操作系统。文档中出现的 `make` 命令(如 `make db-init`)是 Linux / macOS 系统的便捷别名,Windows 用户直接使用对应的原始命令即可(如 `python scripts/init_db.py`)。
|
||||
以上命令是推荐的直接执行方式,适用于所有操作系统。文档中出现的 `make` 命令(如 `make db-migrate`)是 Linux / macOS 系统的便捷别名,Windows 用户直接使用对应的原始命令即可(如 `flask db migrate`)。
|
||||
:::
|
||||
|
||||
初始化完成后,数据库中包含以下默认数据:
|
||||
@@ -230,7 +230,8 @@ pnpm dev
|
||||
**models.py** — 定义数据模型:
|
||||
|
||||
```python
|
||||
from sqlalchemy import Column, String, Integer, text
|
||||
from sqlalchemy import text
|
||||
from src.core.database import db
|
||||
from src.common.base_db import BaseDB
|
||||
from src.common.base_model import BaseModel
|
||||
from config.database import DB_PREFIX
|
||||
@@ -242,11 +243,11 @@ class Position(BaseModel, BaseDB):
|
||||
__table_comment__ = "岗位表"
|
||||
|
||||
# 岗位名称
|
||||
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
|
||||
name = db.Column(db.String(255), nullable=False, index=True, comment="岗位名称")
|
||||
# 岗位状态:1-在用 2-停用
|
||||
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
|
||||
status = db.Column(db.Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:0-停用 1-启用")
|
||||
# 岗位排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
|
||||
sort = db.Column(db.Integer, default=0, server_default=text('0'), comment="岗位排序")
|
||||
```
|
||||
|
||||
**schema.py** — 定义表单校验:
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
| 问题 | 说明 | 详细文档 |
|
||||
|------|------|----------|
|
||||
| IP 地理位置显示"未知" | xdb 文件不存在或 IP 格式问题 | [IP/UA 解析](/zh/volume4/tools/ip-ua) |
|
||||
| 限流不生效 | RATE_LIMIT_ENABLED 未开启 | [IP/UA 解析](/zh/volume4/tools/ip-ua) |
|
||||
| 富文本 XSS 被过滤 | bleach 清洗规则过严 | [富文本过滤](/zh/volume4/tools/rich-text) |
|
||||
| 文件上传被拒绝 | 扩展名不在白名单 | [上传配置](/zh/volume4/tools/upload-guide) |
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
+ **模块可插拔**:业务模块位于 `src/modules/`,按需增删不影响核心框架
|
||||
+ **多数据库支持**:通过 `DB_DRIVER` 环境变量一键切换 MySQL / PostgreSQL / SQL Server / SQLite / Oracle
|
||||
+ **AOP 事务管理**:自定义事务拦截器,业务代码无需手动 commit/rollback
|
||||
+ **中间件可扩展**:CORS、限流、日志、事务通过中间件链式处理,按需注册
|
||||
+ **中间件可扩展**:CORS、日志、事务通过中间件链式处理,按需注册
|
||||
+ **RBAC 权限**:后端装饰器 + 前端指令双重校验,精确到按钮级
|
||||
+ **代码生成器**:根据数据库表结构一键生成前后端 CRUD 代码
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
| Django+AntDesign前后端分离版 | 基于 Django、Django ORM、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_AntdVue) |
|
||||
| Django+NaiveUI前后端分离版 | 基于 Django、Django ORM、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_NaiveVue) |
|
||||
| Flask+Layui混编版 | 基于 Flask、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_Layui) |
|
||||
| Flask+ElementPlus前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue) |
|
||||
| Flask+AntDesign前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue) |
|
||||
| Flask+NaiveUI前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_NaiveVue) |
|
||||
| Flask+ElementPlus前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue) |
|
||||
| Flask+AntDesign前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue) |
|
||||
| Flask+NaiveUI前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_NaiveVue) |
|
||||
| FastAPI+Layui混编版 | 基于 FastAPI、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_Layui) |
|
||||
| FastAPI+ElementPlus前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue) |
|
||||
| FastAPI+AntDesign前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_AntdVue) |
|
||||
|
||||
@@ -14,20 +14,28 @@
|
||||
```
|
||||
├── src/ // 后端源码
|
||||
├── ui/ // 前端源码(Vue3 + ElementPlus)
|
||||
├── config/ // 配置文件(base / database / redis / auth)
|
||||
├── docker/ // Docker 部署配置(Dockerfile / docker-compose.yml)
|
||||
├── document/ // 项目文档(SQL脚本、迁移日志等)
|
||||
├── migrations/ // Alembic 数据库迁移脚本
|
||||
├── scripts/ // 脚本工具(init_db、migrate_db、seed 等)
|
||||
├── scripts/ // 脚本工具(setup_db / migrate_db 等)
|
||||
├── tests/ // 测试代码(contract / unit / integration)
|
||||
├── deploy/ // 部署配置(nginx.conf、supervisor.conf)
|
||||
├── wiki/ // VitePress 文档站点
|
||||
├── static/ // 静态资源(验证码字体等)
|
||||
├── uploads/ // 上传文件存储目录
|
||||
├── app.py // 后端启动入口
|
||||
├── gunicorn.conf.py // Gunicorn 生产环境配置
|
||||
├── generator.py // 代码生成器 CLI 入口
|
||||
├── config.json // 代码生成器配置
|
||||
├── .env.example // 环境变量模板
|
||||
├── .env // 环境变量(不提交到版本控制)
|
||||
├── docker-compose.yml // Docker 编排配置
|
||||
├── Dockerfile // 后端镜像构建文件
|
||||
├── Makefile // 快捷命令(db-init、migrate-*、db-migrate)
|
||||
├── pyproject.toml // 项目元数据与工具配置
|
||||
├── requirements.txt // Python 依赖清单
|
||||
├── alembic.ini // Alembic 配置
|
||||
├── requirements-dev.txt // 开发依赖清单
|
||||
├── .flake8 // Flake8 代码检查配置
|
||||
├── .pre-commit-config.yaml // Pre-commit 钩子配置
|
||||
├── .dockerignore // Docker 构建忽略规则
|
||||
└── README.md // 项目说明
|
||||
```
|
||||
|
||||
@@ -60,7 +68,7 @@ src/
|
||||
│ ├── constants/ // 常量定义
|
||||
│ │ └── common.py // PAGE_SIZE / DEFAULT_PASSWORD 等
|
||||
│ └── utils/ // 工具函数
|
||||
│ ├── request.py // parse_request_body / parse_batch_ids / parse_id_list
|
||||
│ ├── request.py // parse_request_body / parse_batch_ids / parse_batch_ids
|
||||
│ ├── captcha.py // 验证码生成(Pillow)
|
||||
│ ├── file.py // 文件工具
|
||||
│ ├── ip.py // IP 地址获取
|
||||
@@ -141,7 +149,9 @@ ui/src/
|
||||
│ ├── tool/ // 工具页面(generator / example)
|
||||
│ ├── setting/ // 设置页面(profile / configWeb)
|
||||
│ ├── exception/ // 异常页面(404 / 500)
|
||||
│ └── about/ // 关于页面
|
||||
│ ├── about/ // 关于页面
|
||||
│ ├── iframe/ // 内嵌页面
|
||||
│ └── redirect/ // 重定向页面
|
||||
├── components/ // 公共组件
|
||||
│ ├── Table/ // BasicTable 表格组件
|
||||
│ ├── Form/ // BasicForm 表单组件
|
||||
@@ -157,6 +167,9 @@ ui/src/
|
||||
│ ├── Select/ // 下拉选择组件
|
||||
│ ├── CountTo/ // 数字动画组件
|
||||
│ ├── Authority/ // 权限组件
|
||||
│ ├── CheckToken/ // Token 检查组件
|
||||
│ ├── Application/ // 应用组件
|
||||
│ ├── Websocket/ // WebSocket 组件
|
||||
│ ├── Lockscreen/ // 锁屏组件
|
||||
│ ├── Password/ // 密码强度组件
|
||||
│ ├── Region/ // 区域选择组件
|
||||
|
||||
@@ -37,16 +37,16 @@ cd djangoadmin
|
||||
│ ├── core/ # 核心模块(数据库、缓存、认证、响应等)
|
||||
│ ├── routes/ # 路由层(HTTP 接口定义)
|
||||
│ ├── modules/ # 业务模块(模型、仓库、服务)
|
||||
│ ├── middleware/ # 中间件(CORS、限流、日志、操作记录等)
|
||||
│ ├── common/ # 公共模块(基类、常量、工具函数等)
|
||||
│ └── utils/ # 工具函数
|
||||
│ ├── middleware/ # 中间件(认证、事务、日志、操作记录等)
|
||||
│ └── common/ # 公共模块(基类、常量、工具函数等)
|
||||
├── ui/ # 前端源码(独立仓库或子目录)
|
||||
├── config/ # 配置文件(base / database / redis / auth)
|
||||
├── docker/ # Docker 部署配置(Dockerfile / docker-compose.yml)
|
||||
├── scripts/ # 脚本工具(数据库初始化、数据迁移等)
|
||||
├── migrations/ # Flask-Migrate 数据库迁移文件
|
||||
├── tests/ # 测试代码
|
||||
├── .env.example # 环境变量示例文件
|
||||
├── requirements.txt # Python 依赖清单
|
||||
├── docker-compose.yml # Docker 编排文件
|
||||
└── Makefile # 常用命令集合
|
||||
```
|
||||
|
||||
@@ -72,7 +72,7 @@ pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
|
||||
```bash
|
||||
# 检查 Flask 是否安装成功
|
||||
python -c "import flask; print(flask.__version__)"
|
||||
3.1.1
|
||||
3.1.3
|
||||
|
||||
# 检查 SQLAlchemy 是否安装成功
|
||||
python -c "import sqlalchemy; print(sqlalchemy.__version__)"
|
||||
@@ -265,14 +265,20 @@ curl http://127.0.0.1:8021/api/v1/captcha
|
||||
|
||||
| 命令 | 对应命令 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `make install` | `pip install -r requirements.txt` | 安装运行时 + 开发依赖 |
|
||||
| `make install` | `pip install -r requirements.txt` | 安装生产依赖 |
|
||||
| `make install-dev` | `pip install -r requirements-dev.txt` | 安装开发依赖(含测试/规范工具) |
|
||||
| `make dev` | `python app.py` | 启动开发服务 |
|
||||
| `make test` | `pytest` | 运行全部测试(默认跳过 integration) |
|
||||
| `make test-contract` | `pytest tests/contract` | 运行契约快照测试 |
|
||||
| `make test-unit` | `pytest tests/unit` | 运行单元测试 |
|
||||
| `make test-integration` | `pytest tests/integration` | 运行集成测试(需真实 MySQL/Redis) |
|
||||
| `make db-init` | `python scripts/setup_db.py` | 初始化数据库 |
|
||||
| `make seed-dict` | `python scripts/seed_dict.py` | 初始化业务字典数据 |
|
||||
| `make lint` | `flake8 + black --check + isort --check-only` | 静态检查 |
|
||||
| `make format` | `black + isort` | 自动格式化 |
|
||||
| `make type-check` | `mypy src` | 类型检查 |
|
||||
| `make test` | `pytest` | 运行全部测试 |
|
||||
| `make test-cov` | `pytest --cov=src` | 带覆盖率运行测试 |
|
||||
| `make db-migrate` | `flask db migrate` | 生成迁移脚本 |
|
||||
| `make db-upgrade` | `flask db upgrade` | 应用迁移到最新 |
|
||||
| `make db-downgrade` | `flask db downgrade` | 回滚一次迁移 |
|
||||
| `make docker-up` | `cd docker && docker-compose up -d` | 启动 Docker 服务 |
|
||||
| `make docker-down` | `cd docker && docker-compose down` | 停止 Docker 服务 |
|
||||
| `make clean` | 清理 `__pycache__` / `.pyc` / `.pytest_cache` | 清理缓存与临时文件 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@@ -294,13 +294,13 @@ $ python scripts/setup_db.py --drop-first
|
||||
| 数据库 | DB_DRIVER 值 | 默认端口 | 依赖包 | 脚本目录 |
|
||||
|:----|:----|:----|:----|:----|
|
||||
| MySQL | `mysql` | 3306 | PyMySQL | `document/mysql/` |
|
||||
| PostgreSQL | `postgresql` | 5432 | psycopg[binary] | `document/postgresql/` |
|
||||
| PostgreSQL | `postgresql` | 5432 | psycopg2-binary | `document/postgresql/` |
|
||||
| SQL Server | `mssql` | 1433 | pymssql | `document/sqlserver/` |
|
||||
| SQLite | `sqlite` | - | 内置 | `document/sqlite/` |
|
||||
| Oracle | `oracle` | 1521 | oracledb | `document/oracle/` |
|
||||
|
||||
:::tip 温馨提示
|
||||
切换数据库驱动后,需要安装对应的驱动包(如 `pip install psycopg[binary]`),然后按照上述步骤重新初始化数据库。
|
||||
切换数据库驱动后,需要安装对应的驱动包(如 `pip install psycopg2-binary`),然后按照上述步骤重新初始化数据库。
|
||||
:::
|
||||
|
||||
## 种子数据
|
||||
@@ -380,13 +380,13 @@ flask db stamp head
|
||||
|
||||
| 命令 | 对应命令 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `make db-init` | `python scripts/setup_db.py` | 初始化数据库(建库 + 建表 + 种子数据 + flask db stamp) |
|
||||
| `make seed-dict` | `python scripts/seed_dict.py` | 初始化业务字典数据(幂等,按 code 判重) |
|
||||
| `make migrate-revision` | `flask db migrate` | 生成增量迁移脚本 |
|
||||
| `make migrate-upgrade` | `flask db upgrade` | 应用所有待执行的迁移 |
|
||||
| `make migrate-stamp` | `flask db stamp head` | 标记当前数据库为最新版本 |
|
||||
| `make db-migrate ARGS="..."` | `python scripts/migrate_db.py ...` | 跨库数据迁移 |
|
||||
| `make db-migrate-dry-run ARGS="..."` | `python scripts/migrate_db.py ... --dry-run` | 跨库迁移干跑(只核对计划,不写入目标库) |
|
||||
| `make db-migrate` | `flask db migrate` | 生成增量迁移脚本 |
|
||||
| `make db-upgrade` | `flask db upgrade` | 应用迁移到最新 |
|
||||
| `make db-downgrade` | `flask db downgrade` | 回滚一次迁移 |
|
||||
|
||||
:::tip 数据库初始化
|
||||
数据库初始化使用 `python scripts/setup_db.py`(非 Makefile 命令),详见上方「方式二:脚本初始化」章节。跨库数据迁移使用 `python scripts/migrate_db.py`。
|
||||
:::
|
||||
|
||||
## 数据库备份与恢复
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
|
||||
## Docker Compose 配置
|
||||
|
||||
项目的 `docker-compose.yml` 文件定义了后端服务的容器编排:
|
||||
项目的 `docker/docker-compose.yml` 文件定义了后端服务的容器编排:
|
||||
|
||||
```yaml
|
||||
# +======================================================================
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
| FLASK_HOST | 0.0.0.0 | 应用监听地址。`127.0.0.1` 仅本地访问;容器/生产建议 `0.0.0.0` |
|
||||
| FLASK_PORT | 8021 | 应用监听端口 |
|
||||
| FLASK_APP | app.py | 应用启动文件名 |
|
||||
| FLASK_ENV | production | 运行环境:`development` / `production` / `testing` |
|
||||
| FLASK_DEBUG | False | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` |
|
||||
| FLASK_ENV | development | 运行环境:`development` / `production` / `testing` |
|
||||
| FLASK_DEBUG | True | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` |
|
||||
| FLASK_DEMO | False | 是否开启演示模式。`True` 时所有写操作(add/update/delete/status/batchDelete)返回"演示环境,暂无操作权限" |
|
||||
| FLASK_SECRET_KEY | (空) | Flask 应用密钥,用于 Session 加密和 CSRF 保护,**必填**且至少 16 字符 |
|
||||
| FLASK_FILE_URL | http://file.flask.elevue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 |
|
||||
| UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号)。`upload_size_limit_middleware` 按 `Content-Length` 提前拦截超大请求 |
|
||||
| FLASK_USE_RELOADER | False | 是否禁用自动重启。仅在 `FLASK_DEBUG=True` 时生效 |
|
||||
| FLASK_SECRET_KEY | `<必填>` | Flask 应用密钥,用于 Session 加密和 CSRF 保护,**必填**且至少 16 字符 |
|
||||
| FLASK_UPLOAD_DIR | ./uploads | 文件上传存储目录 |
|
||||
| FLASK_FILE_URL | http://file.flask.elevue | 文件访问域名,用于拼接上传文件的访问 URL |
|
||||
| UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号) |
|
||||
| UPLOAD_MAX_SIZE_MB | 10 | 上传文件大小上限(MB) |
|
||||
|
||||
:::tip 安全提示
|
||||
@@ -40,7 +42,11 @@
|
||||
| DB_USERNAME | root | 数据库账号(`sqlite` 时忽略) |
|
||||
| DB_PASSWORD | CHANGE_ME_DB_PASSWORD | 数据库密码,**必填**(`sqlite` 除外) |
|
||||
| DB_PREFIX | flask_ | 数据表前缀,所有业务表名自动添加此前缀。模型中通过 `DB_PREFIX + "position"` 拼接表名 |
|
||||
| DB_DEBUG | False | 是否开启 SQL 调试日志(对应 SQLAlchemy `echo` 参数)。生产环境务必设为 `False` |
|
||||
| DB_DEBUG | True | 是否开启 SQL 调试日志(对应 SQLAlchemy `echo` 参数)。生产环境务必设为 `False` |
|
||||
| DB_SSL | False | 是否启用数据库 SSL 加密连接 |
|
||||
| DB_SSL_CA | (空) | SSL CA 证书路径(验证服务端证书) |
|
||||
| DB_SSL_CERT | (空) | SSL 客户端证书路径(双向认证) |
|
||||
| DB_SSL_KEY | (空) | SSL 客户端私钥路径(双向认证) |
|
||||
|
||||
:::tip 多数据库支持
|
||||
`DB_DRIVER` 支持别名:`postgres` → `postgresql`、`sqlserver` → `mssql`、`sqlite3` → `sqlite`。切换驱动后需安装对应的 Python 驱动包。
|
||||
@@ -58,17 +64,32 @@
|
||||
| REDIS_DECODE_RESPONSES | True | 是否自动解码响应(bytes → str) |
|
||||
|
||||
:::tip 温馨提示
|
||||
Redis 用于存储验证码、JWT 令牌黑名单和登录限流数据,是项目正常运行的必需服务。
|
||||
Redis 用于存储验证码、JWT 令牌黑名单和登录锁定数据,是项目正常运行的必需服务。
|
||||
:::
|
||||
|
||||
## JWT 令牌配置
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| JWT_SALT | (空) | JWT 签名密钥,**必填**。至少 16 字符强随机值,留空应用会拒绝启动 |
|
||||
| JWT_EXPIRE_MINUTES | 20 | JWT 令牌过期时间(分钟) |
|
||||
| JWT_SALT | `<必填>` | JWT 签名密钥,**必填**。至少 16 字符强随机值,留空应用会拒绝启动 |
|
||||
| JWT_EXPIRE_MINUTES | 20 | 滑动会话空闲超时(分钟),无请求则过期,活跃自动续期 |
|
||||
| JWT_ABSOLUTE_TIMEOUT_MINUTES | 1440 | 令牌绝对有效期(分钟),防止无限续期,须 ≥ JWT_EXPIRE_MINUTES |
|
||||
| LOGIN_FAIL_MAX | 5 | 登录失败次数上限,超过后锁定账号(防暴力破解) |
|
||||
| LOGIN_LOCK_SECONDS | 300 | 登录锁定时间(秒) |
|
||||
| PERMISSION_ENABLED | True | 权限校验总开关。`False` 时所有请求放行(开发调试可用) |
|
||||
| INTERNAL_API_KEY | `<必填>` | 内部调用密钥(定时任务等无用户场景,通过 `X-Scheduler-Key` / `X-Internal-API-Key` 传递) |
|
||||
| INTERNAL_ALLOWED_IPS | 127.0.0.1 | 内部调用白名单 IP,逗号分隔 |
|
||||
|
||||
## 登录白名单配置
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| LOGIN_WHITELIST_EXACT | /api/v1/login,/api/v1/captcha,/favicon.ico,/health | 精确匹配白名单,逗号分隔 |
|
||||
| LOGIN_WHITELIST_PREFIX | /api/v1/job/sendSms,/public,/static,/api/webhook | 前缀匹配白名单,逗号分隔 |
|
||||
| LOGIN_WHITELIST_WILDCARD | /api/v1/job/*/send*,/api/v1/*/public* | 通配符匹配白名单,支持 `*` 和 `?` |
|
||||
| LOGIN_WHITELIST_DEBUG | True | 是否启用白名单调试日志 |
|
||||
| LOGIN_WHITELIST_LOG_ACCESS | True | 白名单路径是否记录访问日志 |
|
||||
| CAPTCHA_BYPASS_CODE | (空) | 万能验证码,仅 `FLASK_DEMO=True` 时生效 |
|
||||
|
||||
:::danger 重要提示
|
||||
`JWT_SALT` 是安全关键配置,必须设置。推荐使用以下命令生成:
|
||||
@@ -101,8 +122,7 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| CORS_ALLOWED_ORIGINS | http://localhost:8001 | CORS 允许的源,多个用逗号分隔 |
|
||||
| TRUSTED_PROXY_COUNT | 0 | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 等反代后设为 `>=1` |
|
||||
| TRUSTED_PROXY_IPS | (空) | 可信代理 IP 白名单,逗号分隔 |
|
||||
| PROXY_NUM | 0 | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 等反代后设为 `1` |
|
||||
|
||||
:::tip 温馨提示
|
||||
本地开发时,`CORS_ALLOWED_ORIGINS` 需包含前端开发服务器地址(默认 `http://localhost:8001`)。如果有多个前端地址,用逗号分隔:
|
||||
@@ -130,31 +150,22 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001,http://192.168.1.100:8001
|
||||
邮件功能为预留扩展功能,暂未实现。
|
||||
:::
|
||||
|
||||
## 短信配置
|
||||
## 短信配置(阿里云)
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| SMS_URL | (空) | 短信服务 API 地址 |
|
||||
| SMS_API_KEY | (空) | 短信服务 API 密钥 |
|
||||
| SMS_SIGN | (空) | 短信签名 |
|
||||
| SMS_TEMPLATE_ID | (空) | 短信模板 ID |
|
||||
| SMS_PROVIDER | aliyun | 短信服务商(当前仅支持 aliyun) |
|
||||
| SMS_ALIYUN_ACCESS_KEY_ID | `<必填>` | 阿里云 AccessKey ID |
|
||||
| SMS_ALIYUN_ACCESS_KEY_SECRET | `<必填>` | 阿里云 AccessKey Secret |
|
||||
| SMS_ALIYUN_SIGN_NAME | `<必填>` | 短信签名(需在阿里云控制台审核通过) |
|
||||
| SMS_ALIYUN_ENDPOINT | dysmsapi.aliyuncs.com | 短信服务接入点 |
|
||||
| SMS_ALIYUN_CONNECT_TIMEOUT | 10000 | 连接超时(毫秒) |
|
||||
| SMS_ALIYUN_READ_TIMEOUT | 10000 | 读取超时(毫秒) |
|
||||
|
||||
:::tip 温馨提示
|
||||
短信功能为预留扩展功能,暂未实现。
|
||||
短信功能基于阿里云短信服务,需在阿里云控制台开通短信服务并获取 AccessKey。
|
||||
:::
|
||||
|
||||
## 请求限流配置
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| RATE_LIMIT_ENABLED | False | 是否启用请求限流。在 `.env.example` 中默认被注释,需手动取消注释并设为 `True` |
|
||||
| RATE_LIMIT_LIMIT | 100 | 窗口期内最大请求数 |
|
||||
| RATE_LIMIT_WINDOW_SECONDS | 60 | 限流窗口时间(秒) |
|
||||
| RATE_LIMIT_KEY_PREFIX | rl: | Redis 限流键前缀 |
|
||||
|
||||
:::tip 温馨提示
|
||||
请求限流功能默认关闭。启用后由 `src/middleware/rate_limit.py` 基于 Redis 滑动窗口计数器实现,按客户端 IP 维度统计。超出限制后直接返回 `{"code": 1, "msg": "请求过于频繁"}` 响应,不进入后续中间件链(避免 DoS 攻击时日志表与 DB 连接不降反增)。Redis 不可用时降级放行(fail-open)。生产环境建议启用此功能以防止 API 滥用。
|
||||
:::
|
||||
|
||||
## 环境变量配置示例
|
||||
|
||||
@@ -228,4 +239,4 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001 # 允许跨域访问的前端地址
|
||||
|
||||
## 总结
|
||||
|
||||
本章节汇总了项目所有可配置的环境变量,涵盖基础配置、数据库、Redis、JWT、验证码、跨域、邮件、短信和限流等功能模块。通过合理配置这些变量,可以灵活调整项目的运行参数,适应不同的开发、测试和生产环境需求。建议首次使用时以 `.env.example` 为模板,仅修改必需项,其余保持默认值即可快速启动项目。
|
||||
本章节汇总了项目所有可配置的环境变量,涵盖基础配置、数据库、Redis、JWT、验证码、跨域、邮件、短信等功能模块。通过合理配置这些变量,可以灵活调整项目的运行参数,适应不同的开发、测试和生产环境需求。建议首次使用时以 `.env.example` 为模板,仅修改必需项,其余保持默认值即可快速启动项目。
|
||||
|
||||
@@ -199,7 +199,7 @@ SHOW DATABASES;
|
||||
|
||||
## 安装 Redis
|
||||
|
||||
`Redis` 是必需的缓存服务,用于验证码存储、JWT 令牌黑名单、登录限流等功能。要求 `3.2` 或更高版本。
|
||||
`Redis` 是必需的缓存服务,用于验证码存储、JWT 令牌黑名单、登录锁定等功能。要求 `3.2` 或更高版本。
|
||||
|
||||
+ 下载软件
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ ui/
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面视图
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
│ └── main.ts # 入口文件
|
||||
├── .env.development # 开发环境变量
|
||||
├── .env.production # 生产环境变量
|
||||
├── vite.config.ts # Vite 构建配置
|
||||
|
||||
@@ -15,8 +15,8 @@ Flask 使用 `Blueprint` 机制实现模块化路由注册。每个业务模块
|
||||
|
||||
from flask import Blueprint, request
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.core import response as R
|
||||
from src.common.utils.request import parse_request_body, parse_batch_ids
|
||||
|
||||
# 定义 Blueprint
|
||||
# 参数说明:
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
岗位模型定义,包含名称、排序、状态等属性。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Integer, text
|
||||
from sqlalchemy import text
|
||||
from src.core.database import db
|
||||
|
||||
from src.common.base_db import BaseDB
|
||||
from src.common.base_model import BaseModel
|
||||
@@ -36,11 +37,11 @@ class Position(BaseModel, BaseDB):
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
# 岗位名称
|
||||
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
|
||||
# 岗位状态:1-在用 2-停用
|
||||
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
|
||||
name = db.Column(db.String(255), nullable=False, index=True, comment="岗位名称")
|
||||
# 岗位状态:0-停用 1-启用
|
||||
status = db.Column(db.Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:0-停用 1-启用")
|
||||
# 岗位排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
|
||||
sort = db.Column(db.Integer, default=0, server_default=text('0'), comment="岗位排序")
|
||||
|
||||
# ============================================================
|
||||
# 内置方法
|
||||
|
||||
@@ -56,7 +56,7 @@ flask db upgrade
|
||||
|
||||
### Q6: 批量删除报 500 错误
|
||||
|
||||
**原因**:`parse_id_list` 函数未正确导入或使用。
|
||||
**原因**:`parse_batch_ids` 函数未正确导入或使用。
|
||||
|
||||
**解决**:确保正确导入并使用:
|
||||
|
||||
|
||||
@@ -10,31 +10,147 @@ src/modules/system/position/models.py
|
||||
|
||||
## 完整代码
|
||||
|
||||
以下为 `src/modules/system/position/models.py` 的核心代码(省略版权头):
|
||||
|
||||
```python
|
||||
from sqlalchemy import Column, String, Integer, text
|
||||
"""
|
||||
岗位模型模块
|
||||
=============================================================
|
||||
|
||||
提供岗位管理的数据模型定义,用于定义员工岗位信息。
|
||||
|
||||
企业级特性:
|
||||
1. 使用枚举类管理状态常量,确保类型安全
|
||||
2. 状态文本自动转换,便于前端展示
|
||||
3. 便捷的查询方法,提高开发效率
|
||||
4. 继承 BaseModel 和 BaseDB,复用通用功能
|
||||
5. __init__ 由 BaseModel 统一管理,无需重复定义
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Optional, List
|
||||
|
||||
from src.common.base_db import BaseDB
|
||||
from src.common.base_model import BaseModel
|
||||
from config.database import DB_PREFIX
|
||||
from config import DB_PREFIX
|
||||
from sqlalchemy import text
|
||||
from src.core.database import db
|
||||
from src.common.utils.request import like_contains
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位状态枚举
|
||||
# ============================================================
|
||||
|
||||
class PositionStatus(IntEnum):
|
||||
"""
|
||||
岗位状态枚举
|
||||
|
||||
使用 IntEnum 确保类型安全,避免魔法数字。
|
||||
可直接用于数据库查询和比较。
|
||||
"""
|
||||
DISABLED = 0 # 停用
|
||||
ENABLED = 1 # 启用
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位模型
|
||||
# ============================================================
|
||||
|
||||
class Position(BaseModel, BaseDB):
|
||||
"""岗位模型类"""
|
||||
# 表名配置
|
||||
"""
|
||||
岗位模型
|
||||
|
||||
用于存储员工岗位信息,如:技术总监、产品经理等。
|
||||
继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。
|
||||
"""
|
||||
|
||||
# 表名:使用配置前缀 + 模型名
|
||||
__tablename__ = DB_PREFIX + "position"
|
||||
__table_comment__ = "岗位表"
|
||||
|
||||
# 业务字段定义
|
||||
# 岗位名称
|
||||
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
|
||||
# 岗位状态:1-在用 2-停用
|
||||
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
|
||||
# 岗位排序
|
||||
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
|
||||
# ============================================================
|
||||
# 字段定义
|
||||
# ============================================================
|
||||
|
||||
def __repr__(self):
|
||||
"""返回岗位名称作为字符串表示"""
|
||||
return '岗位:{}'.format(self.name)
|
||||
# 岗位名称:必填字段,最长255字符
|
||||
name = db.Column(
|
||||
db.String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="岗位名称"
|
||||
)
|
||||
|
||||
# 岗位状态:使用枚举确保类型安全
|
||||
# 默认值为停用,创建后需手动启用
|
||||
status = db.Column(
|
||||
db.Integer,
|
||||
index=True,
|
||||
default=PositionStatus.DISABLED,
|
||||
server_default=text('0'),
|
||||
comment="岗位状态:0-停用 1-启用"
|
||||
)
|
||||
|
||||
# 岗位排序:数值越小越靠前
|
||||
sort = db.Column(
|
||||
db.Integer,
|
||||
default=0,
|
||||
server_default=text('0'),
|
||||
comment="岗位排序"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 注意:__init__ 方法在 BaseModel 中已实现,无需重复定义
|
||||
# BaseModel 已处理驼峰转换、字段过滤等逻辑
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# 便捷属性
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
"""获取岗位状态文本:将数字状态转换为可读的中文文本"""
|
||||
status_map = {
|
||||
PositionStatus.DISABLED: '停用',
|
||||
PositionStatus.ENABLED: '启用'
|
||||
}
|
||||
return status_map.get(self.status, '未知')
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""判断岗位是否启用"""
|
||||
return self.status == PositionStatus.ENABLED
|
||||
|
||||
@property
|
||||
def is_disabled(self) -> bool:
|
||||
"""判断岗位是否停用"""
|
||||
return self.status == PositionStatus.DISABLED
|
||||
|
||||
# ============================================================
|
||||
# 自定义查询方法
|
||||
# ============================================================
|
||||
|
||||
@classmethod
|
||||
def get_enabled(cls) -> List['Position']:
|
||||
"""获取所有启用的岗位,按排序值升序排列"""
|
||||
return cls.query.filter(
|
||||
cls.is_delete == 0,
|
||||
cls.status == PositionStatus.ENABLED
|
||||
).order_by(cls.sort.asc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name: Optional[str]) -> Optional['Position']:
|
||||
"""根据岗位名称精确查询"""
|
||||
if not name:
|
||||
return None
|
||||
return cls.query.filter(
|
||||
cls.name == name,
|
||||
cls.is_delete == 0
|
||||
).first()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'岗位:{self.name}'
|
||||
```
|
||||
|
||||
## 代码解析
|
||||
@@ -48,7 +164,34 @@ class Position(BaseModel, BaseDB):
|
||||
| 基类 | 来源 | 提供能力 |
|
||||
|:----|:----|:----|
|
||||
| `BaseModel` | `src/common/base_model.py` | 通用字段:id、create_user、create_time、update_user、update_time、is_delete |
|
||||
| `BaseDB` | `src/common/base_db.py` | `save()` 持久化方法、`delete()` 删除方法、`to_dict()` 序列化方法 |
|
||||
| `BaseDB` | `src/common/base_db.py` | `save()` 持久化方法(仅 flush,不 commit)、`to_dict()` 序列化方法 |
|
||||
|
||||
### 状态枚举
|
||||
|
||||
项目推荐使用 `IntEnum` 管理状态常量,避免魔法数字:
|
||||
|
||||
```python
|
||||
class PositionStatus(IntEnum):
|
||||
DISABLED = 0 # 停用
|
||||
ENABLED = 1 # 启用
|
||||
```
|
||||
|
||||
| 值 | 含义 |
|
||||
|:----|:----|
|
||||
| 0 | 停用 / 禁用 |
|
||||
| 1 | 启用 / 在用 |
|
||||
|
||||
软删除字段 `is_delete` 由基类提供:`0` = 正常,`1` = 已删除。
|
||||
|
||||
### 字段定义风格
|
||||
|
||||
项目使用 `db.Column`(从 `src.core.database` 导入的 `db` 实例),而非直接导入 `Column`:
|
||||
|
||||
```python
|
||||
from src.core.database import db
|
||||
|
||||
name = db.Column(db.String(255), nullable=False, index=True, comment="岗位名称")
|
||||
```
|
||||
|
||||
### 表名配置
|
||||
|
||||
@@ -56,71 +199,17 @@ class Position(BaseModel, BaseDB):
|
||||
__tablename__ = DB_PREFIX + "position"
|
||||
```
|
||||
|
||||
`DB_PREFIX` 从 `.env` 读取(默认 `flask_`),最终表名为 `flask_position`。所有业务表都使用此前缀,便于区分框架表与业务表。
|
||||
|
||||
### 字段定义
|
||||
|
||||
```python
|
||||
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|:----|:----|
|
||||
| `String(255)` | 字段类型,VARCHAR(255) |
|
||||
| `nullable=False` | 非空约束 |
|
||||
| `index=True` | 创建索引,加速查询 |
|
||||
| `comment` | 字段注释,同步到数据库 |
|
||||
|
||||
```python
|
||||
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
|
||||
```
|
||||
|
||||
`default=0` 是 Python 端默认值,`server_default=text('0')` 是数据库端默认值。两者都设置可确保无论通过 ORM 还是直接 SQL 插入,都能正确赋默认值。
|
||||
|
||||
### 枚举值约定
|
||||
|
||||
项目中状态字段统一使用整数枚举:
|
||||
|
||||
| 值 | 含义 |
|
||||
|:----|:----|
|
||||
| 1 | 启用 / 在用 |
|
||||
| 2 | 停用 / 禁用 |
|
||||
|
||||
软删除字段 `is_delete` 由基类提供:`0` = 正常,`1` = 已删除。
|
||||
|
||||
## 数据表 DDL(参考)
|
||||
|
||||
模型定义对应的 MySQL 建表语句:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `flask_position` (
|
||||
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '岗位名称',
|
||||
`status` int DEFAULT '0' COMMENT '岗位状态:1-在用 2-停用',
|
||||
`sort` int DEFAULT '0' COMMENT '岗位排序',
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`create_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
|
||||
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
|
||||
`update_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
|
||||
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
|
||||
`is_delete` int DEFAULT '0' COMMENT '删除标识:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_flask_position_name` (`name`) USING BTREE,
|
||||
KEY `idx_flask_position_status` (`status`) USING BTREE,
|
||||
KEY `idx_flask_position_is_delete` (`is_delete`) USING BTREE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='岗位表';
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
实际开发中不需要手写 DDL。模型定义完成后,框架会通过 `Base.metadata.create_all()` 自动建表,或通过 `flask db migrate` 生成迁移脚本。
|
||||
:::
|
||||
`DB_PREFIX` 从 `.env` 读取(默认 `flask_`),最终表名为 `flask_position`。
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **业务字段仅定义差异部分**:通用字段(id、时间、软删除等)由基类自动提供
|
||||
2. **表名必须加 `DB_PREFIX` 前缀**:保持命名一致性
|
||||
3. **合理使用索引**:查询频繁的字段(如 name、status)设置 `index=True`
|
||||
4. **同时设置 `default` 和 `server_default`**:确保 ORM 和直 SQL 两种场景都能正确赋默认值
|
||||
3. **使用 `db.Column` 而非直接导入 `Column`**:保持与项目一致的导入风格
|
||||
4. **状态字段使用 IntEnum**:避免魔法数字,提高代码可读性
|
||||
5. **同时设置 `default` 和 `server_default`**:确保 ORM 和直 SQL 两种场景都能正确赋默认值
|
||||
6. **`__init__` 由 BaseModel 统一管理**:无需在子类中重复定义
|
||||
|
||||
## 总结
|
||||
|
||||
数据模型继承 `BaseModel`(公共字段)和 `BaseDB`(save/delete 方法),表名必须加 `DB_PREFIX` 前缀。查询频繁的字段设置索引,同时设置 `default` 和 `server_default` 确保 ORM 和直 SQL 两种场景都能正确赋默认值。
|
||||
数据模型继承 `BaseModel`(公共字段)和 `BaseDB`(save 方法),表名必须加 `DB_PREFIX` 前缀。状态字段使用 `IntEnum` 枚举类管理,字段定义使用 `db.Column` 风格。`__init__` 由 BaseModel 统一处理驼峰转换和字段过滤,子类无需重复定义。
|
||||
|
||||
@@ -10,115 +10,173 @@ src/modules/system/position/schema.py
|
||||
|
||||
## 完整代码
|
||||
|
||||
以下为 `src/modules/system/position/schema.py` 的核心代码(省略版权头):
|
||||
|
||||
```python
|
||||
from wtforms import StringField, IntegerField
|
||||
"""
|
||||
岗位表单验证模块
|
||||
=============================================================
|
||||
|
||||
提供岗位相关的表单验证功能,包括:
|
||||
- 岗位添加/更新表单验证
|
||||
- 岗位状态设置表单验证
|
||||
"""
|
||||
|
||||
from wtforms import ValidationError, StringField, IntegerField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
from src.common.forms.base_form import BaseForm
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位表单类
|
||||
# 岗位表单验证(添加和更新共用)
|
||||
# ============================================================
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
"""
|
||||
岗位表单验证类
|
||||
|
||||
用于添加和更新岗位时的数据验证。
|
||||
添加时 id 为 None,更新时 id 必须有值。
|
||||
"""
|
||||
|
||||
# 岗位ID:更新时必填,添加时可选
|
||||
id = IntegerField(
|
||||
label='岗位ID',
|
||||
validators=[
|
||||
Optional(), # 允许为空(添加时)
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
],
|
||||
description='岗位唯一标识,更新时必填,添加时自动生成'
|
||||
)
|
||||
|
||||
# 岗位名称:必填,最长150字符,不可重复
|
||||
name = StringField(
|
||||
label='岗位名称',
|
||||
validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
Length(max=150, message='岗位名称长度不得超过150个字符')
|
||||
],
|
||||
description='岗位显示名称,如:技术总监、产品经理'
|
||||
)
|
||||
|
||||
# 岗位状态:必填,1-在用,2-停用
|
||||
status = IntegerField(
|
||||
label='岗位状态',
|
||||
validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
|
||||
],
|
||||
description='岗位状态:1-在用,2-停用'
|
||||
)
|
||||
|
||||
# 岗位排序:可选,0-99999之间的整数
|
||||
sort = IntegerField(
|
||||
label='岗位排序',
|
||||
validators=[
|
||||
Optional(),
|
||||
NumberRange(min=0, max=99999, message='岗位排序值在0~99999之间')
|
||||
],
|
||||
description='排序值,数值越小越靠前'
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 自定义验证方法
|
||||
# ============================================================
|
||||
|
||||
def validate_name(self, field):
|
||||
"""
|
||||
自定义验证:岗位名称不能重复
|
||||
|
||||
验证逻辑:
|
||||
1. 添加时:检查数据库中是否存在同名记录
|
||||
2. 更新时:排除当前记录自身,检查其他记录是否同名
|
||||
"""
|
||||
if not field.data:
|
||||
return
|
||||
|
||||
# 获取当前记录的ID(更新时存在,添加时为None)
|
||||
current_id = self.id.data if self.id.data else None
|
||||
|
||||
# 唯一性校验(软删过滤 + 更新时排除自身)
|
||||
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
|
||||
raise ValidationError("岗位名称已存在")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置状态表单类
|
||||
# 状态设置表单
|
||||
# ============================================================
|
||||
|
||||
class PositionStatusForm(BaseForm):
|
||||
"""岗位状态更新表单"""
|
||||
id = IntegerField('岗位ID', validators=[
|
||||
"""岗位状态设置表单验证类"""
|
||||
|
||||
id = IntegerField(
|
||||
label='岗位ID',
|
||||
validators=[
|
||||
DataRequired(message='岗位ID不能为空'),
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
],
|
||||
description='要修改状态的岗位ID'
|
||||
)
|
||||
|
||||
status = IntegerField(
|
||||
label='岗位状态',
|
||||
validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
|
||||
],
|
||||
description='目标状态:1-在用,2-停用'
|
||||
)
|
||||
```
|
||||
|
||||
## 代码解析
|
||||
|
||||
### 基类选择
|
||||
|
||||
| 基类 | 用途 | 包含字段 |
|
||||
| 基类 | 用途 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `BaseForm` | 创建/编辑通用表单 | 自带可选 `id` 字段(`Optional`),编辑时传入 |
|
||||
| 特定操作表单 | 无额外字段,按需定义 |
|
||||
| `BaseForm` | 创建/编辑通用表单 | 继承 `flask_wtf.FlaskForm`,支持 ArrayField 处理 |
|
||||
| `PositionStatusForm` | 状态更新专用 | 只包含 `id` 和 `status` 两个字段 |
|
||||
|
||||
创建和编辑共用 `PositionForm`:创建时不传 `id`,编辑时传入 `id`。`BaseForm` 的 `id` 字段定义为 `IntegerField('ID', validators=[Optional()])`,创建时可省略。
|
||||
### id 字段
|
||||
|
||||
创建和编辑共用 `PositionForm`:创建时不传 `id`,编辑时传入 `id`。`id` 字段定义为 `Optional()`,创建时可省略:
|
||||
|
||||
```python
|
||||
id = IntegerField(
|
||||
label='岗位ID',
|
||||
validators=[Optional(), NumberRange(min=1, message='岗位ID必须大于0')],
|
||||
description='岗位唯一标识,更新时必填,添加时自动生成'
|
||||
)
|
||||
```
|
||||
|
||||
### Validator 约束
|
||||
|
||||
```python
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
```
|
||||
| Validator | 含义 | 示例 |
|
||||
|:----|:----|:----|
|
||||
| `DataRequired` | 必填字段,不允许空值 | `DataRequired(message='岗位名称不能为空')` |
|
||||
| `Length(max=150)` | 字符串长度限制 | `Length(max=150, message='...')` |
|
||||
| `NumberRange(min=1, max=2)` | 数值范围限制 | `NumberRange(min=1, max=2, message='...')` |
|
||||
| `Optional` | 可选字段 | `Optional()` |
|
||||
|
||||
| Validator | 含义 |
|
||||
|:----|:----|
|
||||
| `DataRequired` | 必填字段,不允许空值 |
|
||||
| `Length(min=1, max=150)` | 字符串长度限制 |
|
||||
| `NumberRange(min=1, max=2)` | 数值范围限制 |
|
||||
| `Optional` | 可选字段 |
|
||||
### 自定义校验器
|
||||
|
||||
通过定义 `validate_{field_name}` 方法实现自定义校验:
|
||||
|
||||
```python
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
def validate_name(self, field):
|
||||
"""自定义验证:岗位名称不能重复"""
|
||||
if not field.data:
|
||||
return
|
||||
current_id = self.id.data if self.id.data else None
|
||||
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
|
||||
raise ValidationError("岗位名称已存在")
|
||||
```
|
||||
|
||||
| 参数 | 含义 |
|
||||
|:----|:----|
|
||||
| `min=1` | 最小值(大于等于) |
|
||||
| `max=2` | 最大值(小于等于) |
|
||||
| `message` | 校验失败时的错误信息 |
|
||||
|
||||
```python
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
```
|
||||
|
||||
排序字段允许 0 到 99999 的范围。
|
||||
|
||||
### 状态更新单独建类
|
||||
|
||||
```python
|
||||
# ============================================================
|
||||
# 设置状态表单类
|
||||
# ============================================================
|
||||
class PositionStatusForm(BaseForm):
|
||||
"""岗位状态更新表单"""
|
||||
id = IntegerField('岗位ID', validators=[
|
||||
DataRequired(message='岗位ID不能为空'),
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
```
|
||||
|
||||
状态更新接口只需要 `id` 和 `status` 两个字段,与创建/编辑表单分开定义,接口语义更清晰。
|
||||
- 方法名必须为 `validate_{field_name}`
|
||||
- 校验失败抛出 `ValidationError(错误消息)`
|
||||
- 唯一性校验通过 `Repository.exists_by(filters, exclude_id)` 实现,自动过滤软删除记录
|
||||
|
||||
## 如何在 Router 中使用
|
||||
|
||||
@@ -141,7 +199,7 @@ def add():
|
||||
return R.ok(msg="添加成功")
|
||||
```
|
||||
|
||||
`parse_request_body` 函数自动解析请求体 JSON。校验失败时,Service 层抛出 `BizError`,由异常处理器自动返回标准错误响应:
|
||||
校验失败时,由框架自动返回标准错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -151,36 +209,15 @@ def add():
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义校验器
|
||||
|
||||
如果需要自定义校验逻辑,可以在表单类中定义 `validate_` 开头的方法:
|
||||
|
||||
```python
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(max=150, message='岗位名称长度不得超过150个字符')
|
||||
])
|
||||
|
||||
def validate_name(self, field):
|
||||
"""自定义校验:检查岗位名称是否已存在"""
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
if not field.data:
|
||||
return
|
||||
current_id = self.id.data if self.id.data else None
|
||||
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
|
||||
raise ValidationError('岗位名称已存在')
|
||||
```
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,利用其内置的可选 `id` 字段
|
||||
2. **特殊操作单独建类**:如状态更新、排序调整等,只包含必要的字段
|
||||
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,显式定义 `id` 字段(`Optional`)
|
||||
2. **特殊操作单独建类**:如状态更新,只包含必要的字段
|
||||
3. **合理设置 Validator**:`DataRequired` / `Length` / `NumberRange` 等,让框架自动校验
|
||||
4. **message 必填**:用于返回给前端的错误信息,方便调试
|
||||
5. **唯一性校验**:通过自定义 `validate_` 方法实现,使用 Repository 层的 `exists_by(filters, exclude_id)` 方法
|
||||
6. **可选字段用 `Optional()`**:如 `sort` 排序字段,不传时使用数据库默认值
|
||||
|
||||
## 总结
|
||||
|
||||
WTForms Schema 通过 `BaseForm` 继承公共字段(id 等),使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。唯一性校验使用 `Repository.exists_by({'field': value}, exclude_id=current_id)` 方法,自动过滤软删除记录并支持排除自身。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。
|
||||
WTForms Schema 通过 `BaseForm` 继承公共能力,使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。唯一性校验使用 `Repository.exists_by({'field': value}, exclude_id=current_id)` 方法,自动过滤软删除记录并支持排除自身。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。
|
||||
|
||||
@@ -192,16 +192,23 @@ def register_v1(app):
|
||||
|
||||
```python
|
||||
# src/middleware/__init__.py
|
||||
from src.middleware.auth import register_auth
|
||||
from src.middleware.demo import register_demo
|
||||
from src.middleware.auth import check_login, is_whitelisted, register_middleware
|
||||
from src.middleware.demo import check_demo
|
||||
from src.middleware.transaction import register_transaction, transactional, TransactionContext
|
||||
from src.middleware.operation_log import register_operation_log, operation_log
|
||||
from src.middleware.errors import register_error_handlers
|
||||
```
|
||||
|
||||
各中间件在 `src/app.py` 的 `create_app()` 中按顺序注册:
|
||||
|
||||
def register_middleware(app):
|
||||
"""注册中间件"""
|
||||
register_auth(app) # 登录校验 + 白名单
|
||||
register_demo(app) # 演示模式拦截
|
||||
register_error_handlers(app) # 全局异常处理
|
||||
```python
|
||||
# src/app.py - create_app()
|
||||
register_operation_log(app) # 操作日志记录
|
||||
register_transaction(app) # AOP 事务管理
|
||||
register_v1(app) # 路由注册
|
||||
register_extends(app) # 扩展初始化
|
||||
register_middleware(app) # 登录校验 + 白名单
|
||||
register_error_handlers(app) # 全局异常处理
|
||||
```
|
||||
|
||||
中间件执行顺序(请求进入时):
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ BaseRepository (src/common/base_repository.py) │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 查询: get_by_id / get_by_ids / get_one / get_all / count │
|
||||
│ 分页: paginate → (items, total) │
|
||||
│ 查询: get_by_id / get_by_ids / get_all / first_by / count_by │
|
||||
│ 分页: find_page / find_list │
|
||||
│ 写操作: create / update / batch_delete │
|
||||
│ 条件: filter (原始) / filter_by (等值+软删) │
|
||||
│ 条件: exists / exists_by (等值+软删) │
|
||||
│ 软删除: 等值方法自动追加 is_delete=0 │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
@@ -104,29 +104,39 @@
|
||||
### 通用字段
|
||||
|
||||
```python
|
||||
class BaseModel(Base):
|
||||
# src/common/base_model.py
|
||||
from src.core.database import db
|
||||
from sqlalchemy import text, func
|
||||
|
||||
def now():
|
||||
"""返回当前时间(北京时间)"""
|
||||
from datetime import datetime
|
||||
return datetime.now()
|
||||
|
||||
class BaseModel(db.Model):
|
||||
__abstract__ = True # 抽象基类,不会在数据库中创建对应的表
|
||||
# 主键:自增整数,所有业务表的唯一标识
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
# 创建人:记录该条记录的创建者用户名,由 Service 层自动填充
|
||||
create_user = Column(String(50), nullable=True, comment="创建人用户名")
|
||||
# 创建时间:记录创建时的 UTC 时间,数据库默认自动生成
|
||||
create_time = Column(DateTime, default=_UTC_NOW, server_default=func.now(), comment="创建时间")
|
||||
# 更新人:记录最后修改该条记录的用户名,由 Service 层自动填充
|
||||
update_user = Column(String(50), nullable=True, comment="最后更新人用户名")
|
||||
# 更新时间:记录最后修改时间,更新时自动刷新,数据库默认自动生成
|
||||
update_time = Column(DateTime, default=_UTC_NOW, onupdate=_UTC_NOW, server_default=func.now(), comment="最后更新时间")
|
||||
# 逻辑删除标记:0 表示正常记录,1 表示已删除,物理删除不使用,保证数据可恢复;添加索引以提升查询性能,Repository 层默认过滤 is_delete=0
|
||||
is_delete = Column(Integer, default=0, server_default=text('0'), index=True, comment="逻辑删除标识:0-正常,1-已删除")
|
||||
|
||||
# 主键:自增整数
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
# 创建人:记录创建者,由业务层通过 get_realname() 设置
|
||||
create_user = db.Column(db.String(64), default='', server_default=text("''"), comment="创建人")
|
||||
# 创建时间:记录创建时间,创建后不再变化
|
||||
create_time = db.Column(db.DateTime, default=now, server_default=func.now(), comment="创建时间")
|
||||
# 更新人:记录最后修改者
|
||||
update_user = db.Column(db.String(64), default='', server_default=text("''"), comment="更新人")
|
||||
# 更新时间:记录最后修改时间,更新时自动刷新
|
||||
update_time = db.Column(db.DateTime, default=now, onupdate=now, server_default=func.now(), comment="最后更新时间")
|
||||
# 逻辑删除标记:0=正常,1=已删除
|
||||
is_delete = db.Column(db.Integer, default=0, server_default=text('0'), index=True, comment="逻辑删除标识:0-正常,1-已删除")
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `id` | Integer, PK, AI | 主键自增 |
|
||||
| `create_user` | String(50) | 创建人(realname) |
|
||||
| `create_time` | DateTime | 创建时间(UTC,server_default) |
|
||||
| `update_user` | String(50) | 更新人(realname) |
|
||||
| `update_time` | DateTime | 更新时间(UTC,onupdate 自动触发) |
|
||||
| `create_user` | String(64) | 创建人(realname),默认空字符串 |
|
||||
| `create_time` | DateTime | 创建时间,server_default=func.now() |
|
||||
| `update_user` | String(64) | 更新人(realname),默认空字符串 |
|
||||
| `update_time` | DateTime | 更新时间,onupdate 自动触发 |
|
||||
| `is_delete` | Integer | 软删除标识:0=正常,1=已删除 |
|
||||
|
||||
### 表注释钩子
|
||||
@@ -203,30 +213,29 @@ class BaseDB:
|
||||
# | 说明: 统一处理前端传入的空字符串 id → None 等通用校验
|
||||
# +======================================================================
|
||||
|
||||
from wtforms import Form, IntegerField
|
||||
from wtforms.validators import Optional
|
||||
from flask_wtf import FlaskForm
|
||||
from src.common.forms.fields import ArrayField
|
||||
|
||||
|
||||
class BaseForm(Form):
|
||||
class BaseForm(FlaskForm):
|
||||
"""
|
||||
表单基类
|
||||
自定义表单基类,增强对 JSON 数组的支持
|
||||
|
||||
所有创建/编辑表单应继承此类,自动获得:
|
||||
1. id 字段:Optional,前端传空字符串时自动转为 None
|
||||
重写 process 方法,确保 ArrayField 能正确处理数组数据。
|
||||
"""
|
||||
|
||||
id = IntegerField('ID', validators=[Optional()])
|
||||
def process(self, formdata=None, obj=None, data=None, **kwargs):
|
||||
"""重写 process 方法,支持 ArrayField 处理数组数据"""
|
||||
# ... 处理逻辑
|
||||
```
|
||||
|
||||
### 代码解析
|
||||
|
||||
**可选 `id` 字段**:
|
||||
**继承 `FlaskForm`**:`BaseForm` 继承自 `flask_wtf.FlaskForm`(而非 `wtforms.Form`),获得 CSRF 保护等 Flask 集成能力。
|
||||
|
||||
```python
|
||||
id = IntegerField('ID', validators=[Optional()])
|
||||
```
|
||||
**ArrayField 支持**:重写 `process` 方法,确保 `ArrayField` 能正确处理前端传入的 JSON 数组数据。
|
||||
|
||||
`id` 默认为 `None`。创建记录时前端不传 `id`,WTForms 自动赋默认值;编辑记录时前端传入 `id`,Service 层据此判断是新增还是更新。
|
||||
**id 字段由子类自行定义**:`BaseForm` 本身不定义 `id` 字段,各业务表单类根据需要自行声明(通常为 `IntegerField` + `Optional()`)。
|
||||
|
||||
### 子类示例
|
||||
|
||||
@@ -301,7 +310,7 @@ class PositionForm(BaseForm):
|
||||
### 软删除约定
|
||||
|
||||
:::warning 重要设计约定
|
||||
- **等值便捷方法**(get_by_id / get_one / get_all / count / filter_by / exists / exists_by_field / paginate)自动追加 `is_delete=0` 过滤,调用方无需关心
|
||||
- **等值便捷方法**(get_by_id / get_by_ids / get_all / first_by / count_by / exists / exists_by / find_page / find_list)自动追加 `is_delete=0` 过滤,调用方无需关心
|
||||
- **原始出口** `filter(*criterion)` **不做**软删过滤,由调用方自行追加 `Model.is_delete == 0`,用于 `in_` / `!=` / `and_` / 区间等复杂条件
|
||||
:::
|
||||
|
||||
@@ -352,13 +361,17 @@ WTForms Schema 可能用 camelCase(如 `parentId`),而模型列是 snake_c
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|:---|:---|:---|
|
||||
| `get_page()` | 分页查询 | `R.ok(data=[...], count=total)` |
|
||||
| `get_list()` | 不分页查询 | `R.ok(data=[...])` |
|
||||
| `get_page(params)` | 分页查询 | `dict`(含 records/total 等分页数据) |
|
||||
| `get_list(params)` | 不分页查询 | `list`(记录列表) |
|
||||
| `get_detail(obj_id)` | 详情查询 | `dict` 或 `None` |
|
||||
| `add(data)` | 新增记录 | `R.ok(msg='添加成功')` |
|
||||
| `update(data)` | 编辑记录 | 含表单校验 + 存在性校验 |
|
||||
| `remove(ids)` | 批量软删除 | 逗号分隔 ID 字符串 |
|
||||
| `update_status(data)` | 状态切换 | 含状态表单校验 |
|
||||
| `add(data)` | 新增记录 | `None`(成功无返回,失败 raise BizError) |
|
||||
| `update(data)` | 编辑记录 | `None`(成功无返回,失败 raise BizError) |
|
||||
| `remove(ids)` | 批量软删除 | `int`(删除数量) |
|
||||
| `update_status(data)` | 状态切换 | `None`(成功无返回,失败 raise BizError) |
|
||||
|
||||
:::warning Service 不返回 R 对象
|
||||
Service 层入参为数据字典、出参为纯数据(或 None),业务失败通过 `raise BizError` 表达。Router 层负责封装 `R.ok()` / `R.failed()` 响应。
|
||||
:::
|
||||
|
||||
### 钩子方法
|
||||
|
||||
|
||||
@@ -181,7 +181,6 @@ def get_redis():
|
||||
| **JWT 黑名单** | String | `jwt:blacklist:{token_hash}` | Token 剩余有效期 | 登出时将 Token 加入黑名单 |
|
||||
| **登录失败锁** | String | `login:lock:{username}` | 600 秒 | 连续 5 次失败后锁定 |
|
||||
| **登录失败计数** | String (Lua) | `login:fail:{username}` | 300 秒 | 原子递增 + 首次设置 TTL |
|
||||
| **滑动窗口限流** | String (Lua) | `rate:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器 |
|
||||
| **数据字典缓存** | String (JSON) | `dict:{dict_code}` | 300 秒 | 字典项 {value: label} 映射 |
|
||||
| **权限列表缓存** | String (JSON) | `perm:user:{user_id}` | 变更时失效 | 用户权限字符串列表 |
|
||||
| **在线用户** | String | `online:user:{user_id}` | Token 有效期 | 在线用户 Token 存储 |
|
||||
@@ -207,27 +206,9 @@ def incr_with_expire(redis_client, key: str, seconds: int) -> int:
|
||||
```
|
||||
|
||||
:::tip 为什么需要 Lua 脚本?
|
||||
`incr` 和 `expire` 分开执行时,两步之间若进程崩溃,key 会以无 TTL 的状态永久残留,导致基于 Redis 的限流/登录锁定永久封禁。Lua 脚本保证原子性,消除此风险。
|
||||
`incr` 和 `expire` 分开执行时,两步之间若进程崩溃,key 会以无 TTL 的状态永久残留。Lua 脚本保证原子性,消除此风险。
|
||||
:::
|
||||
|
||||
#### 滑动窗口限流
|
||||
|
||||
```python
|
||||
_SLIDING_WINDOW_LUA = """
|
||||
local cur = redis.call('incr', KEYS[1])
|
||||
if cur == 1 then
|
||||
redis.call('expire', KEYS[1], ARGV[1])
|
||||
end
|
||||
local prev = tonumber(redis.call('get', KEYS[2]) or '0')
|
||||
local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[2]) * tonumber(ARGV[1])
|
||||
local f = elapsed / tonumber(ARGV[1])
|
||||
if f < 0 then f = 0 elseif f > 1 then f = 1 end
|
||||
local effective = prev * (1 - f) + cur
|
||||
return effective
|
||||
"""
|
||||
```
|
||||
|
||||
滑动窗口限流通过当前窗口与上一窗口的计数按时间比例加权,消除固定窗口边界处的 2x 突发问题。
|
||||
|
||||
## 缓存策略对比
|
||||
|
||||
@@ -236,7 +217,7 @@ return effective
|
||||
| **Cache-Aside** | 先查缓存,未命中查 DB,写回缓存 | 读多写少 | 数据字典、权限列表 |
|
||||
| **Write-Through** | 写操作同时更新缓存 | 数据一致性要求高 | 字典项变更时主动失效 |
|
||||
| **Write-Behind** | 写操作只更新缓存,异步写 DB | 写入性能要求高 | 未使用 |
|
||||
| **TTL 过期** | 缓存自动过期 | 允许短暂不一致 | 登录锁、限流计数 |
|
||||
| **TTL 过期** | 缓存自动过期 | 允许短暂不一致 | 登录锁 |
|
||||
| **主动失效** | 数据变更时主动清除缓存 | 数据一致性要求高 | 字典缓存、权限缓存 |
|
||||
|
||||
## 缓存失效策略
|
||||
@@ -263,7 +244,6 @@ invalidate_perm_cache(user_id)
|
||||
```
|
||||
数据字典缓存:TTL 300 秒(5 分钟)
|
||||
登录失败计数:TTL 300 秒(5 分钟)
|
||||
滑动窗口限流:TTL 2 倍窗口时长
|
||||
验证码:TTL 120 秒(2 分钟)
|
||||
```
|
||||
|
||||
|
||||
@@ -21,13 +21,12 @@ src/
|
||||
│ ├── response.py # 统一响应:R.ok() / R.failed() / R.page()
|
||||
│ ├── exceptions.py # 自定义异常:BizError
|
||||
│ ├── permission.py # 权限装饰器:check_permission
|
||||
│ ├── logger.py # 日志配置:结构化日志、文件输出
|
||||
│ ├── mail.py # 邮件发送
|
||||
│ └── scheduler.py # 定时任务调度器
|
||||
│
|
||||
├── middleware/ # HTTP 中间件层
|
||||
│ ├── __init__.py # 统一导出
|
||||
│ ├── auth.py # JWT 认证中间件:login_required
|
||||
│ ├── cors.py # CORS 跨域中间件
|
||||
│ ├── demo.py # 演示模式中间件:check_demo
|
||||
│ ├── errors.py # 全局异常处理
|
||||
│ ├── operation_log.py # 操作日志中间件
|
||||
@@ -57,7 +56,7 @@ src/
|
||||
│ ├── json.py # JSON 工具
|
||||
│ ├── model_utils.py # 模型工具
|
||||
│ ├── regular.py # 正则工具
|
||||
│ ├── request.py # 请求工具:parse_request_body / parse_id_list
|
||||
│ ├── request.py # 请求工具:parse_request_body / parse_batch_ids
|
||||
│ ├── rich_text.py # 富文本处理
|
||||
│ ├── time_utils.py # 时间工具
|
||||
│ └── user_agent.py # User-Agent 解析
|
||||
|
||||
@@ -139,8 +139,8 @@ update_training_status = TrainingService.update_status
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.core import response as R
|
||||
from src.common.utils.request import parse_request_body, parse_batch_ids
|
||||
|
||||
training_blue = Blueprint('training', __name__, url_prefix='/training')
|
||||
|
||||
@@ -197,7 +197,7 @@ def status():
|
||||
@check_permission('sys:training:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.training.service import batch_delete_trainings
|
||||
id_list = parse_id_list(request)
|
||||
id_list = parse_batch_ids(request)
|
||||
return batch_delete_trainings(id_list)
|
||||
```
|
||||
|
||||
@@ -336,7 +336,7 @@ def _build_database_url(driver: str = None) -> str:
|
||||
| 驱动 | 连接串格式 | Python 包 |
|
||||
|:----|:----|:----|
|
||||
| mysql | `mysql+pymysql://user:pass@host:port/db?charset=utf8mb4` | PyMySQL |
|
||||
| postgresql | `postgresql+psycopg://user:pass@host:port/db` | psycopg[binary] |
|
||||
| postgresql | `postgresql+psycopg2://user:pass@host:port/db` | psycopg2-binary |
|
||||
| mssql | `mssql+pymssql://user:pass@host:port/db?charset=utf8` | pymssql |
|
||||
| oracle | `oracle+oracledb://user:pass@host:port/?service_name=db` | oracledb |
|
||||
| sqlite | `sqlite:///./path/to/db` | 内置 |
|
||||
@@ -376,7 +376,7 @@ DB_PASSWORD=your_password
|
||||
```
|
||||
|
||||
:::warning 驱动切换注意事项
|
||||
1. 切换驱动后需安装对应的 Python 包(如 `psycopg[binary]` for PostgreSQL)
|
||||
1. 切换驱动后需安装对应的 Python 包(如 `psycopg2-binary` for PostgreSQL)
|
||||
2. 数据迁移使用 `scripts/migrate_db.py` 工具
|
||||
3. SQLite 不支持并发写入,仅适用于开发/测试环境
|
||||
4. Oracle 需要额外的客户端库配置
|
||||
@@ -409,13 +409,17 @@ python scripts/migrate_db.py --src-driver mysql --src-host ... --dst-driver post
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# CORS + ProxyFix(最外层)
|
||||
CORS(app, ...)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, ...)
|
||||
|
||||
# 先注册 → 内层(最后处理请求)
|
||||
register_redis(app) # Redis 上下文注入
|
||||
register_db_session(app) # DB 会话管理(commit/rollback)
|
||||
register_operation_log(app) # 操作日志记录
|
||||
register_transaction(app) # AOP 事务管理
|
||||
register_v1(app) # 路由注册(login_required 依赖)
|
||||
register_exception(app) # 全局异常处理器
|
||||
register_v1(app) # 路由注册
|
||||
register_extends(app) # 扩展初始化
|
||||
register_middleware(app) # 登录校验 + 白名单
|
||||
register_error_handlers(app) # 全局异常处理器
|
||||
|
||||
# 请求计时(先注册→内层,统计业务耗时)
|
||||
@app.before_request
|
||||
@@ -429,8 +433,7 @@ def create_app():
|
||||
response.headers["X-Process-Time"] = str(round(process_time, 5))
|
||||
return response
|
||||
|
||||
register_rate_limit(app) # 滑动窗口限流
|
||||
register_cors(app) # CORS(最后注册→最外层)
|
||||
CORS(app, ...) # CORS(最后注册→最外层)
|
||||
|
||||
return app
|
||||
```
|
||||
@@ -470,13 +473,13 @@ def create_app():
|
||||
### 中间件执行顺序
|
||||
|
||||
```
|
||||
请求进入 → CORS → 限流 → 上传限制 → 计时 → 操作日志 → DB会话 → Redis注入 → 路由处理
|
||||
请求进入 → CORS → 上传限制 → 计时 → 操作日志 → DB会话 → Redis注入 → 路由处理
|
||||
│
|
||||
响应返回 ← CORS ← 限流 ← 上传限制 ← 计时 ← 操作日志 ← DB会话 ← Redis注入 ← 路由处理 ←┘
|
||||
响应返回 ← CORS ← 上传限制 ← 计时 ← 操作日志 ← DB会话 ← Redis注入 ← 路由处理 ←┘
|
||||
```
|
||||
|
||||
:::warning 中间件注册顺序
|
||||
Flask 中间件按注册顺序执行。`register_cors` 最后注册 → 最外层(最先处理请求)。`register_redis` 最先注册 → 最内层(最后处理请求)。
|
||||
Flask 中间件按注册顺序执行。CORS 最先配置 → 最外层(最先处理请求)。`register_middleware` 最后注册 → 最内层(最后处理请求)。
|
||||
:::
|
||||
|
||||
## 四、装饰器扩展
|
||||
|
||||
@@ -85,8 +85,8 @@ from flask import g, request
|
||||
from src.core.jwt import decode_token
|
||||
|
||||
|
||||
def register_auth(app):
|
||||
"""注册登录校验中间件"""
|
||||
def register_middleware(app):
|
||||
"""注册登录校验中间件(实际位于 src/middleware/auth.py)"""
|
||||
|
||||
@app.before_request
|
||||
def login_required():
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.core import response as R
|
||||
from src.common.utils.request import parse_request_body, parse_batch_ids
|
||||
|
||||
# ============================================================
|
||||
# 创建 Blueprint 实例
|
||||
@@ -199,9 +199,9 @@ def batch_delete():
|
||||
|
||||
:return: 批量删除结果响应
|
||||
"""
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
from src.modules.system.position.service import delete_positions
|
||||
id_list = parse_batch_ids(request)
|
||||
return delete_positions(id_list)
|
||||
```
|
||||
|
||||
:::tip 装饰器顺序
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ ┌──────────────────────────────────────────┐ │
|
||||
│ │ SQLAlchemy 2.0 │ │ Redis │ │
|
||||
│ │ ORM → SQL 生成 │ │ Token 黑名单 · 登录锁 · 限流计数 · 缓存 │ │
|
||||
│ │ ORM → SQL 生成 │ │ Token 黑名单 · 登录锁 · 缓存 │ │
|
||||
│ └──────────┬──────────┘ └──────────────────────────────────────────┘ │
|
||||
└─────────────┼───────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
@@ -92,19 +92,18 @@
|
||||
|
||||
```python
|
||||
# src/app.py - create_app() 中间件注册顺序
|
||||
register_cors(app) # CORS(最外层)
|
||||
register_rate_limit(app) # 滑动窗口限流
|
||||
register_upload_size_limit(app) # 上传体积预拦截
|
||||
register_redis(app) # Redis 上下文注入
|
||||
register_db_session(app) # DB 会话管理
|
||||
CORS(app, ...) # CORS(最外层)
|
||||
ProxyFix(app.wsgi_app, ...) # 可信代理
|
||||
register_operation_log(app) # 操作日志记录
|
||||
register_transaction(app) # AOP 事务管理
|
||||
register_auth(app) # JWT 认证
|
||||
register_v1(app) # 路由注册
|
||||
register_extends(app) # 扩展初始化
|
||||
register_middleware(app) # 认证中间件
|
||||
register_v1(app) # 路由注册
|
||||
register_exception(app) # 全局异常处理
|
||||
```
|
||||
|
||||
外层中间件(CORS、限流)先拦截,内层中间件(DB 会话、Redis 注入)后处理,响应沿反向链返回。限流被置于 CORS 内层,被限流的请求不进入 DB 会话与操作日志,避免 DoS 攻击时日志表与 DB 连接不降反增。
|
||||
外层中间件(CORS)先拦截,内层中间件(DB 会话、Redis 注入)后处理,响应沿反向链返回。
|
||||
|
||||
### 5. 全局异常处理
|
||||
|
||||
@@ -177,16 +176,17 @@ def create_app():
|
||||
app = Flask(__name__)
|
||||
# 1. 验证配置
|
||||
validate_config()
|
||||
# 2. 初始化扩展
|
||||
register_extends(app)
|
||||
# 2. CORS + ProxyFix
|
||||
CORS(app, ...)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, ...)
|
||||
# 3. 注册中间件
|
||||
register_cors(app)
|
||||
register_redis(app)
|
||||
register_db_session(app)
|
||||
register_operation_log(app)
|
||||
register_transaction(app)
|
||||
register_auth(app)
|
||||
# 4. 注册路由
|
||||
register_v1(app)
|
||||
register_extends(app)
|
||||
register_middleware(app)
|
||||
register_error_handlers(app)
|
||||
# 4. 依赖注入
|
||||
register_v1(app)
|
||||
# 5. 注册异常处理
|
||||
register_exception(app)
|
||||
@@ -249,4 +249,4 @@ def create_app():
|
||||
|
||||
## 总结
|
||||
|
||||
项目采用经典的前后端分离 + 三层架构设计,通过中间件链实现横切关注点(认证、限流、日志、事务)的统一处理,通过基类模板方法消除重复 CRUD 代码,通过 WTForms 实现请求参数的声明式校验。整体架构在保持简洁的同时,具备良好的可扩展性和可维护性,适合企业级后台管理系统的快速开发与长期演进。
|
||||
项目采用经典的前后端分离 + 三层架构设计,通过中间件链实现横切关注点(认证、日志、事务)的统一处理,通过基类模板方法消除重复 CRUD 代码,通过 WTForms 实现请求参数的声明式校验。整体架构在保持简洁的同时,具备良好的可扩展性和可维护性,适合企业级后台管理系统的快速开发与长期演进。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 请求链路总览
|
||||
|
||||
:::tip 链路概览
|
||||
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS → 限流 → Redis 注入 → DB 会话 → 操作日志 → 路由匹配 → JWT 认证 → RBAC 鉴权 → WTForms 校验 → Router → Service → Repository → DB → 响应序列化 → 中间件反向链 → 浏览器
|
||||
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS → Redis 注入 → DB 会话 → 操作日志 → 路由匹配 → JWT 认证 → RBAC 鉴权 → WTForms 校验 → Router → Service → Repository → DB → 响应序列化 → 中间件反向链 → 浏览器
|
||||
:::
|
||||
|
||||
以下以 `POST /api/v1/position/add` 添加岗位为例,逐步拆解完整链路。
|
||||
@@ -49,37 +49,25 @@ Nginx 将请求代理到 Flask 服务,同时附加客户端真实 IP 到 `X-Fo
|
||||
|
||||
Flask 的中间件通过 `before_request` / `after_request` 钩子实现。请求从最外层进入,逐层穿透到路由处理函数,响应沿反向链返回。
|
||||
|
||||
### 3. CORS 跨域中间件(最外层)
|
||||
### 3. CORS 跨域配置(最外层)
|
||||
|
||||
```python
|
||||
# src/middleware/cors.py
|
||||
register_cors(app) # 注册 CORS 中间件
|
||||
# src/app.py - create_app()
|
||||
from flask_cors import CORS
|
||||
from config import CORS_ALLOWED_ORIGINS
|
||||
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
allowed_origins = [origin.strip() for origin in CORS_ALLOWED_ORIGINS.split(',') if origin.strip()]
|
||||
CORS(app, resources={r"/*": {"origins": allowed_origins}}, supports_credentials=True)
|
||||
else:
|
||||
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
|
||||
```
|
||||
|
||||
- 检查 `Origin` 头是否在允许列表中
|
||||
- 预检请求(OPTIONS)直接返回 CORS 响应头
|
||||
- 普通请求附加 `Access-Control-Allow-*` 响应头
|
||||
|
||||
### 4. 请求限流中间件
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
@app.before_request
|
||||
def rate_limit():
|
||||
if not RATE_LIMIT_ENABLED:
|
||||
return
|
||||
# 滑动窗口计数器(Redis Lua 原子操作)
|
||||
count = sliding_window_incr(redis, current_key, prev_key, ...)
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
return R.failed("请求过于频繁,请稍后再试")
|
||||
```
|
||||
|
||||
- 基于客户端 IP 的滑动窗口计数器
|
||||
- 使用 Redis Lua 脚本保证原子性
|
||||
- 超限直接返回统一 JSON 响应,不进入后续链路
|
||||
- Redis 不可用时降级放行(fail-open)
|
||||
|
||||
### 5. Redis 上下文注入中间件
|
||||
### 4. Redis 上下文注入中间件
|
||||
|
||||
```python
|
||||
# src/core/redis_client.py
|
||||
@@ -351,8 +339,7 @@ R.ok(msg='添加成功')
|
||||
2. **操作日志**:记录写操作日志到数据库
|
||||
3. **DB 会话**:检测 `code==0`,执行 `db.session.commit()`
|
||||
4. **Redis 上下文**:无操作(g 对象自动清理)
|
||||
5. **限流**:无操作(响应直接透传)
|
||||
6. **CORS**:附加 `Access-Control-Allow-*` 响应头
|
||||
5. **CORS**:附加 `Access-Control-Allow-*` 响应头
|
||||
|
||||
### 20. 浏览器接收响应
|
||||
|
||||
@@ -381,10 +368,6 @@ R.ok(msg='添加成功')
|
||||
[CORS 中间件] ── 跨域校验通过
|
||||
│
|
||||
▼
|
||||
[限流中间件] ── 滑动窗口计数(Redis)
|
||||
│ ├─ 超限 → 直接返回失败
|
||||
│ └─ 通过 ▼
|
||||
▼
|
||||
[DB 会话中间件] ── 标记事务活跃
|
||||
│
|
||||
▼
|
||||
@@ -421,7 +404,7 @@ R.ok(msg='添加成功')
|
||||
└─ 业务失败(code≠0)→ rollback 回滚事务
|
||||
│
|
||||
▼
|
||||
[限流] → [CORS] → [Nginx]
|
||||
[CORS] → [Nginx]
|
||||
│
|
||||
▼
|
||||
[浏览器] ← JSON 响应({ code, data, msg, ok })
|
||||
@@ -443,4 +426,4 @@ R.ok(msg='添加成功')
|
||||
|
||||
## 总结
|
||||
|
||||
一个 API 请求从浏览器到数据库经过 7 个阶段、20+ 个处理节点。中间件链负责横切关注点(安全、限流、日志、事务),路由层负责认证鉴权,WTForms 负责参数校验,三层架构负责业务逻辑与数据访问。这种分层设计使得每一层职责单一、可独立测试,系统在高并发场景下仍能保持稳定的性能和可靠的事务一致性。
|
||||
一个 API 请求从浏览器到数据库经过 7 个阶段、20+ 个处理节点。中间件链负责横切关注点(安全、日志、事务),路由层负责认证鉴权,WTForms 负责参数校验,三层架构负责业务逻辑与数据访问。这种分层设计使得每一层职责单一、可独立测试,系统在高并发场景下仍能保持稳定的性能和可靠的事务一致性。
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
|
||||
**授权(Authorization)**:基于 RBAC(基于角色的访问控制)模型设计,权限控制粒度细化至接口级。用户通过角色继承权限节点(如 `sys:position:add`),由 `@check_permission` 装饰器在路由层统一拦截校验,权限数据采用 Redis 缓存加速查询,管理员(`userId == 1`)自动放行。权限变更时,相关缓存主动失效,确保权限更新的实时性。
|
||||
|
||||
**防护(Protection)**:系统从多维度构建安全防护体系。限流中间件基于 Redis 滑动窗口算法,有效防御 CC 攻击和接口滥用;参数校验通过 WTForms Schema 自动拦截非法输入,防止 SQL 注入和 XSS 攻击;敏感密码采用 bcrypt 加盐哈希存储,即使数据库泄露也无法还原明文;演示模式下 `@check_demo` 装饰器统一拦截写操作,保护测试数据不被污染;跨域配置精确控制可信域名,防止 CSRF 攻击。
|
||||
**防护(Protection)**:系统从多维度构建安全防护体系。参数校验通过 WTForms Schema 自动拦截非法输入,防止 SQL 注入和 XSS 攻击;敏感密码采用 bcrypt 加盐哈希存储,即使数据库泄露也无法还原明文;演示模式下 `@check_demo` 装饰器统一拦截写操作,保护测试数据不被污染;跨域配置精确控制可信域名,防止 CSRF 攻击。
|
||||
|
||||
**审计(Audit)**:系统提供完备的操作审计能力。操作日志中间件自动记录所有写操作(POST/PUT/DELETE/PATCH)的请求路径、参数、操作人、IP 地址及执行结果,存入 `flask_operation_log` 表;登录日志独立记录每次登录尝试(含 IP、User-Agent、登录状态),便于安全事件追溯与异常行为分析;定时任务每次执行记录至 `flask_job_log` 表,确保后台任务的执行过程可审计。
|
||||
|
||||
:::tip 安全设计原则
|
||||
纵深防御:认证、授权、限流、参数校验多层防护,单一防线失效时仍有其他机制兜底
|
||||
纵深防御:认证、授权、参数校验多层防护,单一防线失效时仍有其他机制兜底
|
||||
|
||||
最小权限:用户仅能访问已授权接口,角色权限按需分配,遵循"够用即可"原则
|
||||
|
||||
数据不落地:敏感信息(Token、密码)不在日志中明文记录,密码哈希不可逆
|
||||
|
||||
安全默认:框架默认配置为安全模式(如 DEBUG 关闭、限流开启),如需放宽需显式配置
|
||||
安全默认:框架默认配置为安全模式(如 DEBUG 关闭),如需放宽需显式配置
|
||||
:::
|
||||
|
||||
## 安全架构总览
|
||||
@@ -29,7 +29,7 @@
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 网络层防护 │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ CORS │ │ 限流 │ │ XSS 防护 │ │ 上传体积限制 │ │ │
|
||||
│ │ │ CORS │ │ XSS 防护 │ │ 上传体积限制 │ │ │
|
||||
│ │ │ 跨域控制 │ │ 滑动窗口 │ │ 输入清洗 │ │ DoS 防御 │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
@@ -316,7 +316,7 @@ def verify_password(password: str, salt: str, hashed: str) -> bool:
|
||||
# src/core/permission.py
|
||||
from functools import wraps
|
||||
from flask import request
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
from src.core.security import get_user_id
|
||||
|
||||
def check_permission(permission: str):
|
||||
@@ -369,7 +369,7 @@ def invalidate_perm_cache(user_id: int):
|
||||
# src/middleware/demo.py
|
||||
from functools import wraps
|
||||
from flask import current_app
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
def check_demo(func):
|
||||
"""演示模式拦截装饰器,放在 @check_permission 之后、路由函数之前"""
|
||||
@@ -390,39 +390,20 @@ def check_demo(func):
|
||||
### CORS 跨域控制
|
||||
|
||||
```python
|
||||
# src/middleware/cors.py
|
||||
# src/app.py - create_app()
|
||||
from flask_cors import CORS
|
||||
from config import CORS_ALLOWED_ORIGINS
|
||||
|
||||
def register_cors(app):
|
||||
CORS(app,
|
||||
origins=CORS_ALLOWED_ORIGINS,
|
||||
supports_credentials=True,
|
||||
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"])
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
allowed_origins = [origin.strip() for origin in CORS_ALLOWED_ORIGINS.split(',') if origin.strip()]
|
||||
CORS(app, resources={r"/*": {"origins": allowed_origins}}, supports_credentials=True)
|
||||
else:
|
||||
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
|
||||
```
|
||||
|
||||
- 生产环境应配置具体的 `origins`,避免使用 `*`
|
||||
- 开发环境可配置为 `http://localhost:8001`
|
||||
|
||||
### 请求限流
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
from flask import request
|
||||
|
||||
def rate_limit():
|
||||
# 基于客户端 IP 的滑动窗口限流
|
||||
client_ip = get_client_ip()
|
||||
count = sliding_window_incr(redis, current_key, prev_key, ...)
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
return R.failed(msg="请求过于频繁,请稍后再试")
|
||||
```
|
||||
|
||||
- 默认关闭,通过 `RATE_LIMIT_ENABLED=True` 启用
|
||||
- 使用 Redis 滑动窗口计数器,消除固定窗口边界 2x 突发
|
||||
- Redis 不可用时降级放行(fail-open)
|
||||
- CORS 预检请求不计数
|
||||
|
||||
### XSS 防护
|
||||
|
||||
```python
|
||||
@@ -450,17 +431,16 @@ def sanitize_html(content: str) -> str:
|
||||
### 上传体积限制
|
||||
|
||||
```python
|
||||
# src/middleware/upload_size.py
|
||||
from flask import request
|
||||
# src/modules/upload/service.py
|
||||
from config import UPLOAD_MAX_SIZE_MB
|
||||
|
||||
def upload_size_limit():
|
||||
content_length = request.headers.get('content-length')
|
||||
if content_length and int(content_length) > MAX_UPLOAD_SIZE:
|
||||
return R.failed(msg="上传文件超出大小限制")
|
||||
max_size_bytes = UPLOAD_MAX_SIZE_MB * 1024 * 1024
|
||||
if file_size > max_size_bytes:
|
||||
raise BizError(f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB")
|
||||
```
|
||||
|
||||
- 按 `Content-Length` 提前拒绝超大请求体
|
||||
- 避免超大 multipart 落临时盘后再由业务层校验
|
||||
- 在业务层校验文件大小,通过 `UPLOAD_MAX_SIZE_MB` 环境变量配置上限
|
||||
- 前端配合 `el-upload` 组件的 `beforeUpload` 钩子做前置校验
|
||||
|
||||
## 四、数据层防护
|
||||
|
||||
@@ -548,9 +528,6 @@ def operation_log():
|
||||
| JWT 过期时间 | `JWT_EXPIRE_MINUTES` | 120 | Token 有效期(分钟) |
|
||||
| 密码错误锁定 | — | 5 次/10 分钟 | 连续失败后锁定 |
|
||||
| 验证码有效期 | — | 120 秒 | 验证码 Redis TTL |
|
||||
| 限流开关 | `RATE_LIMIT_ENABLED` | False | 滑动窗口限流 |
|
||||
| 限流阈值 | `RATE_LIMIT_LIMIT` | 100 | 每窗口最大请求数 |
|
||||
| 限流窗口 | `RATE_LIMIT_WINDOW_SECONDS` | 60 | 窗口时长(秒) |
|
||||
| CORS 源 | `CORS_ALLOWED_ORIGINS` | * | 允许的跨域源 |
|
||||
| 上传限制 | `UPLOAD_MAX_SIZE_MB` | 10MB | 最大上传体积 |
|
||||
| 演示模式 | `FLASK_DEMO` | False | 锁定写操作 |
|
||||
@@ -567,4 +544,4 @@ def operation_log():
|
||||
|
||||
## 总结
|
||||
|
||||
项目的安全架构涵盖认证(JWT + 验证码 + 登录锁定 + Token 黑名单)、授权(RBAC + check_permission + check_demo)、防护(CORS + 限流 + XSS + 上传限制 + SQL 注入防护)、审计(操作日志 + 登录日志)四大领域。通过纵深防御策略,在网络层、认证层、授权层、数据层分别设置安全屏障,确保系统在面对各类安全威胁时具备足够的防护能力。生产部署时需特别关注 JWT 密钥强度、CORS 配置、HTTPS 启用等关键安全项。
|
||||
项目的安全架构涵盖认证(JWT + 验证码 + 登录锁定 + Token 黑名单)、授权(RBAC + check_permission + check_demo)、防护(CORS + XSS + 上传限制 + SQL 注入防护)、审计(操作日志 + 登录日志)四大领域。通过纵深防御策略,在网络层、认证层、授权层、数据层分别设置安全屏障,确保系统在面对各类安全威胁时具备足够的防护能力。生产部署时需特别关注 JWT 密钥强度、CORS 配置、HTTPS 启用等关键安全项。
|
||||
|
||||
@@ -72,21 +72,20 @@ SQLAlchemy 是 Python 生态中最成熟、功能最完备的 ORM 框架,2.0
|
||||
| **ElementPlus** | 2.x | Vue3 生态最成熟的 UI 组件库,企业级后台管理系统首选 |
|
||||
| **Pinia** | 2.x | Vue3 官方推荐的状态管理方案,替代 Vuex |
|
||||
| **Vue Router** | 4.x | Vue3 官方路由,支持动态路由、路由守卫 |
|
||||
| **Axios** | 1.x | HTTP 客户端,支持请求/响应拦截器、取消请求 |
|
||||
| **Axios** | 0.27.x | HTTP 客户端,支持请求/响应拦截器、取消请求 |
|
||||
|
||||
## 缓存与消息中间件
|
||||
|
||||
### Redis
|
||||
|
||||
:::tip Redis 在项目中的角色
|
||||
Redis 在项目中承担多个关键职责:JWT 令牌黑名单、登录失败锁定、请求限流计数、数据字典缓存、权限缓存等。采用同步客户端与 Flask 的同步模型无缝集成。
|
||||
Redis 在项目中承担多个关键职责:JWT 令牌黑名单、登录失败锁定、数据字典缓存、权限缓存等。采用同步客户端与 Flask 的同步模型无缝集成。
|
||||
:::
|
||||
|
||||
| 用途 | 数据结构 | 过期策略 |
|
||||
|:---|:---|:---|
|
||||
| JWT 黑名单 | String | 等于 Token 剩余有效期 |
|
||||
| 登录失败锁 | String | 指定锁定时长 |
|
||||
| 滑动窗口限流 | String (Lua) | 2 倍窗口时长 |
|
||||
| 数据字典缓存 | String (JSON) | TTL 可配置 |
|
||||
| 权限列表缓存 | String (JSON) | 变更时主动失效 |
|
||||
|
||||
@@ -211,9 +210,9 @@ Redis 在项目中承担多个关键职责:JWT 令牌黑名单、登录失败
|
||||
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|:---|:---|:---|
|
||||
| `Flask` | >= 3.1.1 | Web 框架,`src/app.py` 通过 `create_app()` 创建应用 |
|
||||
| `PyJWT` | >= 2.13.0 | JWT 令牌签发与验证(HS256 算法),`src/core/jwt.py` |
|
||||
| `bcrypt` | >= 4.0.0 | 密码哈希(带随机盐),`src/core/password.py` |
|
||||
| `Flask` | >= 3.1.3 | Web 框架,`src/app.py` 通过 `create_app()` 创建应用 |
|
||||
| `Flask-JWT-Extended` | >= 4.7.1 | JWT 令牌签发与验证,`src/core/jwt.py` |
|
||||
| `bcrypt` | >= 4.3.0 | 密码哈希(带随机盐),`src/core/password.py` |
|
||||
| `openpyxl` | >= 3.1.0 | Excel 导入导出,文件上传/下载模块使用 |
|
||||
| `Pillow` | >= 10.0.0 | 验证码图片生成,`src/common/utils/captcha.py` |
|
||||
| `python-dotenv` | >= 1.0.0 | `.env` 配置文件加载,`config/__init__.py` 首先调用 `load_dotenv()` |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
:::tip 用户管理
|
||||
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。与简单 CRUD 模块不同,用户管理使用自定义 service 函数而非继承 BaseService 的类方法。
|
||||
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。用户管理继承 `BaseService` 获得标准 CRUD 能力,并通过覆写钩子方法实现头像保存、密码加密、角色关联等定制逻辑。
|
||||
:::
|
||||
|
||||
## 模块结构
|
||||
@@ -237,7 +237,7 @@ def _enrich_user_records(records):
|
||||
role_map = user_role.get_user_roles_batch(user_ids)
|
||||
|
||||
for data in records:
|
||||
data["genderName"] = gender_map.get(str(data.get("gender")))
|
||||
data["genderText"] = gender_map.get(str(data.get("gender")))
|
||||
data["deptName"] = dept_map.get(data.get("deptId"))
|
||||
data["levelName"] = level_map.get(data.get("levelId"))
|
||||
data["positionName"] = position_map.get(data.get("positionId"))
|
||||
|
||||
@@ -117,15 +117,13 @@ Content-Type: application/json
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"must_change_password": false
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `access_token`:JWT 令牌,后续请求需携带
|
||||
- `must_change_password`:是否需要修改密码(检测到默认密码时为 `true`)
|
||||
|
||||
### 失败响应
|
||||
|
||||
@@ -134,10 +132,10 @@ Content-Type: application/json
|
||||
{"code": 1, "msg": "验证码不正确", "data": null, "ok": false}
|
||||
|
||||
// 账号或密码错误
|
||||
{"code": 1, "msg": "用户名或密码不正确", "data": null, "ok": false}
|
||||
{"code": 1, "msg": "用户不存在", "data": null, "ok": false}
|
||||
|
||||
// 账号被锁定
|
||||
{"code": 1, "msg": "登录失败次数过多,请280秒后再试", "data": null, "ok": false}
|
||||
{"code": 1, "msg": "登录失败次数过多,请5分钟后再试", "data": null, "ok": false}
|
||||
```
|
||||
|
||||
### 登录流程详解
|
||||
|
||||
@@ -1,90 +1,112 @@
|
||||
:::tip CORS跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。中间件位于 `src/middleware/cors.py`。
|
||||
# 跨域资源共享(CORS)
|
||||
|
||||
:::tip CORS 跨域
|
||||
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。项目通过 `flask-cors` 扩展实现 CORS 支持,配置入口在 `src/app.py` 的 `create_app()` 中,通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) |
|
||||
| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 |
|
||||
| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8001` | 允许的跨域源(逗号分隔),留空则允许所有源 |
|
||||
| `PROXY_NUM` | `0` | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 后设为 `1` |
|
||||
|
||||
## 实现原理
|
||||
|
||||
CORS 配置位于 `src/app.py` 的 `create_app()` 中:
|
||||
|
||||
```python
|
||||
# src/app.py
|
||||
from flask_cors import CORS
|
||||
from config import CORS_ALLOWED_ORIGINS
|
||||
|
||||
# ============================================================
|
||||
# 跨域资源共享配置
|
||||
# ============================================================
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
allowed_origins = [origin.strip() for origin in CORS_ALLOWED_ORIGINS.split(',') if origin.strip()]
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/*": {"origins": allowed_origins}},
|
||||
supports_credentials=True
|
||||
)
|
||||
else:
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/*": {"origins": "*"}},
|
||||
supports_credentials=False
|
||||
)
|
||||
```
|
||||
|
||||
- 配置了 `CORS_ALLOWED_ORIGINS` 时:仅允许指定源,支持携带凭证(Cookie 等)
|
||||
- 未配置时:允许所有源,但不携带凭证
|
||||
|
||||
## 配置示例
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
# 允许所有源(开发环境)
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
# 开发环境:允许前端开发服务器
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:8001
|
||||
|
||||
# 允许指定源(生产环境)
|
||||
# 生产环境:允许指定域名(多个用逗号分隔)
|
||||
CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com
|
||||
|
||||
# 信任一层反向代理(Nginx)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
:::warning 生产环境
|
||||
生产环境务必配置 `CORS_ALLOWED_ORIGINS` 为具体域名,避免允许所有源带来的安全风险。
|
||||
:::
|
||||
|
||||
## CORS 中间件注册
|
||||
## 可信代理配置
|
||||
|
||||
Flask 使用 `flask-cors` 扩展实现 CORS 支持:
|
||||
当应用部署在 Nginx 等反向代理后,需要配置代理信任以获取真实客户端 IP:
|
||||
|
||||
```python
|
||||
# src/middleware/cors.py
|
||||
from flask_cors import CORS
|
||||
from config.app import CORS_ALLOWED_ORIGINS
|
||||
# src/app.py
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
def register_cors(app):
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
origins = [o.strip() for o in CORS_ALLOWED_ORIGINS.split(',') if o.strip()]
|
||||
CORS(app, origins=origins, supports_credentials=True)
|
||||
else:
|
||||
# 未配置时允许所有源但不携带凭证
|
||||
CORS(app, origins="*")
|
||||
# ============================================================
|
||||
# 可信代理配置(反向代理环境下获取真实客户端IP)
|
||||
# ============================================================
|
||||
proxy_num = int(os.getenv('PROXY_NUM', 0))
|
||||
if proxy_num > 0:
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)
|
||||
```
|
||||
|
||||
## 中间件顺序
|
||||
`PROXY_NUM` 说明:
|
||||
|
||||
CORS 中间件在 `src/app.py` 中注册为最外层:
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
|
||||
| `1` | 信任一层反向代理(如 Nginx) |
|
||||
| `2` | 信任两层代理 |
|
||||
|
||||
:::tip 安全提示
|
||||
仅当应用部署在反向代理后才设置 `PROXY_NUM>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过安全校验。
|
||||
:::
|
||||
|
||||
## 中间件注册顺序
|
||||
|
||||
CORS 在 `src/app.py` 中最先注册,确保所有响应都带 CORS 头:
|
||||
|
||||
```python
|
||||
# 最先注册→最外层:确保限流等直接返回的响应也带 CORS 头
|
||||
register_cors(app)
|
||||
# src/app.py - create_app() 中间件注册顺序
|
||||
# 1. CORS 配置(最先注册→最外层)
|
||||
# 2. ProxyFix 可信代理
|
||||
# 3. register_operation_log(app) # 操作日志
|
||||
# 4. register_transaction(app) # AOP 事务
|
||||
# 5. register_v1(app) # 路由注册
|
||||
# 6. register_extends(app) # 扩展初始化
|
||||
# 7. register_middleware(app) # 认证中间件
|
||||
# 8. register_error_handlers(app) # 异常处理
|
||||
```
|
||||
|
||||
中间件执行顺序(从外到内):
|
||||
|
||||
```
|
||||
CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑
|
||||
CORS → 操作日志 → AOP 事务 → 认证 → 路由 → 业务逻辑
|
||||
```
|
||||
|
||||
## 代理头处理
|
||||
|
||||
当应用部署在 Nginx 等反向代理后,需要配置代理信任:
|
||||
|
||||
```bash
|
||||
# 信任一层代理
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
`TRUSTED_PROXY_COUNT` 说明:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
|
||||
| `1` | 信任一层反向代理 |
|
||||
| `2` | 信任两层代理 |
|
||||
|
||||
:::tip 安全提示
|
||||
仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。
|
||||
:::
|
||||
|
||||
## Flask-CORS 配置选项
|
||||
|
||||
```python
|
||||
@@ -107,14 +129,24 @@ CORS(app,
|
||||
| `supports_credentials` | 允许携带凭证 | `False` |
|
||||
| `max_age` | 预检请求缓存时间 | `None` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
+ 跨域请求被拒绝
|
||||
|
||||
```
|
||||
1. 检查 .env 中 CORS_ALLOWED_ORIGINS 是否包含前端地址
|
||||
2. 检查前端请求的 Origin 头是否与配置一致
|
||||
3. 生产环境检查 Nginx 是否正确传递 Origin 头
|
||||
```
|
||||
|
||||
+ 携带凭证(Cookie)失败
|
||||
|
||||
```
|
||||
1. CORS_ALLOWED_ORIGINS 必须配置具体域名,不能用 *
|
||||
2. 前端请求需设置 withCredentials: true
|
||||
3. 确保 supports_credentials=True
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
CORS 跨域配置具备以下特点:
|
||||
|
||||
```
|
||||
1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源
|
||||
2. 开发/生产分离:开发环境允许所有源,生产环境指定域名
|
||||
3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数
|
||||
4. 安全默认:未配置时允许所有源但不携带凭证
|
||||
5. 最外层注册:确保所有响应都带 CORS 头
|
||||
```
|
||||
CORS 跨域配置通过 `flask-cors` 扩展在 `src/app.py` 中实现,通过 `CORS_ALLOWED_ORIGINS` 环境变量灵活配置允许的源。开发环境允许前端开发服务器(`http://localhost:8001`),生产环境应配置具体域名。反向代理场景通过 `PROXY_NUM` 配置代理信任层数,确保获取真实客户端 IP。
|
||||
|
||||
@@ -40,7 +40,7 @@ def add():
|
||||
```python
|
||||
from functools import wraps
|
||||
from flask import current_app
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
def check_demo(func):
|
||||
"""演示模式拦截装饰器,放在 @check_permission 之后、路由函数之前"""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
`src/core/response.py` 提供统一的响应生成函数:
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
# 成功响应
|
||||
R.ok(data={"id": 1}, msg="操作成功")
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
|
||||
|
||||
```javascript
|
||||
import { useLockFn } from '@/utils/lock'
|
||||
import { useLockFn } from '@/utils/useLockFn'
|
||||
|
||||
// 包装提交函数,防止重复点击
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
const { lockFn: handleSubmit } = useLockFn(async () => {
|
||||
await axios.post('/api/v1/level/add', formData)
|
||||
ElMessage.success('添加成功')
|
||||
})
|
||||
@@ -21,17 +21,20 @@ const handleSubmit = useLockFn(async () => {
|
||||
`useLockFn` 原理:
|
||||
|
||||
```javascript
|
||||
function useLockFn(fn) {
|
||||
let isLocked = false
|
||||
return async (...args) => {
|
||||
if (isLocked) return
|
||||
isLocked = true
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useLockFn(fn) {
|
||||
const isLock = ref(false)
|
||||
const lockFn = async (...args) => {
|
||||
if (isLock.value) return
|
||||
isLock.value = true
|
||||
try {
|
||||
return await fn(...args)
|
||||
await fn(...args)
|
||||
} finally {
|
||||
isLocked = false
|
||||
isLock.value = false
|
||||
}
|
||||
}
|
||||
return { isLock, lockFn }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ def login(data):
|
||||
|
||||
return R.ok(msg="登录成功", data={
|
||||
"access_token": access_token,
|
||||
"must_change_password": is_default_pwd
|
||||
"access_token": access_token
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -17,50 +17,42 @@
|
||||
以下路径不记录操作日志:
|
||||
|
||||
```python
|
||||
_EXCLUDE_PATHS = (
|
||||
'/api/v1/login',
|
||||
'/api/v1/logout',
|
||||
'/api/v1/captcha',
|
||||
'/api/v1/upload',
|
||||
'/api/v1/loginlog',
|
||||
'/api/v1/operationlog',
|
||||
'/api/v1/index/menu',
|
||||
'/api/v1/index/user',
|
||||
)
|
||||
# src/middleware/operation_log.py
|
||||
_EXCLUDED_PATH_SEGMENTS = ("/login", "/captcha", "/logout", "/sendSms")
|
||||
```
|
||||
|
||||
排除逻辑使用 `endswith` 匹配路径末段,而非精确匹配完整路径。
|
||||
|
||||
## 操作类型自动推断
|
||||
|
||||
中间件根据 URL 路径末段自动推断操作类型,映射表定义在 `_TYPE_MAP` 中:
|
||||
中间件根据 HTTP 方法和 URL 路径自动推断操作类型:
|
||||
|
||||
```python
|
||||
_TYPE_MAP = {
|
||||
'add': 1, # 新增
|
||||
'update': 2, # 修改
|
||||
'delete': 3, # 删除
|
||||
'batchdelete': 4, # 批量删除
|
||||
'status': 5, # 状态设置
|
||||
'resetpwd': 6, # 重置密码
|
||||
'updatepassword': 7, # 修改密码
|
||||
'import': 8, # 导入
|
||||
'export': 9, # 导出
|
||||
'kickout': 10, # 强退
|
||||
'generate': 11, # 生成代码
|
||||
'clear': 12, # 清空数据
|
||||
'auth': 13, # 授权
|
||||
'refresh': 14, # 缓存
|
||||
'openaccount': 15, # 开账号
|
||||
# src/middleware/operation_log.py
|
||||
|
||||
# HTTP 方法 → 日志类型
|
||||
_METHOD_LOG_TYPE = {
|
||||
"POST": 3, # 新增
|
||||
"PUT": 4, # 修改
|
||||
"DELETE": 5, # 删除
|
||||
}
|
||||
|
||||
def _infer_type(request) -> int:
|
||||
"""从 URL 路径末段推断操作类型,匹配不到返回 0(其它)"""
|
||||
path = request.path.rstrip('/')
|
||||
last_segment = path.split('/')[-1].lower()
|
||||
# 跳过纯数字路径参数(如 /delete/123 → 取 delete)
|
||||
if last_segment.isdigit():
|
||||
parts = path.split('/')
|
||||
last_segment = parts[-2].lower() if len(parts) >= 2 else ''
|
||||
return _TYPE_MAP.get(last_segment, 0)
|
||||
# URL 路径关键字 → 日志类型(优先级高于方法)
|
||||
_PATH_ACTION_TYPE = {
|
||||
"status": 4, # 状态变更
|
||||
"import": 3, # 导入
|
||||
"export": 5, # 导出
|
||||
"batchDelete": 5, # 批量删除
|
||||
}
|
||||
|
||||
def _infer_log_type(request) -> int:
|
||||
"""从 HTTP 方法和 URL 路径推断操作类型"""
|
||||
# 先按路径关键字匹配
|
||||
for keyword, log_type in _PATH_ACTION_TYPE.items():
|
||||
if keyword in request.path:
|
||||
return log_type
|
||||
# 再按 HTTP 方法匹配
|
||||
return _METHOD_LOG_TYPE.get(request.method, 18) # 18=其它
|
||||
```
|
||||
|
||||
| 路径末段 | 操作类型 | 类型ID |
|
||||
@@ -136,7 +128,7 @@ def register_operation_log(app):
|
||||
return response
|
||||
|
||||
# 排除特定路径
|
||||
if request.path in _EXCLUDE_PATHS:
|
||||
if any(seg in request.path for seg in _EXCLUDED_PATH_SEGMENTS):
|
||||
return response
|
||||
|
||||
# 计算耗时
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
:::tip 请求限流
|
||||
请求限流中间件基于 Redis 实现 IP 级别的滑动窗口限流,防止恶意请求和 DoS 攻击。默认关闭,可通过环境变量开启。源码位于 `src/middleware/rate_limit.py`,滑动窗口 Lua 脚本位于 `src/core/redis_client.py`。
|
||||
:::
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
|
||||
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每个 IP 最大请求数 |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 统计窗口时长(秒) |
|
||||
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis 计数 key 前缀 |
|
||||
|
||||
:::warning 生产环境建议
|
||||
生产环境建议开启限流(`RATE_LIMIT_ENABLED=True`),根据实际流量调整 `RATE_LIMIT_LIMIT` 和 `RATE_LIMIT_WINDOW_SECONDS`。
|
||||
:::
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
1. 请求进入 → 检查 RATE_LIMIT_ENABLED 开关
|
||||
2. 获取客户端 IP(支持 X-Forwarded-For 代理头)
|
||||
3. 使用滑动窗口算法计算当前窗口请求数
|
||||
4. 超过限制 → 返回 {"code":1, "msg":"请求过于频繁,请 X 秒后再试"}
|
||||
5. 未超过 → 放行请求
|
||||
```
|
||||
|
||||
## 滑动窗口算法
|
||||
|
||||
采用滑动窗口计数器,按当前窗口与上一窗口的计数时间加权,消除固定窗口边界处的 2x 突发问题。
|
||||
|
||||
### 中间件实现
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
from flask import request, g
|
||||
import time
|
||||
|
||||
def register_rate_limit(app):
|
||||
"""注册限流中间件"""
|
||||
|
||||
@app.before_request
|
||||
def rate_limit():
|
||||
"""按客户端 IP 做滑动窗口限流,超限直接返回统一 200 + code 响应"""
|
||||
from flask import current_app
|
||||
|
||||
if not current_app.config.get('RATE_LIMIT_ENABLED'):
|
||||
return
|
||||
|
||||
# CORS 预检请求不计数,避免浏览器预检占用限流额度
|
||||
if request.method == 'OPTIONS':
|
||||
return
|
||||
|
||||
# Redis 未就绪时降级放行
|
||||
redis = getattr(g, 'redis', None)
|
||||
if redis is None:
|
||||
return
|
||||
|
||||
client_ip = get_client_ip()
|
||||
now = time.time()
|
||||
window_index = int(now // RATE_LIMIT_WINDOW_SECONDS)
|
||||
current_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index}"
|
||||
prev_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index - 1}"
|
||||
|
||||
# 原子滑动窗口计数
|
||||
try:
|
||||
count = sliding_window_incr(
|
||||
redis, current_key, prev_key, RATE_LIMIT_WINDOW_SECONDS, window_index, now)
|
||||
except Exception as e:
|
||||
app.logger.warning(f"限流计数异常,降级放行: {e}")
|
||||
return
|
||||
|
||||
# 直接返回而非抛异常
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
try:
|
||||
ttl = redis.ttl(current_key)
|
||||
except Exception:
|
||||
ttl = RATE_LIMIT_WINDOW_SECONDS
|
||||
from src.core.response import R
|
||||
return R.failed(msg=f"请求过于频繁,请 {ttl} 秒后再试")
|
||||
```
|
||||
|
||||
### Lua 脚本
|
||||
|
||||
滑动窗口的核心在 `src/core/redis_client.py` 中的 Lua 脚本:
|
||||
|
||||
```python
|
||||
_SLIDING_WINDOW_LUA = """
|
||||
local cur = redis.call('incr', KEYS[1])
|
||||
if cur == 1 then
|
||||
redis.call('expire', KEYS[1], ARGV[1])
|
||||
end
|
||||
local prev = tonumber(redis.call('get', KEYS[2]) or '0')
|
||||
local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[2]) * tonumber(ARGV[1])
|
||||
local f = elapsed / tonumber(ARGV[1])
|
||||
if f < 0 then f = 0 elseif f > 1 then f = 1 end
|
||||
local effective = prev * (1 - f) + cur
|
||||
return effective
|
||||
"""
|
||||
|
||||
def sliding_window_incr(redis, current_key, prev_key,
|
||||
window_seconds, window_index, now) -> float:
|
||||
"""滑动窗口限流计数:返回当前滑动窗口内的加权请求数(近似精确)"""
|
||||
return redis.eval(
|
||||
_SLIDING_WINDOW_LUA, 2, current_key, prev_key,
|
||||
window_seconds, window_index, now)
|
||||
```
|
||||
|
||||
算法原理:当前窗口计数 `cur` + 上一窗口计数 `prev` 按时间比例加权(`prev * (1 - f)`),窗口边界处请求数平滑过渡,消除固定窗口在边界整体重置导致的 2x 突发。
|
||||
|
||||
## 降级策略
|
||||
|
||||
```
|
||||
1. Redis 未连接:直接放行,不阻塞请求
|
||||
2. Redis 运行期故障:降级放行,记录警告日志
|
||||
3. 限流计数异常:降级放行,避免全站不可用
|
||||
```
|
||||
|
||||
:::tip fail-open 策略
|
||||
限流采用 fail-open 策略:Redis 不可用时放行所有请求,优先保证服务可用性。这是安全与可用性的权衡。
|
||||
:::
|
||||
|
||||
## 中间件位置
|
||||
|
||||
限流中间件在 `src/app.py` 中注册为最外层(CORS 内层):
|
||||
|
||||
```python
|
||||
# 最外层:限流(被限流的请求不进入 DB 会话与操作日志)
|
||||
register_rate_limit(app)
|
||||
# CORS(确保限流响应也带 CORS 头)
|
||||
register_cors(app)
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
请求限流模块具备以下特点:
|
||||
|
||||
```
|
||||
1. 滑动窗口:消除固定窗口边界处的 2x 突发问题
|
||||
2. IP 维度:按客户端 IP 限流,支持代理头
|
||||
3. 默认关闭:opt-in 模式,按需开启
|
||||
4. 降级策略:Redis 故障时 fail-open 放行
|
||||
5. 最外层拦截:限流请求不进入 DB 会话和操作日志
|
||||
6. Lua 原子操作:计数和过期设置原子执行,避免并发问题
|
||||
```
|
||||
@@ -118,59 +118,108 @@ def add():
|
||||
校验逻辑(完整实现):
|
||||
|
||||
```python
|
||||
# src/core/permission.py
|
||||
import fnmatch
|
||||
import hmac
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, g
|
||||
from src.core.response import R
|
||||
from src.core.security import get_user_id
|
||||
from typing import List, Protocol
|
||||
|
||||
def check_permission(permission: str):
|
||||
"""节点权限鉴权装饰器,userId==1(管理员)直接放行"""
|
||||
from flask import request, g, current_app
|
||||
from src.core import response as R
|
||||
from src.core.security import get_user_id
|
||||
from config import PERMISSION_ENABLED, INTERNAL_API_KEY, INTERNAL_ALLOWED_IPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionProvider(Protocol):
|
||||
"""权限数据提供方接口:core 只认接口,不认实现。"""
|
||||
|
||||
def get_user_permissions(self, user_id: int) -> List[str]:
|
||||
"""返回用户拥有的权限节点列表。"""
|
||||
|
||||
|
||||
def get_provider() -> "PermissionProvider":
|
||||
"""获取注入的权限提供方(应用工厂注入)。"""
|
||||
return current_app.extensions.get("permission_provider")
|
||||
|
||||
|
||||
def check_permission(permission: str, log: bool = False):
|
||||
"""
|
||||
权限验证装饰器
|
||||
|
||||
权限验证规则:
|
||||
1. 定时任务等内部调用 → 跳过权限验证,直接放行
|
||||
2. 超级管理员(user_id=1) → 拥有所有权限
|
||||
3. 普通用户 → 通过注入的 PermissionProvider 验证权限
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 获取当前用户ID
|
||||
user_id = get_user_id()
|
||||
# 定时任务等内部调用:跳过权限验证
|
||||
if is_internal_call():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 管理员免检
|
||||
# 获取用户ID
|
||||
user_id = getattr(g, "user_id", None)
|
||||
if user_id is None:
|
||||
try:
|
||||
user_id = get_user_id()
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
# 超级管理员放行(user_id=1 为系统内置管理员)
|
||||
if user_id == 1:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 获取用户权限列表
|
||||
from src.modules.system.menu.service import get_permissions_list
|
||||
permission_list = get_permissions_list(user_id)
|
||||
# 普通用户权限验证
|
||||
if user_id:
|
||||
provider = get_provider()
|
||||
if provider is None:
|
||||
return R.failed(msg="权限验证失败", code=500)
|
||||
|
||||
# 检查权限
|
||||
permission_list = provider.get_user_permissions(user_id)
|
||||
if permission not in permission_list:
|
||||
return R.failed("权限不足")
|
||||
return R.failed(msg="权限不足!", code=403)
|
||||
else:
|
||||
return R.failed(msg="请先登录", code=401)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
:::warning Request 提取
|
||||
装饰器通过 `flask.request` 获取当前请求对象,无需显式传递 request 参数。
|
||||
:::tip 依赖注入设计
|
||||
权限校验采用依赖注入设计(解决 core → modules 依赖倒置):
|
||||
- core 定义 `PermissionProvider` 协议,不 import 任何业务模块
|
||||
- 应用工厂启动时注入 menu 服务的实现(见 `src/app.py` 的 `_inject_dependencies`)
|
||||
- `check_permission` 通过 `current_app.extensions['permission_provider']` 获取权限数据
|
||||
:::
|
||||
|
||||
:::tip 管理员免检
|
||||
用户ID为1的管理员账号(admin)自动跳过所有权限校验,拥有全部权限。
|
||||
:::
|
||||
|
||||
### 权限缓存
|
||||
### 权限数据注入
|
||||
|
||||
权限列表通过 Redis 缓存,提高查询性能:
|
||||
权限数据通过应用工厂注入,避免 core 层直接依赖 modules 层:
|
||||
|
||||
```python
|
||||
# 获取用户的权限节点列表(带 Redis 缓存)
|
||||
def get_permissions_list(user_id: int) -> list:
|
||||
# 1. 先查 Redis 缓存
|
||||
cache_key = f"user:permissions:{user_id}"
|
||||
cached = redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
# src/app.py - _inject_dependencies()
|
||||
from src.modules.system.menu.service import get_user_permissions
|
||||
|
||||
# 2. 缓存未命中 → 查 DB(user → role → menu where type=1)
|
||||
permission_list = _query_permissions_from_db(user_id)
|
||||
class MenuPermissionProvider:
|
||||
"""权限数据提供方(适配 menu 服务)。"""
|
||||
def get_user_permissions(self, user_id):
|
||||
return get_user_permissions(user_id)
|
||||
|
||||
app.extensions['permission_provider'] = MenuPermissionProvider()
|
||||
```
|
||||
|
||||
### 权限缓存
|
||||
|
||||
权限列表通过 Redis 缓存,提高查询性能。缓存在角色菜单变更时主动失效。
|
||||
|
||||
# 3. 写入缓存
|
||||
redis.set(cache_key, json.dumps(permission_list), ex=3600)
|
||||
|
||||
@@ -98,7 +98,7 @@ def add_position(data):
|
||||
### 批量操作
|
||||
|
||||
```python
|
||||
def batch_delete_positions(id_list):
|
||||
def delete_positions(id_list):
|
||||
"""批量删除岗位"""
|
||||
# 批量软删除(仅 flush,不 commit)
|
||||
PositionRepository.batch_delete(id_list)
|
||||
|
||||
@@ -123,7 +123,7 @@ from wtforms import Form, IntegerField
|
||||
from wtforms.validators import Optional
|
||||
|
||||
|
||||
class BaseForm(Form):
|
||||
class BaseForm(FlaskForm):
|
||||
"""
|
||||
表单基类
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| Vite | 3.x | 下一代前端构建工具 |
|
||||
| Pinia | 2.x | 状态管理(Vuex 替代方案) |
|
||||
| Vue Router | 4.x | 路由管理 |
|
||||
| Axios | 1.x | HTTP 请求库 |
|
||||
| Axios | 0.27.x | HTTP 请求库 |
|
||||
| TypeScript | 5.x | 类型安全的 JavaScript 超集 |
|
||||
| Tailwind CSS | 3.x | 原子化 CSS 框架 |
|
||||
| SCSS | - | CSS 预处理器 |
|
||||
|
||||
@@ -74,7 +74,7 @@ class R:
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
# 成功
|
||||
return R.ok(data=user_info)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# IP/UA 解析
|
||||
|
||||
:::tip 说明
|
||||
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、限流、登录锁定等场景提供客户端身份信息。
|
||||
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、登录锁定等场景提供客户端身份信息。
|
||||
:::
|
||||
|
||||
## 架构总览
|
||||
@@ -21,8 +21,8 @@
|
||||
┌─────┴─────┐
|
||||
▼ ▼
|
||||
┌─────────┐ ┌──────────────────────────────────┐
|
||||
│ 限流 │ │ get_ip_region(ip) │
|
||||
│ 登录锁定 │ │ ├─ 内网/保留 IP → "内网IP" │
|
||||
│ 登录锁定 │ │ get_ip_region(ip) │
|
||||
│ 验证码防刷│ │ ├─ 内网/保留 IP → "内网IP" │
|
||||
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
|
||||
└─────────┘ │ └─ 异常降级 → "未知" │
|
||||
└──────────┬───────────────────────┘
|
||||
@@ -49,49 +49,40 @@
|
||||
### get_client_ip()
|
||||
|
||||
```python
|
||||
def get_client_ip() -> str:
|
||||
# src/common/utils/ip.py
|
||||
def get_client_ip():
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
|
||||
X-Forwarded-For 头:
|
||||
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
|
||||
直接使用 request.remote_addr(fail-closed,防止被直接暴露时伪造 XFF)
|
||||
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
|
||||
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
|
||||
request.remote_addr为真实客户端IP。未配置ProxyFix时,
|
||||
request.remote_addr即为直连IP,避免客户端伪造。
|
||||
"""
|
||||
from flask import request
|
||||
return request.remote_addr
|
||||
```
|
||||
|
||||
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
|
||||
|
||||
### 代理链解析原理
|
||||
|
||||
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
|
||||
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。项目通过 Flask 的 `ProxyFix` 中间件自动处理,在 `src/app.py` 中通过 `PROXY_NUM` 配置:
|
||||
|
||||
```
|
||||
客户端 (1.2.3.4)
|
||||
│
|
||||
▼
|
||||
外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4
|
||||
│
|
||||
▼
|
||||
内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1
|
||||
│
|
||||
▼
|
||||
Flask 应用
|
||||
request.remote_addr = 10.0.0.2
|
||||
X-Forwarded-For = "1.2.3.4, 10.0.0.1"
|
||||
```
|
||||
```python
|
||||
# src/app.py - create_app()
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。
|
||||
proxy_num = int(os.getenv('PROXY_NUM', 0))
|
||||
if proxy_num > 0:
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)
|
||||
```
|
||||
|
||||
### 安全设计:fail-closed
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.remote_addr` |
|
||||
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
|
||||
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
|
||||
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
|
||||
| `PROXY_NUM=0`(默认) | 不信任任何代理头,直接返回 `request.remote_addr` |
|
||||
| `PROXY_NUM>=1` | 信任 N 层代理,从 XFF 右侧取真实客户端 IP |
|
||||
| `remote_addr` 为 None | 回退到 `127.0.0.1` |
|
||||
|
||||
### 代理配置
|
||||
@@ -100,15 +91,15 @@ Flask 应用
|
||||
|
||||
```bash
|
||||
# 信任的代理层数(0 = 不信任代理头)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
PROXY_NUM=1
|
||||
|
||||
# 可信代理IP白名单(逗号分隔)
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
PROXY_NUM=127.0.0.1
|
||||
```
|
||||
|
||||
**常见部署配置**:
|
||||
|
||||
| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS |
|
||||
| 部署方式 | PROXY_NUM | PROXY_NUM |
|
||||
|:---------|:----|:----|
|
||||
| 直连(无代理) | `0` | 留空 |
|
||||
| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP |
|
||||
@@ -116,7 +107,7 @@ TRUSTED_PROXY_IPS=127.0.0.1
|
||||
| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) |
|
||||
|
||||
:::warning 安全提示
|
||||
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。
|
||||
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `PROXY_NUM=0`。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定。
|
||||
:::
|
||||
|
||||
## 二、IP 地理位置查询
|
||||
@@ -339,29 +330,7 @@ os_name, browser_name = parse_user_agent(ua)
|
||||
| `os` | `VARCHAR(100)` | 操作系统 |
|
||||
| `browser` | `VARCHAR(100)` | 浏览器 |
|
||||
|
||||
### 3. 请求限流
|
||||
|
||||
基于 IP 的 Redis 滑动窗口限流,防止接口被刷:
|
||||
|
||||
```python
|
||||
# src/middleware/rate_limit.py
|
||||
client_ip = get_client_ip()
|
||||
current_key = f"rl:{client_ip}:{window_index}"
|
||||
count = sliding_window_incr(redis, current_key, ...)
|
||||
if count > RATE_LIMIT_LIMIT:
|
||||
return R.failed(msg="请求过于频繁,请 N 秒后再试")
|
||||
```
|
||||
|
||||
**配置项**(`.env`):
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:-----|:-------|:-----|
|
||||
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
|
||||
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每 IP 最大请求数 |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 窗口时长(秒) |
|
||||
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis key 前缀 |
|
||||
|
||||
### 4. 登录失败锁定
|
||||
### 3. 登录失败锁定
|
||||
|
||||
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
|
||||
|
||||
@@ -380,7 +349,7 @@ if locked:
|
||||
| 失败计数 | `login:fail:{ip}:{username}` | 累计失败次数 |
|
||||
| 锁定状态 | `login:lock:{ip}:{username}` | 锁定时自动设置 TTL |
|
||||
|
||||
### 5. 验证码防刷
|
||||
### 4. 验证码防刷
|
||||
|
||||
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
|
||||
|
||||
@@ -402,21 +371,7 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT:
|
||||
# 可信代理配置
|
||||
# ============================================================
|
||||
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF)
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
|
||||
# ============================================================
|
||||
# 请求限流配置
|
||||
# ============================================================
|
||||
# 是否启用请求限流(默认关闭)
|
||||
RATE_LIMIT_ENABLED=False
|
||||
# 单窗口内每个 IP 允许的最大请求数
|
||||
RATE_LIMIT_LIMIT=100
|
||||
# 统计窗口时长(秒)
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
# Redis 计数 key 前缀
|
||||
RATE_LIMIT_KEY_PREFIX=rl:
|
||||
PROXY_NUM=1
|
||||
```
|
||||
|
||||
## 六、常见问题
|
||||
@@ -425,7 +380,7 @@ RATE_LIMIT_KEY_PREFIX=rl:
|
||||
|
||||
**原因**:本地直连无代理,`request.remote_addr` 就是 `127.0.0.1`。
|
||||
|
||||
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
|
||||
**说明**:这是正常行为。生产环境配置 `PROXY_NUM` 后即可获取真实客户端 IP。
|
||||
|
||||
### 地理位置显示为"未知"
|
||||
|
||||
@@ -443,14 +398,6 @@ RATE_LIMIT_KEY_PREFIX=rl:
|
||||
|
||||
**说明**:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
|
||||
|
||||
### 限流不生效
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True`
|
||||
2. 检查 Redis 连接是否正常
|
||||
3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.remote_addr`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设
|
||||
|
||||
## 七、总结
|
||||
|
||||
| 模块 | 文件 | 核心能力 | 降级策略 |
|
||||
@@ -459,4 +406,4 @@ RATE_LIMIT_KEY_PREFIX=rl:
|
||||
| 地理位置 | `src/common/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` |
|
||||
| UA 解析 | `src/common/utils/user_agent.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` |
|
||||
|
||||
三者组合为登录日志、操作日志、限流、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
|
||||
三者组合为登录日志、操作日志、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# 日志系统
|
||||
|
||||
:::tip 说明
|
||||
项目通过 Python 标准库 `logging` 实现结构化日志,支持控制台 + 文件双输出,日志文件按大小自动轮转。配置入口位于 `src/core/logger.py`。
|
||||
:::
|
||||
|
||||
## 配置入口
|
||||
|
||||
日志配置位于 `src/core/logger.py`:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR = Path(__file__).resolve().parents[2] / "logs"
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 日志格式
|
||||
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||
|
||||
# 轮转配置
|
||||
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
||||
LOG_BACKUP_COUNT = 5
|
||||
```
|
||||
|
||||
## setup_logging()
|
||||
|
||||
```python
|
||||
def setup_logging(level: int = logging.INFO, log_file: str = "app.log") -> None:
|
||||
"""
|
||||
配置全局日志:root logger 输出到控制台与文件
|
||||
|
||||
Args:
|
||||
level: 日志级别(默认 INFO)
|
||||
log_file: 日志文件名(位于 logs/ 目录下)
|
||||
"""
|
||||
```
|
||||
|
||||
### 控制台输出
|
||||
|
||||
```python
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(formatter)
|
||||
root.addHandler(console)
|
||||
```
|
||||
|
||||
### 文件输出(按大小轮转)
|
||||
|
||||
```python
|
||||
file_handler = RotatingFileHandler(
|
||||
LOG_DIR / log_file,
|
||||
maxBytes=LOG_MAX_BYTES, # 单文件上限 10MB
|
||||
backupCount=LOG_BACKUP_COUNT, # 保留 5 份备份
|
||||
encoding="utf-8",
|
||||
)
|
||||
```
|
||||
|
||||
## 日志轮转
|
||||
|
||||
当日志文件达到 10MB 时自动轮转:
|
||||
|
||||
```
|
||||
logs/app.log ← 当前日志
|
||||
logs/app.log.1 ← 最近一次轮转
|
||||
logs/app.log.2 ← 倒数第二次
|
||||
logs/app.log.3
|
||||
logs/app.log.4
|
||||
logs/app.log.5 ← 最早的备份(超过自动删除)
|
||||
```
|
||||
|
||||
## 日志格式
|
||||
|
||||
```
|
||||
2025-03-06 14:30:25,123 | INFO | modules.system.user.service | 用户登录成功
|
||||
```
|
||||
|
||||
格式说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `asctime` | 时间戳 |
|
||||
| `levelname` | 日志级别(右对齐7字符) |
|
||||
| `name` | 模块名称 |
|
||||
| `message` | 日志内容 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
在各模块中获取 logger:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("操作成功")
|
||||
logger.warning("配置缺失")
|
||||
logger.error(f"异常信息: {str(e)}")
|
||||
```
|
||||
|
||||
## 框架日志集成
|
||||
|
||||
Flask 和 SQLAlchemy 的默认日志统一走 root logger:
|
||||
|
||||
```python
|
||||
for name in ("flask", "sqlalchemy"):
|
||||
lg = logging.getLogger(name)
|
||||
lg.handlers.clear()
|
||||
lg.propagate = True
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
|
||||
:::
|
||||
|
||||
## 操作日志
|
||||
|
||||
除了应用日志外,还通过 `src/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `flask_operation_log` 表中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
|
||||
|
||||
## 总结
|
||||
|
||||
日志系统通过 `RotatingFileHandler` 实现按大小轮转,单文件上限 10MB 保留 5 份备份,防止日志无限增长撑爆磁盘。控制台和文件双输出,格式统一包含时间戳、级别、模块名和消息内容。
|
||||
@@ -4,22 +4,22 @@
|
||||
`src/common/utils/request.py` 提供请求数据解析相关的工具函数,包括批量 ID 解析、SQL LIKE 安全处理、分页参数解析等。这些工具函数在各业务模块中广泛使用。
|
||||
:::
|
||||
|
||||
## parse_id_list
|
||||
## parse_batch_ids
|
||||
|
||||
从请求体解析批量删除的 ID 数组,校验并转为整数列表。
|
||||
|
||||
```python
|
||||
from flask import request
|
||||
from src.common.utils.request import parse_id_list
|
||||
from src.common.utils.request import parse_batch_ids
|
||||
|
||||
def parse_id_list(request) -> list:
|
||||
def parse_batch_ids(request) -> list:
|
||||
"""
|
||||
从请求体解析 ID 数组
|
||||
请求体格式: [1, 2, 3]
|
||||
返回: [1, 2, 3]
|
||||
|
||||
示例:
|
||||
>>> parse_id_list(request)
|
||||
>>> parse_batch_ids(request)
|
||||
[1, 2, 3]
|
||||
"""
|
||||
ids = request.get_json()
|
||||
@@ -38,9 +38,9 @@ def parse_id_list(request) -> list:
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
from src.modules.system.position.service import delete_positions
|
||||
id_list = parse_batch_ids(request)
|
||||
return delete_positions(id_list)
|
||||
```
|
||||
|
||||
## parse_request_body
|
||||
@@ -207,4 +207,4 @@ def get_client_ip() -> str:
|
||||
|
||||
## 总结
|
||||
|
||||
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_id_list` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
|
||||
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_batch_ids` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
|
||||
|
||||
@@ -256,8 +256,8 @@ update_example_status = _example_service.update_status
|
||||
```python
|
||||
from flask import Blueprint, request
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.core import response as R
|
||||
from src.common.utils.request import parse_request_body, parse_batch_ids
|
||||
|
||||
example_blue = Blueprint('example', __name__, url_prefix='/example')
|
||||
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
首次部署时,使用初始化脚本一键完成建库、建表、Flask-Migrate stamp:
|
||||
|
||||
```bash
|
||||
# 方式一:直接运行脚本
|
||||
python scripts/setup_db.py
|
||||
|
||||
# 方式二:Make 命令
|
||||
make db-init
|
||||
```
|
||||
|
||||
:::tip 初始化流程
|
||||
@@ -40,7 +36,7 @@ make db-init
|
||||
flask db migrate -m "描述变更内容"
|
||||
|
||||
# 或使用 Make 命令
|
||||
make migrate-revision
|
||||
flask db migrate
|
||||
```
|
||||
|
||||
:::tip 注意事项
|
||||
@@ -56,7 +52,7 @@ make migrate-revision
|
||||
flask db upgrade
|
||||
|
||||
# 或使用 Make 命令
|
||||
make migrate-upgrade
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
### 回滚迁移
|
||||
@@ -87,7 +83,7 @@ flask db history
|
||||
flask db stamp head
|
||||
|
||||
# 或使用 Make 命令
|
||||
make migrate-stamp
|
||||
flask db stamp head
|
||||
```
|
||||
|
||||
## 跨库数据迁移
|
||||
@@ -111,7 +107,7 @@ make migrate-stamp
|
||||
|
||||
| 目标库 | 驱动包 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| PostgreSQL | psycopg[binary] | psycopg3 |
|
||||
| PostgreSQL | psycopg2-binary | psycopg2 |
|
||||
| SQL Server | pymssql | - |
|
||||
| SQLite | 内置 | 无需额外驱动 |
|
||||
| Oracle | oracledb | thin 模式,无需安装 Oracle Client |
|
||||
@@ -362,14 +358,14 @@ CREATE SEQUENCE flask_user_id_seq START WITH <max_id+1>;
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 全新安装 | `make db-init` |
|
||||
| 模型变更后生成迁移 | `make migrate-revision` |
|
||||
| 应用迁移 | `make migrate-upgrade` |
|
||||
| 全新安装 | `python scripts/setup_db.py` |
|
||||
| 模型变更后生成迁移 | `flask db migrate` |
|
||||
| 应用迁移 | `flask db upgrade` |
|
||||
| 回滚一次迁移 | `flask db downgrade -1` |
|
||||
| 标记已有库为最新 | `make migrate-stamp` |
|
||||
| 标记已有库为最新 | `flask db stamp head` |
|
||||
| 跨库迁移干跑 | `make db-migrate-dry-run ARGS="..."` |
|
||||
| 跨库迁移执行 | `make db-migrate ARGS="..."` |
|
||||
|
||||
## 总结
|
||||
|
||||
数据库迁移分为结构迁移(Flask-Migrate / Alembic)和数据迁移(`migrate_db.py`)两部分。全新安装用 `make db-init`,模型变更用 Flask-Migrate 自动生成迁移脚本,跨库切换用 `migrate_db.py` 数据泵。建议每次迁移前先干跑确认,生产环境务必做好备份。
|
||||
数据库迁移分为结构迁移(Flask-Migrate / Alembic)和数据迁移(`migrate_db.py`)两部分。全新安装用 `python scripts/setup_db.py`,模型变更用 Flask-Migrate 自动生成迁移脚本,跨库切换用 `migrate_db.py` 数据泵。建议每次迁移前先干跑确认,生产环境务必做好备份。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## Dockerfile 详解
|
||||
|
||||
项目根目录 `Dockerfile` 完整内容:
|
||||
`docker/Dockerfile` 完整内容:
|
||||
|
||||
```dockerfile
|
||||
# 基础镜像:Python 3.11-slim
|
||||
|
||||
@@ -113,18 +113,18 @@ Docker 部署时,`docker-compose.yml` 通过 `env_file: .env` 注入环境变
|
||||
| DB_DRIVER | 数据库 | 依赖包 | 连接串格式 |
|
||||
|-----------|--------|--------|-----------|
|
||||
| mysql(默认) | MySQL 8.x | PyMySQL | `mysql+pymysql://user:pass@host:3306/db` |
|
||||
| postgresql | PostgreSQL 14+ | psycopg[binary] | `postgresql+psycopg://user:pass@host:5432/db` |
|
||||
| postgresql | PostgreSQL 14+ | psycopg2-binary | `postgresql+psycopg2://user:pass@host:5432/db` |
|
||||
| mssql | SQL Server 2019+ | pymssql | `mssql+pymssql://user:pass@host:1433/db` |
|
||||
| sqlite | SQLite 3 | 内置 | `sqlite:///path/to/db.sqlite3` |
|
||||
| oracle | Oracle 12c+ | oracledb | `oracle+oracledb://user:pass@host:1521/db` |
|
||||
|
||||
:::tip 切换数据库后
|
||||
切换数据库驱动后,需要重新初始化数据库结构。项目 Makefile 提供常用命令:
|
||||
切换数据库驱动后,需要重新初始化数据库结构。常用命令:
|
||||
```bash
|
||||
make db-init # 初始化数据库(建库 + SQL 导入 + 补表 + flask db stamp)
|
||||
make migrate-revision # 生成增量迁移脚本
|
||||
make migrate-upgrade # 应用迁移
|
||||
make migrate-stamp # 标记当前库已到基线
|
||||
python scripts/setup_db.py # 初始化数据库(建库 + SQL 导入 + 补表 + flask db stamp)
|
||||
flask db migrate # 生成增量迁移脚本
|
||||
flask db upgrade # 应用迁移
|
||||
flask db stamp head # 标记当前库已到基线
|
||||
make db-migrate ARGS="..." # 跨库数据迁移
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -18,16 +18,14 @@
|
||||
Flask 应用提供健康检查路由,可用于负载均衡器或容器编排的探针配置:
|
||||
|
||||
```bash
|
||||
# 基础健康检查
|
||||
GET /api/v1/health
|
||||
# 基础健康检查(无需认证,已在登录白名单中)
|
||||
GET /health
|
||||
|
||||
# 响应示例
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"uptime": 3600
|
||||
"status": "ok"
|
||||
},
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
@@ -38,7 +36,7 @@ GET /api/v1/health
|
||||
在 Dockerfile 中配置 HEALTHCHECK:
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8021/api/v1/health || exit 1
|
||||
CMD curl -f http://localhost:8021/health || exit 1
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -79,7 +77,7 @@ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
|
||||
```
|
||||
2026-09-05 10:30:15 [INFO] src.app: Application startup complete
|
||||
2026-09-05 10:30:16 [WARNING] src.middleware.rate_limit: Rate limit exceeded for 192.168.1.100
|
||||
2026-09-05 10:30:16 [WARNING] src.middleware.auth: Login attempt locked for user admin from 192.168.1.100
|
||||
2026-09-05 10:30:17 [ERROR] src.modules.auth.service: Login failed for user admin
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Nginx 作为反向代理服务器,承担静态资源服务、API 请求转发
|
||||
|
||||
## 基础配置
|
||||
|
||||
项目提供了 Nginx 配置模板 `deploy/nginx.conf`,按需修改即可使用:
|
||||
以下为推荐的 Nginx 配置模板,按需修改后保存为 `/etc/nginx/conf.d/djangoadmin.conf`:
|
||||
|
||||
```nginx
|
||||
upstream flask_backend {
|
||||
@@ -101,8 +101,7 @@ cd ui && pnpm build
|
||||
mkdir -p /data/apps/djangoadmin/frontend
|
||||
cp -r ui/dist /data/apps/djangoadmin/frontend/
|
||||
|
||||
# 3. 拷贝 Nginx 配置
|
||||
cp deploy/nginx.conf /etc/nginx/conf.d/djangoadmin.conf
|
||||
# 3. 将上方配置保存为 /etc/nginx/conf.d/djangoadmin.conf
|
||||
|
||||
# 4. 修改配置中的实际值(server_name、root 等)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Supervisor 是一个 Python 编写的进程管理工具,用于在 Linux 服务
|
||||
|
||||
## 配置文件示例
|
||||
|
||||
项目提供了 Supervisor 配置模板 `deploy/supervisor.conf`,完整内容:
|
||||
以下为推荐的 Supervisor 配置模板,按需修改后保存到 `/etc/supervisor/conf.d/`:
|
||||
|
||||
```ini
|
||||
; Supervisor 进程管理示例配置
|
||||
@@ -47,7 +47,7 @@ environment=PYTHONUNBUFFERED=1
|
||||
# 1. 修改 command 中的虚拟环境路径
|
||||
# 2. 修改 directory 为项目实际路径
|
||||
# 3. 修改 stdout_logfile 为日志实际路径
|
||||
cp deploy/supervisor.conf /etc/supervisor/conf.d/djangoadmin-flask.conf
|
||||
# 将上方配置保存为 /etc/supervisor/conf.d/djangoadmin-flask.conf
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -192,4 +192,4 @@ startsecs=10
|
||||
|
||||
## 总结
|
||||
|
||||
Supervisor 是 Linux 环境下管理 Flask 进程的可靠方案,提供自动重启、日志轮转、进程监控等能力。配置文件位于 `deploy/supervisor.conf`,修改路径后复制到 `/etc/supervisor/conf.d/` 即可使用。生产环境建议配合 systemd 管理 Supervisor 自身的启动。
|
||||
Supervisor 是 Linux 环境下管理 Flask 进程的可靠方案,提供自动重启、日志轮转、进程监控等能力。按上方模板创建配置文件,修改路径后复制到 `/etc/supervisor/conf.d/` 即可使用。生产环境建议配合 systemd 管理 Supervisor 自身的启动。
|
||||
|
||||
@@ -94,8 +94,6 @@ flask db history
|
||||
# 执行迁移
|
||||
flask db upgrade
|
||||
|
||||
# 或使用 Make 命令
|
||||
make migrate-upgrade
|
||||
```
|
||||
|
||||
:::warning 迁移前确认
|
||||
@@ -124,7 +122,7 @@ sudo systemctl restart djangoadmin-flask
|
||||
sudo supervisorctl status djangoadmin-flask
|
||||
|
||||
# 2. 检查健康端点
|
||||
curl -s http://127.0.0.1:8021/api/v1/health | python -m json.tool
|
||||
curl -s http://127.0.0.1:8021/health | python -m json.tool
|
||||
|
||||
# 3. 检查日志
|
||||
tail -f /data/apps/logs/supervisor.out.log
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
### R.ok() 成功响应
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
# 基础成功响应
|
||||
return R.ok()
|
||||
@@ -114,7 +114,7 @@ return R.page(data=records, total=100, current=1, size=10)
|
||||
## 在 Router 中使用
|
||||
|
||||
```python
|
||||
from src.core.response import R
|
||||
from src.core import response as R
|
||||
|
||||
# 查询详情:service 返回 dict,router 包 R.ok
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
|
||||
@@ -51,9 +51,9 @@ Content-Type: application/json
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
"""批量删除岗位"""
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
from src.modules.system.position.service import delete_positions
|
||||
id_list = parse_batch_ids(request)
|
||||
return delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 删除流程
|
||||
@@ -61,7 +61,7 @@ def batch_delete():
|
||||
```
|
||||
1. Router 接收请求
|
||||
↓
|
||||
2. parse_id_list(request) 解析 ID 数组
|
||||
2. parse_batch_ids(request) 解析 ID 数组
|
||||
↓
|
||||
3. Service.batch_delete(id_list)
|
||||
↓
|
||||
|
||||
@@ -136,7 +136,7 @@ wiki/ # VitePress 文档站
|
||||
│ ├── 3. 模块开发实战/ # volume2/(Model→Schema→Repo→Service→Router→前端)
|
||||
│ ├── 4. 架构设计/ # volume3/(分层、基类、缓存、安全)
|
||||
│ ├── 5. 开发指南/ # volume4/
|
||||
│ │ ├── 5.1 后端核心功能/ # core/(JWT、RBAC、字典、日志、限流、事务)
|
||||
│ │ ├── 5.1 后端核心功能/ # core/(JWT、RBAC、字典、日志、事务)
|
||||
│ │ ├── 5.2 后端业务模块/ # business/(用户、角色、菜单、文章、任务)
|
||||
│ │ ├── 5.3 后端通用工具/ # tools/(上传、Excel、IP/UA、密码、富文本)
|
||||
│ │ ├── 5.4 前端开发/ # frontend/(组件、路由、Store、构建)
|
||||
|
||||
@@ -96,9 +96,9 @@ def status():
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
from src.modules.system.position.service import delete_positions
|
||||
id_list = parse_batch_ids(request)
|
||||
return delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 权限标识规范
|
||||
@@ -126,8 +126,8 @@ def batch_delete():
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.core import response as R
|
||||
from src.common.utils.request import parse_request_body, parse_batch_ids
|
||||
|
||||
# 创建 Blueprint 实例
|
||||
position_blue = Blueprint('position', __name__, url_prefix='/position')
|
||||
@@ -191,9 +191,9 @@ def status():
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
def batch_delete():
|
||||
from src.modules.system.position.service import batch_delete_positions
|
||||
id_list = parse_id_list(request)
|
||||
return batch_delete_positions(id_list)
|
||||
from src.modules.system.position.service import delete_positions
|
||||
id_list = parse_batch_ids(request)
|
||||
return delete_positions(id_list)
|
||||
```
|
||||
|
||||
## 路由注册
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# WTForms Schema规范
|
||||
|
||||
## 概述
|
||||
|
||||
项目使用 WTForms 进行请求数据校验。所有请求体参数通过 WTForms 表单类定义,框架在调用 Router 前自动完成解析和验证。
|
||||
|
||||
:::tip 核心原则
|
||||
- 请求体参数必须声明为 WTForms 表单类(Form 类)
|
||||
- 字段约束使用 `validators` 定义
|
||||
- 自定义校验使用 `validate_` 方法
|
||||
- 唯一性校验在 Schema 中查询数据库
|
||||
:::
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 命名格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 创建/编辑表单 | `{模块}Form` | `PositionForm` |
|
||||
| 状态更新表单 | `{模块}StatusForm` | `PositionStatusForm` |
|
||||
| 查询参数表单 | `{模块}QueryForm` | `PositionQueryForm` |
|
||||
|
||||
:::tip 继承 BaseForm
|
||||
创建/编辑表单继承 `src.common.forms.base_form.BaseForm`,自动获得 `id` 字段(编辑时必填)。
|
||||
:::
|
||||
|
||||
## PositionForm 示例
|
||||
|
||||
以岗位模块为例,`src/modules/system/position/schema.py` 完整内容:
|
||||
|
||||
```python
|
||||
from wtforms import StringField, IntegerField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange
|
||||
|
||||
from src.common.forms.base_form import BaseForm
|
||||
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
sort = IntegerField('岗位排序', validators=[
|
||||
DataRequired(message='岗位排序不能为空'),
|
||||
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
|
||||
])
|
||||
|
||||
|
||||
class PositionStatusForm(BaseForm):
|
||||
"""岗位状态更新表单"""
|
||||
id = IntegerField('岗位ID', validators=[
|
||||
DataRequired(message='岗位ID不能为空'),
|
||||
NumberRange(min=1, message='岗位ID必须大于0')
|
||||
])
|
||||
status = IntegerField('岗位状态', validators=[
|
||||
DataRequired(message='岗位状态不能为空'),
|
||||
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
|
||||
])
|
||||
```
|
||||
|
||||
:::tip BaseForm 基类
|
||||
`BaseForm`(定义在 `src/common/forms/base_form.py`)提供 `id` 字段和数据清洗功能,自动将前端传入的空字符串 `''`/`'null'`/`'undefined'` 转为 `None`,避免 WTForms 解析报错。
|
||||
:::
|
||||
|
||||
## Validator 约束
|
||||
|
||||
| Validator | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `DataRequired` | any | 必填字段 | `DataRequired(message='不能为空')` |
|
||||
| `Length` | str | 字符串长度 | `Length(min=1, max=150)` |
|
||||
| `NumberRange` | int/float | 数值范围 | `NumberRange(min=0, max=99999)` |
|
||||
| `Optional` | any | 可选字段 | `Optional()` |
|
||||
| `Email` | str | 邮箱格式 | `Email(message='邮箱格式不正确')` |
|
||||
| `URL` | str | URL 格式 | `URL(message='URL格式不正确')` |
|
||||
| `Regexp` | str | 正则表达式 | `Regexp(r'^[a-zA-Z]+$')` |
|
||||
|
||||
:::tip Validator 说明
|
||||
- `DataRequired`:必填字段,不允许空值
|
||||
- `Length`:字符串长度限制
|
||||
- `NumberRange`:数值范围限制
|
||||
- `Optional`:可选字段
|
||||
:::
|
||||
|
||||
## 自定义校验器
|
||||
|
||||
### 唯一性校验
|
||||
|
||||
```python
|
||||
from wtforms import ValidationError
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
|
||||
|
||||
class PositionForm(BaseForm):
|
||||
"""岗位创建/编辑表单"""
|
||||
name = StringField('岗位名称', validators=[
|
||||
DataRequired(message='岗位名称不能为空'),
|
||||
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
|
||||
])
|
||||
|
||||
def validate_name(self, field):
|
||||
"""岗位名称唯一性校验"""
|
||||
exclude_id = self.id.data if self.id.data else None
|
||||
if PositionRepository.exists_by([Position.name == field.data], exclude_id):
|
||||
raise ValidationError('岗位名称不能重复')
|
||||
```
|
||||
|
||||
### 格式校验
|
||||
|
||||
```python
|
||||
from wtforms import ValidationError
|
||||
import re
|
||||
|
||||
|
||||
class UserForm(BaseForm):
|
||||
"""用户创建/编辑表单"""
|
||||
username = StringField('用户名', validators=[
|
||||
DataRequired(message='用户名不能为空'),
|
||||
Length(min=3, max=50, message='用户名长度必须在3-50个字符之间')
|
||||
])
|
||||
email = StringField('邮箱', validators=[
|
||||
DataRequired(message='邮箱不能为空'),
|
||||
Length(max=100, message='邮箱长度不能超过100个字符')
|
||||
])
|
||||
phone = StringField('手机号', validators=[
|
||||
Optional(),
|
||||
Length(max=20, message='手机号长度不能超过20个字符')
|
||||
])
|
||||
|
||||
def validate_username(self, field):
|
||||
"""用户名只允许字母、数字、下划线"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', field.data):
|
||||
raise ValidationError('用户名只允许字母、数字、下划线')
|
||||
|
||||
def validate_email(self, field):
|
||||
"""邮箱格式校验"""
|
||||
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', field.data):
|
||||
raise ValidationError('邮箱格式不正确')
|
||||
|
||||
def validate_phone(self, field):
|
||||
"""手机号格式校验(可选字段,有值时校验)"""
|
||||
if field.data and field.data != '':
|
||||
if not re.match(r'^1[3-9]\d{9}$', field.data):
|
||||
raise ValidationError('手机号格式不正确')
|
||||
```
|
||||
|
||||
:::tip validator 规则
|
||||
- 方法名必须为 `validate_{field_name}`
|
||||
- 校验失败抛出 `ValidationError(错误消息)`
|
||||
- 可选字段需判断 `None` 值
|
||||
:::
|
||||
|
||||
## 在 Router 中使用
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
:::tip 数据访问
|
||||
Service 层通过 `data['field']` 直接访问字段。WTForms 在进入 Router 之前已完成校验,Service 无需重复验证。
|
||||
:::
|
||||
|
||||
## 错误响应
|
||||
|
||||
WTForms 校验失败时,由框架自动转换:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "岗位名称不能为空",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
WTForms Schema 规范要求:表单类命名 `{Module}Form`,继承 `BaseForm`;字段约束用 `validators` 定义长度/范围;自定义校验用 `validate_{field_name}` 方法抛 `ValidationError`;唯一性校验通过 `Repository.exists_by(filters, exclude_id)` 实现。框架自动解析和验证,Service 通过 `form.data['field']` 访问已校验的数据。
|
||||
@@ -2,134 +2,234 @@
|
||||
|
||||
## 概述
|
||||
|
||||
Router 层是 HTTP 接口层,负责定义路由、权限装饰器和请求参数绑定。每个业务模块对应一个 router 文件,所有业务逻辑委托给 Service 层处理。
|
||||
Router 层是 HTTP 接口层,负责定义路由、权限装饰器和请求参数解析。每个业务模块对应一个 router 文件,所有业务逻辑委托给 Service 层处理。
|
||||
|
||||
:::tip 设计原则
|
||||
- Router 层不含业务逻辑,仅做路由定义、权限校验和参数绑定
|
||||
- 装饰器顺序固定为:路由 → 权限
|
||||
- Service 函数使用延迟导入,避免循环引用
|
||||
- Router 层不含业务逻辑,仅做路由定义、权限校验和参数解析
|
||||
- 装饰器顺序固定为:路由 → 权限 → 演示模式 → 操作日志
|
||||
- Service 模块在文件顶部统一导入(`from src.modules.system.position import service as position`)
|
||||
- Router 负责调用 `R.ok()` / `R.failed()` 封装响应,Service 不返回 R 对象
|
||||
:::
|
||||
|
||||
## 完整示例
|
||||
|
||||
以岗位模块为例,`src/modules/system/position/router.py` 完整内容:
|
||||
以岗位模块为例,以下为 `src/modules/system/position/router.py` 的核心代码(省略版权头):
|
||||
|
||||
```python
|
||||
"""
|
||||
岗位管理控制器模块
|
||||
=============================================================
|
||||
|
||||
提供岗位(Position)相关的API接口,包括:
|
||||
- 岗位分页查询
|
||||
- 岗位详情查询
|
||||
- 岗位添加、更新、删除
|
||||
- 岗位状态设置
|
||||
- 岗位数据列表获取
|
||||
"""
|
||||
from flask import Blueprint, request
|
||||
|
||||
from src.middleware import check_demo, operation_log
|
||||
from src.core.permission import check_permission
|
||||
from src.core.response import R
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
from src.modules.system.position import service as position
|
||||
from src.common.utils.request import parse_batch_ids, parse_request_body
|
||||
|
||||
from src.core import response as R
|
||||
|
||||
# ============================================================
|
||||
# 创建 Blueprint 实例
|
||||
# ============================================================
|
||||
position_blue = Blueprint('position', __name__, url_prefix='/position')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询分页数据
|
||||
# 岗位分页查询
|
||||
# ============================================================
|
||||
@position_blue.route('/page', methods=['GET'])
|
||||
@check_permission('sys:position:page')
|
||||
|
||||
@position_blue.route('/page', methods=["GET"])
|
||||
@check_permission("sys:position:page")
|
||||
@operation_log("岗位管理", "查询")
|
||||
def page():
|
||||
"""获取岗位分页列表"""
|
||||
from src.modules.system.position.service import get_position_page
|
||||
return get_position_page()
|
||||
"""查询岗位分页数据"""
|
||||
return R.ok(data=position.get_position_page(request.args))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询岗位详情
|
||||
# 岗位详情查询
|
||||
# ============================================================
|
||||
@position_blue.route('/detail/<int:id>', methods=['GET'])
|
||||
@check_permission('sys:position:detail')
|
||||
|
||||
@position_blue.route('/detail/<int:id>', methods=["GET"])
|
||||
@check_permission("sys:position:detail")
|
||||
@operation_log("岗位管理", "详情")
|
||||
def detail(id):
|
||||
"""根据岗位ID查询详细信息"""
|
||||
from src.modules.system.position.service import get_position_detail
|
||||
return R.ok(data=get_position_detail(id))
|
||||
"""查询岗位详情"""
|
||||
data = position.get_position_detail(id)
|
||||
return R.ok(data=data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 获取岗位列表
|
||||
# 岗位添加
|
||||
# ============================================================
|
||||
@position_blue.route('/list', methods=['GET'])
|
||||
@check_permission('sys:position:list')
|
||||
def get_position_list():
|
||||
"""获取所有岗位列表(无分页,用于下拉选择)"""
|
||||
from src.modules.system.position.service import get_position_list
|
||||
return R.ok(data=get_position_list())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 添加岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/add', methods=['POST'])
|
||||
@check_permission('sys:position:add')
|
||||
@position_blue.route('/add', methods=["POST"])
|
||||
@check_permission("sys:position:add")
|
||||
@check_demo
|
||||
@operation_log("岗位管理", "新增")
|
||||
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)
|
||||
"""添加岗位"""
|
||||
json_data, err = parse_request_body()
|
||||
if err:
|
||||
return R.failed(msg=err)
|
||||
position.add_position(json_data)
|
||||
return R.ok(msg="添加成功")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 更新岗位
|
||||
# 岗位更新
|
||||
# ============================================================
|
||||
@position_blue.route('/update', methods=['PUT'])
|
||||
@check_permission('sys:position:update')
|
||||
|
||||
@position_blue.route('/update', methods=["PUT"])
|
||||
@check_permission("sys:position:update")
|
||||
@check_demo
|
||||
@operation_log("岗位管理", "修改")
|
||||
def update():
|
||||
"""更新岗位信息"""
|
||||
from src.modules.system.position.service import update_position
|
||||
from src.modules.system.position.schema import PositionForm
|
||||
data = parse_request_body(request, PositionForm)
|
||||
return update_position(data)
|
||||
"""更新岗位"""
|
||||
json_data, err = parse_request_body()
|
||||
if err:
|
||||
return R.failed(msg=err)
|
||||
position.update_position(json_data)
|
||||
return R.ok(msg="更新成功")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 删除岗位
|
||||
# 岗位删除
|
||||
# ============================================================
|
||||
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
|
||||
@check_permission('sys:position:delete')
|
||||
|
||||
@position_blue.route('/delete/<string:id>', methods=["DELETE"])
|
||||
@check_permission("sys:position:delete")
|
||||
@check_demo
|
||||
@operation_log("岗位管理", "删除")
|
||||
def delete(id):
|
||||
"""根据ID删除单个岗位"""
|
||||
from src.modules.system.position.service import delete_position
|
||||
return delete_position(id)
|
||||
"""删除岗位(支持逗号分隔的多个ID)"""
|
||||
count = position.delete_positions(id)
|
||||
return R.ok(msg=f"本次共删除{count}条数据")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置岗位状态
|
||||
# 岗位批量删除
|
||||
# ============================================================
|
||||
@position_blue.route('/status', methods=['PUT'])
|
||||
@check_permission('sys:position:status')
|
||||
def status():
|
||||
"""修改岗位状态(启用/禁用)"""
|
||||
from src.modules.system.position.service import update_position_status
|
||||
from src.modules.system.position.schema import PositionStatusForm
|
||||
data = parse_request_body(request, PositionStatusForm)
|
||||
return update_position_status(data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 批量删除岗位
|
||||
# ============================================================
|
||||
@position_blue.route('/batchDelete', methods=['DELETE'])
|
||||
@check_permission('sys:position:batchDelete')
|
||||
@position_blue.route('/batchDelete', methods=["DELETE"])
|
||||
@check_permission("sys:position:batchDelete")
|
||||
@check_demo
|
||||
@operation_log("岗位管理", "批量删除")
|
||||
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)
|
||||
id_str, err = parse_batch_ids()
|
||||
if err:
|
||||
return err
|
||||
count = position.delete_positions(id_str)
|
||||
return R.ok(msg=f"本次共删除{count}条数据")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位状态设置
|
||||
# ============================================================
|
||||
|
||||
@position_blue.route('/status', methods=["PUT"])
|
||||
@check_permission("sys:position:status")
|
||||
@check_demo
|
||||
@operation_log("岗位管理", "状态变更")
|
||||
def status():
|
||||
"""设置岗位状态"""
|
||||
json_data, err = parse_request_body()
|
||||
if err:
|
||||
return R.failed(msg=err)
|
||||
position.update_position_status(json_data)
|
||||
return R.ok(msg="设置成功")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 岗位列表获取(无分页)
|
||||
# ============================================================
|
||||
|
||||
@position_blue.route('/list', methods=["GET"])
|
||||
@check_permission("sys:position:list")
|
||||
@operation_log("岗位管理", "查询")
|
||||
def get_position_list():
|
||||
"""获取岗位数据列表(无分页,用于下拉选择)"""
|
||||
result = position.get_position_list(request.args)
|
||||
return R.ok(data=result)
|
||||
```
|
||||
|
||||
## 装饰器说明
|
||||
|
||||
| 装饰器 | 作用 | 位置 |
|
||||
| 装饰器 | 作用 | 适用场景 |
|
||||
|:----|:----|:----|
|
||||
| `@position_blue.route()` | 定义 HTTP 方法和路径 | 最外层 |
|
||||
| `@check_permission('sys:position:page')` | RBAC 权限校验 | 路由装饰器之后 |
|
||||
| `@position_blue.route()` | 定义 HTTP 方法和路径 | 所有接口 |
|
||||
| `@check_permission('sys:position:page')` | RBAC 权限校验 | 所有接口 |
|
||||
| `@check_demo` | 演示模式拦截(禁止写操作) | 写操作(add/update/delete/status/batchDelete) |
|
||||
| `@operation_log("岗位管理", "查询")` | 操作日志自动记录 | 所有接口 |
|
||||
|
||||
**装饰器顺序必须严格遵守**:路由 → 权限。顺序错误会导致功能异常。
|
||||
**装饰器顺序必须严格遵守**:路由 → 权限 → 演示模式 → 操作日志。
|
||||
|
||||
## 导入规范
|
||||
|
||||
```python
|
||||
# 模块顶部统一导入
|
||||
from src.middleware import check_demo, operation_log
|
||||
from src.core.permission import check_permission
|
||||
from src.modules.system.position import service as position
|
||||
from src.common.utils.request import parse_batch_ids, parse_request_body
|
||||
from src.core import response as R
|
||||
```
|
||||
|
||||
| 导入项 | 来源 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `check_demo` | `src.middleware` | 演示模式装饰器 |
|
||||
| `operation_log` | `src.middleware` | 操作日志装饰器 |
|
||||
| `check_permission` | `src.core.permission` | 权限校验装饰器 |
|
||||
| `service as position` | 业务模块 | Service 层代理函数 |
|
||||
| `parse_request_body` | `src.common.utils.request` | 解析请求体 JSON |
|
||||
| `parse_batch_ids` | `src.common.utils.request` | 解析批量删除的 ID 数组 |
|
||||
| `R` | `src.core` | 统一响应封装 |
|
||||
|
||||
## 请求参数解析
|
||||
|
||||
```python
|
||||
# 解析请求体(返回元组:数据, 错误信息)
|
||||
json_data, err = parse_request_body()
|
||||
if err:
|
||||
return R.failed(msg=err)
|
||||
|
||||
# 解析批量删除的ID数组(返回元组:ID字符串, 错误信息)
|
||||
id_str, err = parse_batch_ids()
|
||||
if err:
|
||||
return err
|
||||
```
|
||||
|
||||
| 函数 | 作用 | 返回值 |
|
||||
|:----|:----|:----|
|
||||
| `parse_request_body()` | 从请求体解析 JSON | `(dict, None)` 或 `(None, error_msg)` |
|
||||
| `parse_batch_ids()` | 从请求体解析 ID 数组 | `(id_str, None)` 或 `(None, R.failed(...))` |
|
||||
|
||||
## 响应封装
|
||||
|
||||
Router 层负责调用 `R.ok()` / `R.failed()` 封装响应:
|
||||
|
||||
```python
|
||||
# 查询类:Service 返回数据,Router 用 R.ok(data=...) 包装
|
||||
return R.ok(data=position.get_position_page(request.args))
|
||||
|
||||
# 写操作类:Service 执行成功后无返回值,Router 返回成功消息
|
||||
position.add_position(json_data)
|
||||
return R.ok(msg="添加成功")
|
||||
|
||||
# 删除类:Service 返回删除数量,Router 格式化消息
|
||||
count = position.delete_positions(id)
|
||||
return R.ok(msg=f"本次共删除{count}条数据")
|
||||
```
|
||||
|
||||
:::warning Service 不返回 R 对象
|
||||
Service 层入参为数据字典、出参为纯数据(或 None),业务失败通过 `raise BizError` 表达。Router 层负责封装 `R.ok()` / `R.failed()` 响应。
|
||||
:::
|
||||
|
||||
## 权限字符串命名规范
|
||||
|
||||
@@ -148,28 +248,6 @@ sys:{module}:{action}
|
||||
| `batchDelete` | DELETE | 批量删除 |
|
||||
| `status` | PUT | 状态变更 |
|
||||
|
||||
## 请求参数解析
|
||||
|
||||
```python
|
||||
from src.common.utils.request import parse_request_body, parse_id_list
|
||||
|
||||
# 解析请求体
|
||||
data = parse_request_body(request, PositionForm)
|
||||
|
||||
# 解析ID列表
|
||||
id_list = parse_id_list(request)
|
||||
```
|
||||
|
||||
| 函数 | 作用 |
|
||||
|:----|:----|
|
||||
| `parse_request_body(request, FormClass)` | 从请求体解析 JSON 并校验 |
|
||||
| `parse_id_list(request)` | 从请求体解析 ID 数组 |
|
||||
|
||||
## 响应封装
|
||||
|
||||
- **Service 返回 R 对象的**(如 `add_position`、`update_position`):直接 `return`
|
||||
- **Service 返回原始数据的**(如 `get_position_detail`):用 `R.ok(data=...)` 包装
|
||||
|
||||
## 模块注册
|
||||
|
||||
在 `src/routes/v1.py` 中注册模块:
|
||||
@@ -186,13 +264,13 @@ BLUEPRINTS = [
|
||||
|
||||
## 开发要点
|
||||
|
||||
1. **Router 层不含业务逻辑**:仅做路由定义、权限校验和参数绑定
|
||||
2. **装饰器顺序**:路由 → 权限
|
||||
3. **使用 `parse_request_body` 解析请求体**:自动执行 WTForms 校验
|
||||
4. **使用 `parse_id_list` 解析批量操作的 ID 数组**
|
||||
5. **Service 函数使用延迟导入**:避免循环引用
|
||||
6. **权限字符串与代码完全一致**:确保权限校验正确
|
||||
1. **Router 层不含业务逻辑**:仅做路由定义、权限校验和参数解析
|
||||
2. **装饰器顺序**:路由 → 权限 → 演示模式 → 操作日志
|
||||
3. **Service 模块顶部统一导入**:`from src.modules.system.position import service as position`
|
||||
4. **使用 `parse_request_body()` 解析请求体**:返回 `(data, err)` 元组
|
||||
5. **使用 `parse_batch_ids()` 解析批量操作的 ID 数组**
|
||||
6. **Router 负责封装响应**:Service 不返回 R 对象
|
||||
|
||||
## 总结
|
||||
|
||||
Router 层是 HTTP 请求的入口,负责路由定义、权限校验和参数绑定,不含业务逻辑。装饰器顺序固定为路由 → 权限,使用 `parse_request_body` 解析请求体,使用 `parse_id_list` 解析批量操作的 ID 数组。Service 函数使用延迟导入,避免循环引用。
|
||||
Router 层是 HTTP 请求的入口,负责路由定义、权限校验和参数解析,不含业务逻辑。装饰器顺序固定为路由 → 权限 → 演示模式 → 操作日志。Service 模块在文件顶部统一导入,Router 负责调用 `R.ok()` / `R.failed()` 封装响应,Service 不返回 R 对象。
|
||||
|
||||
@@ -44,7 +44,7 @@ router.py → 路由控制器(Blueprint)
|
||||
- **后端三层分离**:Endpoint(HTTP)→ Service(业务)→ Repository(数据),职责清晰
|
||||
- **基类复用**:BaseRepository / BaseService 提供 30+ 通用方法,子类只需声明差异
|
||||
- **模块可插拔**:业务模块位于 `src/modules/`,按需增删不影响框架
|
||||
- **中间件可扩展**:CORS、限流、日志、事务按需注册,链式处理
|
||||
- **中间件可扩展**:CORS、日志、事务按需注册,链式处理
|
||||
|
||||
### 3. 多数据库支持,数据可迁移
|
||||
|
||||
@@ -66,7 +66,7 @@ router.py → 路由控制器(Blueprint)
|
||||
|
||||
- **认证安全**:JWT 令牌 + 黑名单机制,密码 bcrypt 加盐加密
|
||||
- **登录防护**:图形验证码防暴力破解、失败自动锁定、密码强度校验
|
||||
- **接口防护**:Redis 滑动窗口限流、防重复提交(幂等性)
|
||||
- **接口防护**:防重复提交(幂等性)、WTForms 参数校验
|
||||
- **文件上传**:扩展名白名单 + 魔术字节校验 + 路径遍历防护 + 高危格式黑名单
|
||||
- **RBAC 权限**:后端装饰器 + 前端指令双重校验,精确到按钮级,管理员自动绕过
|
||||
- **数据安全**:软删除机制、敏感字段序列化排除、XSS 清洗
|
||||
|
||||
Reference in New Issue
Block a user