签入版本

This commit is contained in:
2026-09-16 10:11:44 +08:00
parent bf08cf1248
commit 20d8a85989
205 changed files with 34363 additions and 0 deletions
@@ -0,0 +1,530 @@
# 核心基类体系
本章详细描述系统的核心基类体系,包括 `BaseModel`、`BaseSchema`、`BaseRepository`、`BaseService` 四大基类的设计理念、继承关系和使用方式。以岗位(Position)模块为实例贯穿说明。
## 基类继承关系
:::tip 基类体系总览
四大基类各司其职,覆盖从数据模型到请求校验再到业务逻辑的完整链路。子类只需声明差异点,即可获得标准化的增删改查能力。
:::
```
┌──────────────────────────────────────────────────────────────────┐
│ SQLAlchemy Base (declarative_base) │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ BaseModel (src/core/base_model.py) │
│ ─────────────────────────────────────────────────────────────── │
│ 通用字段: id / create_user / create_time / update_user / │
│ update_time / is_delete │
│ 序列化: to_dict() → 驼峰字段名 + UTC 转本地 + 排除敏感字段 │
│ 子类钩子: __table_comment__ 自动写入 __table_args__ │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
Position(BaseModel)
┌──────────────────────────┐
│ __tablename__ │
│ name / status / sort │
└──────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ BaseSchema (src/core/base_schema.py) │
│ ─────────────────────────────────────────────────────────────── │
│ Pydantic BaseModel 子类 │
│ 可选 id 字段: Optional[int],创建时可省略 │
│ 空字符串容错: '' / 'null' / 'undefined' → None │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
PositionSchema(BaseSchema)
┌──────────────────────────┐
│ name / status / sort │
│ 继承可选 id │
└──────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ BaseRepository (src/core/base_repository.py) │
│ ─────────────────────────────────────────────────────────────── │
│ 查询: get_by_id / get_by_ids / get_one / all / count │
│ 分页: paginate → (items, total) │
│ 写操作: create / update / batch_delete │
│ 条件: filter (原始) / filter_by (等值+软删) │
│ 软删除: 等值方法自动追加 is_delete=0 │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
PositionRepository(BaseRepository)
┌──────────────────────────┐
│ model = Position │
└──────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ BaseService (src/core/base_service.py) │
│ ─────────────────────────────────────────────────────────────── │
│ 查询: get_page / get_options / get_detail │
│ 写操作: add / update / delete / batch_delete / update_status │
│ 差异点: repo_cls / model / page_like_fields / page_eq_fields / │
│ page_order_by / unique_fields / serialize_maps │
│ 钩子: _before_add / _before_update / _before_delete │
│ _serialize / _serialize_detail │
│ _build_create_fields / _build_update_fields │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
PositionService(BaseService)
┌──────────────────────────────────┐
│ repo_cls = PositionRepository │
│ model = Position │
│ page_like_fields = ('name',) │
│ page_eq_fields = ('status',) │
│ page_order_by = (('sort','asc'),)│
│ unique_fields = {'name': '...'} │
│ position_service = 单例 │
└──────────────────────────────────┘
```
## base_model — 模型基类
**文件位置**:`src/core/base_model.py`
### 设计目标
为所有业务模型提供统一的通用字段和序列化方法,消除重复定义。
### 通用字段
```python
class BaseModel(Base):
__abstract__ = True # 抽象基类,不会在数据库中创建对应的表
# 主键:自增整数,所有业务表的唯一标识
id = Column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
# 创建人:记录该条记录的创建者姓名,由 Repository 层自动填充
create_user = Column(String(50), nullable=True, comment="创建人")
# 创建时间:记录创建时的 UTC 时间,数据库默认自动生成
create_time = Column(DateTime, default=_UTC_NOW, server_default=func.now(), comment="创建时间")
# 更新人:记录最后修改该条记录的姓名,由 Repository 层自动填充
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` | Integer, PK, AI | 主键自增 |
| `create_user` | String(50) | 创建人(realname) |
| `create_time` | DateTime | 创建时间(UTC,server_default) |
| `update_user` | String(50) | 更新人(realname) |
| `update_time` | DateTime | 更新时间(UTC,onupdate 自动触发) |
| `is_delete` | Integer | 软删除标识:0=正常,1=已删除 |
### 表注释钩子
```python
# src/core/base_model.py
# ============================================================
# 子类钩子:自动将 __table_comment__ 写入 __table_args__
# ============================================================
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
comment = getattr(cls, '__table_comment__', None)
if not comment:
return
# 仅对有 __tablename__ 的具体模型操作(跳过纯抽象中间层)
if not getattr(cls, '__tablename__', None):
return
existing = getattr(cls, '__table_args__', {})
if isinstance(existing, dict):
merged = {**existing}
elif isinstance(existing, tuple) and existing:
merged = dict(existing[-1]) if isinstance(existing[-1], dict) else {}
else:
merged = {}
if 'comment' not in merged:
merged['comment'] = comment
cls.__table_args__ = merged
```
子类只需声明 `__table_comment__ = "岗位表"`,基类通过 `__init_subclass__` 钩子自动将其写入 `__table_args__`,无需手动配置 `Table(comment=...)`。
### 序列化方法
```python
# src/core/base_model.py
def to_dict(self) -> Dict[str, Any]:
"""将模型实例转换为字典"""
result = {}
for column in self.__table__.columns:
if column.name in SERIALIZE_EXCLUDE_FIELDS:
continue
value = getattr(self, column.name)
if isinstance(value, datetime):
if value.tzinfo is None:
# MySQL DATETIME 无时区信息,读回为 naive UTC,统一按 UTC 转本地
value = value.replace(tzinfo=timezone.utc)
value = value.astimezone().strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(value, date):
value = value.strftime('%Y-%m-%d')
# 字段名转驼峰
camel_key = snake_to_camel(column.name)
result[camel_key] = value
return result
```
关键设计点:
1. **敏感字段排除**:`is_delete`、`password`、`salt` 不会出现在序列化结果中
2. **时区处理**:MySQL 的 `DATETIME` 读回为 naive UTC,统一转 aware UTC 后再转本地时间
3. **驼峰命名**:数据库下划线字段名自动转为前端习惯的驼峰命名
## BaseSchema — 表单基类
**文件位置**:`src/core/base_schema.py`
### 设计目标
所有创建/编辑表单的 Pydantic 基类,提供统一的可选 `id` 字段和前端空字符串容错处理。
### 完整代码
```python
# src/core/base_schema.py
# +======================================================================
# | 模块: 表单基类
# | 说明: 统一处理前端传入的空字符串 id → None 等通用校验
# +======================================================================
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ============================================================
# 表单基类
# ============================================================
class BaseSchema(BaseModel):
"""
表单基类
所有创建/编辑表单应继承此类,自动获得:
1. id 字段:Optional[int],前端传空字符串时自动转为 None
"""
id: Optional[int] = Field(None, description="主键ID")
@field_validator('id', mode='before')
@classmethod
def empty_str_to_none(cls, v):
"""前端 POST 空字符串 '' 时转为 None,避免 Pydantic 整数解析报错"""
if v == '' or v == 'null' or v == 'undefined':
return None
return v
```
### 代码解析
**可选 `id` 字段**:
```python
id: Optional[int] = Field(None, description="主键ID")
```
`id` 默认为 `None`。创建记录时前端不传 `id`,Pydantic 自动赋默认值;编辑记录时前端传入 `id`,Service 层据此判断是新增还是更新。
**空字符串容错**:
```python
@field_validator('id', mode='before')
@classmethod
def empty_str_to_none(cls, v):
if v == '' or v == 'null' or v == 'undefined':
return None
return v
```
前端表单在未填写 `id` 时,可能提交空字符串 `''`、字面量 `'null'` 或 `'undefined'`。Pydantic 无法将这些值解析为 `int`,会抛出校验错误。`mode='before'` 确保此验证器在 Pydantic 类型转换之前执行,将这些边界值统一转为 `None`。
### 子类示例
```python
# src/modules/position/schemas.py
# +======================================================================
# | 模块: 岗位表单验证
# | 说明: 岗位创建/编辑/状态更新的请求数据校验
# +======================================================================
from core.base_schema import BaseSchema
# ============================================================
# 岗位表单类
# ============================================================
class PositionForm(BaseSchema):
"""岗位创建/编辑表单"""
name: str = Field(..., min_length=1, max_length=150, description="岗位名称")
status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用")
sort: int = Field(..., ge=0, le=99999, description="岗位排序")
```
`PositionForm` 继承 `BaseSchema` 后自动获得可选 `id` 字段。创建时 JSON 请求体无需包含 `id`:
```json
{ "name": "测试岗位", "status": 1, "sort": 0 }
```
编辑时传入 `id`:
```json
{ "id": 1, "name": "修改后", "status": 1, "sort": 1 }
```
### 使用场景
| 场景 | 是否传 id | BaseSchema 行为 |
|:----|:----|:----|
| 新增 | 不传 | `id = None`,Service 走 `add()` 分支 |
| 编辑 | 传入 | `id = 123`,Service 走 `update()` 分支 |
| 特定操作 | 不需要 id | 继承 `BaseModel` 而非 `BaseSchema`(如 `PositionStatusForm`) |
:::tip 何时继承 BaseSchema vs BaseModel
- **创建/编辑共用的表单**:继承 `BaseSchema`,自动获得可选 `id`
- **特定操作的表单**(如状态更新、排序调整):直接继承 `BaseModel`,只定义必要字段
:::
## BaseRepository — 仓库基类
**文件位置**:`src/core/base_repository.py`
### 设计目标
封装各模型的数据访问层公共操作,提供统一的 CRUD 接口和软删除约定。
### 核心方法一览
| 方法 | 签名 | 说明 | 软删过滤 |
|:---|:---|:---|:---:|
| `get_by_id` | `(obj_id) → Optional[M]` | 按 ID 获取单条记录 | 是 |
| `get_by_ids` | `(ids) → list` | 按 ID 列表批量获取 | 是 |
| `get_one` | `(**filters) → Optional[M]` | 等值条件获取单条 | 是 |
| `all` | `(**filters) → List[M]` | 等值条件获取所有 | 是 |
| `count` | `(**filters) → int` | 等值条件计数 | 是 |
| `exists` | `(obj_id) → bool` | 检查记录是否存在 | 是 |
| `exists_by_field` | `(field, value, exclude_id) → bool` | 唯一性校验 | 是 |
| `paginate` | `(page_no, page_size, query) → (items, total)` | 分页查询 | 是 |
| `filter` | `(*criterion) → Query` | 原始条件查询 | **否** |
| `filter_by` | `(**kwargs) → Query` | 等值条件查询 | 是 |
| `create` | `(**fields) → M` | 创建记录 | — |
| `update` | `(obj_id, data) → int` | 按 ID 更新 | 是 |
| `batch_delete` | `(ids_str) → int` | 按 ID 软删除 | — |
### 软删除约定
:::warning 重要设计约定
- **等值便捷方法**(get_by_id / get_one / all / count / filter_by / exists / exists_by_field / paginate)自动追加 `is_delete=0` 过滤,调用方无需关心
- **原始出口** `filter(*criterion)` **不做**软删过滤,由调用方自行追加 `Model.is_delete == 0`,用于 `in_` / `!=` / `and_` / 区间等复杂条件
:::
### 数据清洗
```python
# src/core/base_repository.py
def _clean_model_data(self, data: dict) -> dict:
"""清洗入库数据:camelCase 键转 snake_case 并仅保留模型真实列字段
Pydantic schema 可能用 camelCase(如 parentId)而模型列是 snake_case
(parent_id),直传 Query.update / model(**fields) 会触发 CompileError /
TypeError。统一在数据访问层转换,业务模块无需各自处理。
"""
if not data:
return {}
columns = {c.name for c in self.model.__table__.columns}
return {
_camel_to_snake(k): v
for k, v in data.items()
if _camel_to_snake(k) in columns
}
```
Pydantic Schema 可能用 camelCase(如 `parentId`),而模型列是 snake_case(如 `parent_id`)。`_clean_model_data` 在数据访问层统一转换,同时过滤掉模型中不存在的字段,业务模块无需各自处理。`_camel_to_snake` 是 `BaseRepository` 的静态方法。
## BaseService — 服务基类
**文件位置**:`src/core/base_service.py`
### 设计目标
通过模板方法模式封装通用 CRUD 流程。子类只需声明差异点,即可获得分页、详情、添加、编辑、删除、批量删除、状态切换等标准接口。
### 差异点声明
| 差异点 | 类型 | 说明 |
|:---|:---|:---|
| `repo_cls` | `Type[BaseRepository]` | **必填**,数据仓库类(不是实例) |
| `model` | `type[M]` | **必填**,ORM 模型类 |
| `page_like_fields` | `Tuple[str, ...]` | 分页模糊查询字段(取同名查询参数,`LIKE %值%`) |
| `page_eq_fields` | `Tuple[str, ...]` | 分页等值查询字段(取同名查询参数,`=值`) |
| `page_order_by` | `Tuple[Tuple[str, str], ...]` | 分页排序规则,默认 `(('id', 'desc'),)` |
| `unique_fields` | `Dict[str, str]` | 唯一性校验字段及提示,如 `{'name': '岗位名称不能重复'}` |
| `serialize_maps` | `Dict[str, Dict]` | 枚举显示名映射。值为 `str` 时从数据字典取 `{value: label}`,值为 `dict` 时直接按值取名称 |
| `serialize_extra` | `Callable` | 列表/详情共用序列化钩子,返回需合并进字典的额外字段 |
| `detail_serialize` | `Callable` | 详情专用序列化钩子,仅在 `get_detail` 时合并 |
| `file_fields` | `Tuple[str, ...]` | 文件字段(add/update 时自动迁移临时文件,serialize 时自动补全 URL) |
| `rich_text_fields` | `Tuple[str, ...]` | 富文本字段(含嵌入图片迁移 + XSS 清洗) |
| `file_dir` | `str` | 文件存储子目录名,默认从表名去掉 `DB_PREFIX` 推导 |
### 标准接口
| 方法 | 说明 | 返回值 |
|:---|:---|:---|
| `get_page(handler)` | 分页查询 | `R.page(data, total, current, size)` |
| `get_options(handler)` | 下拉列表查询 | `R.ok(data=[...])` |
| `get_detail(obj_id)` | 详情查询 | `dict` 或 `None` |
| `add(handler)` | 新增记录 | `R.ok(msg='添加成功')` |
| `update(handler)` | 编辑记录 | `R.ok(msg='更新成功')` |
| `delete(handler, ids)` | 删除记录 | `R.ok(msg='本次共删除N条数据')` |
| `batch_delete(handler)` | 批量删除 | `R.ok(...)` |
| `update_status(handler)` | 状态切换 | `R.ok(msg='设置成功')` |
### 钩子方法
| 钩子 | 触发时机 | 默认行为 |
|:---|:---|:---|
| `_before_add(handler, data)` | 新增前 | 空操作 |
| `_before_update(handler, data)` | 编辑前 | 空操作 |
| `_before_delete(ids)` | 删除前 | 返回 None(放行) |
| `_build_page_query(handler)` | 分页查询构建 | 按声明字段过滤 + 排序 |
| `_serialize(item)` | 列表行序列化 | `to_dict()` + 枚举映射 + 文件 URL |
| `_serialize_detail(obj)` | 详情序列化 | 同列表 + `detail_serialize` |
| `_build_create_fields(data)` | 新增字段组装 | camelCase→snake_case + 过滤非模型字段 |
| `_build_update_fields(data)` | 更新字段组装 | 同上 + 排除 None 值 |
### 删除流程
Service 层的 `delete()` 和 `batch_delete()` 方法内部调用 `repo.batch_delete(ids)` 完成软删除:
```python
# src/core/base_service.py
async def delete(self, handler, ids) -> R:
try:
id_list = list(dict.fromkeys(parse_id_list(str(ids))))
if not id_list:
return R.failed(handler, "记录ID不存在")
if len(self.repo.get_by_ids(id_list)) != len(id_list):
return R.failed(handler, "记录不存在")
err = self._before_delete(ids)
if err:
return R.failed(handler, err)
count = self.repo.batch_delete(ids)
return R.ok(handler, msg="本次共删除{0}条数据".format(count))
finally:
self.repo.close()
```
:::tip 设计要点
Repository 层的 `batch_delete` 只返回删除条数(数据原语),Service 层负责 R 响应封装和文案统一。删除前先校验全部记录存在,避免"部分软删已提交却整体报失败"导致的数据不一致。
:::
## 实例:Position 模块的基类使用
### Model(继承 BaseModel)
```python
# src/modules/position/models.py
from sqlalchemy import Column, Integer, String, text
from config.database import DB_PREFIX
from core.base_model import BaseModel
class Position(BaseModel):
"""岗位模型。"""
__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="岗位状态")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
```
获得的能力:
- 6 个通用字段(id / create_user / create_time / update_user / update_time / is_delete)
- `to_dict()` 序列化方法(自动排除 password/salt/is_delete,驼峰命名)
- `__table_comment__` 自动写入 `__table_args__`
### Repository(继承 BaseRepository)
```python
# src/modules/position/repository.py
"""岗位数据访问。"""
from core.base_repository import BaseRepository
from modules.position.models import Position
class PositionRepository(BaseRepository):
"""岗位仓储。"""
model = Position
```
获得的能力:
- 全部通用 CRUD 方法(get_by_id / get_by_ids / get_one / all / count / exists / exists_by_field / paginate / filter / filter_by / create / update / batch_delete)
- 软删除自动过滤(等值方法自动追加 `is_delete=0`)
- camelCase → snake_case 数据清洗(`_clean_model_data`)
### Service(继承 BaseService)
```python
# src/modules/position/service.py
"""岗位业务逻辑。"""
from core.base_service import BaseService
from modules.position.models import Position
from modules.position.repository import PositionRepository
from modules.position.schemas import PositionSchema, PositionStatusSchema
class PositionService(BaseService):
"""岗位服务。"""
# 基础声明
model = Position
repo_cls = PositionRepository
# 差异点声明
create_schema = PositionSchema
update_schema = PositionSchema
status_schema = PositionStatusSchema
page_like_fields = ("name",)
page_eq_fields = ("status",)
page_order_by = (("sort", "asc"),)
options_filter = {"status": 1}
# 模块级单例
position_service = PositionService()
```
获得的能力:
- 8 个标准接口(get_page / get_options / get_detail / add / update / delete / batch_delete / update_status)
- 唯一性自动校验
- 分页查询自动构建
- 序列化自动处理
:::tip 极简 CRUD
对于最简单的模块(如 Level 职级),Repository 和 Service 只需声明差异点,无需编写任何自定义方法,即可获得完整的增删改查能力。
:::
## 总结
五大基类通过模板方法模式和声明式配置,将通用 CRUD 流程固化为标准实现:
| 基类 | 职责 | 核心能力 |
|:----|:----|:----|
| `BaseModel` | 模型基类 | 通用字段、to_dict() 序列化、表注释钩子 |
| `BaseSchema` | 表单基类 | 可选 id、空字符串容错 |
| `BaseRepository` | 仓库基类 | 通用 CRUD 方法、软删除自动过滤 |
| `BaseService` | 服务基类 | 标准接口、钩子方法、声明式配置 |
子类只需声明差异点(过滤字段、排序规则、唯一性约束等),即可获得企业级的增删改查能力。以岗位模块为例,完整实现只需约 80 行有效代码(不含版权头),其中 Repository 几乎零代码。这种设计使得新模块的开发效率提升 3-5 倍,同时保证了代码风格和行为的一致性。
@@ -0,0 +1,350 @@
# 缓存架构设计
本章详细描述 的缓存架构设计,包括两级缓存策略(进程内缓存 + Redis 缓存)、缓存应用场景、失效策略和代码实现。
## 两级缓存架构
:::tip 缓存分层设计
采用两级缓存架构:第一级为进程内字典缓存(`dict_util`),第二级为 Redis 分布式缓存。两级缓存配合使用,在保证数据一致性的同时最大化查询性能。
:::
```
┌─────────────────────────────────────────────────────────────────┐
│ 请求处理流程 │
│ │
│ ┌──────────┐ 命中 ┌──────────┐ 命中 ┌───────────┐ │
│ │ 业务代码 │ ─────────→ │ L1 缓存 │ ────────→ │ 返回结果 │ │
│ │ │ │ (进程内) │ │ │ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │ │ 未命中 │
│ │ ▼ │
│ │ ┌──────────┐ 命中 ┌───────────┐ │
│ │ │ L2 缓存 │ ────────→ │ 写回 L1 │ │
│ │ │ (Redis) │ │ 返回结果 │ │
│ │ └──────────┘ └───────────┘ │
│ │ │ 未命中 │
│ │ ▼ │
│ │ ┌──────────┐ ┌───────────┐ │
│ │ │ 数据库 │ ────────→ │ 写回 L1 │ │
│ │ │ (MySQL) │ │ 写回 L2 │ │
│ │ └──────────┘ └───────────┘ │
│ │ │
│ ┌──────────┐ │
│ │ 缓存失效 │ → 清除 L1 + 删除 L2 │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 缓存层级对比
| 维度 | L1 进程内缓存 | L2 Redis 缓存 |
|:---|:---|:---|
| **存储位置** | Python 进程内存 | Redis 服务器 |
| **访问速度** | 纳秒级(字典查找) | 毫秒级(网络 I/O) |
| **数据共享** | 仅当前进程 | 所有进程/实例共享 |
| **持久化** | 进程重启丢失 | 持久化可配置 |
| **容量限制** | 受进程内存限制 | 受 Redis 内存限制 |
| **适用场景** | 高频读取、低变更数据 | 跨进程共享、分布式场景 |
## 进程内字典缓存
### 设计决策
`dict_util` 采用**纯进程内 TTL 缓存**(无 Redis L2),原因:
1. 字典数据几乎不变,读写均落在同步 service 路径,无法使用请求级异步 Redis
2. 字典/字典项增删改时由 service 主动失效,TTL 仅作兜底
3. 多 worker 部署下各进程缓存最多在 TTL 内短暂不一致,属可接受范围
### 实现原理
```python
# src/common/utils/dict_util.py
import logging
import time
from modules.dictionary.dict.models import Dict
from modules.dictionary.dict_item.models import DictItem
from modules.dictionary.dict.repository import dict_repo
from modules.dictionary.dict_item.repository import dictitem_repo
logger = logging.getLogger(__name__)
# 字典缓存 TTL(秒):兜底过期时间,实际变更靠主动失效即时生效
_CACHE_TTL = 60
# 进程内字典缓存:{code: (过期时间戳, 字典项列表)}
_dict_cache: dict = {}
def get_dict_items(code):
"""按字典编码读取字典项列表 [{label, value, sort}, ...],字典不存在返回空列表"""
now = time.time()
hit = _dict_cache.get(code)
if hit and hit[0] > now:
return hit[1]
items = _load_dict_items(code)
_dict_cache[code] = (now + _CACHE_TTL, items)
return items
def get_dict_map(code):
"""按字典编码读取 {value: label} 映射(value 为字典项存储值,字符串类型)"""
return {item['value']: item['label'] for item in get_dict_items(code)}
def get_dict_label(code, value, default=None):
"""取单个字典项名称:value 自动转字符串匹配存储值,查不到返回 default"""
return get_dict_map(code).get(str(value), default)
def invalidate_dict(code):
"""清除指定字典编码的缓存(None/未缓存时安全无操作)"""
if code:
_dict_cache.pop(code, None)
def invalidate_dicts_by_ids(dict_ids):
"""按字典ID批量清除缓存:字典项变更后据 dict_id 反查 code 再失效"""
if not dict_ids:
return
try:
rows = dict_repo.filter(Dict.id.in_(dict_ids), Dict.is_delete == 0).all()
for row in rows:
invalidate_dict(row.code)
except Exception as e:
logger.warning("字典缓存失效失败(将按 TTL 自动过期): %s", e)
def _load_dict_items(code):
"""从数据库读取字典项(未删除、按 sort+id 升序),字典不存在返回空列表"""
dict_obj = dict_repo.get_one(code=code)
if not dict_obj:
return []
rows = dictitem_repo.filter_by(dict_id=dict_obj.id).all()
rows.sort(key=lambda item: (item.sort, item.id))
return [{'label': item.name, 'value': item.value, 'sort': item.sort} for item in rows]
```
### 公开接口
| 函数 | 签名 | 说明 |
|:---|:---|:---|
| `get_dict_items` | `(code) → [{label, value, sort}]` | 字典项列表(供前端下拉) |
| `get_dict_map` | `(code) → {value: label}` | 值→显示名映射(供后端翻译) |
| `get_dict_label` | `(code, value, default) → str` | 单值翻译 |
| `invalidate_dict` | `(code) → None` | 按字典编码清除缓存 |
| `invalidate_dicts_by_ids` | `(dict_ids) → None` | 按字典 ID 批量清除缓存 |
### 使用场景
| 场景 | 调用方式 | 说明 |
|:---|:---|:---|
| 枚举显示名 | `get_dict_map('status_type')` | 序列化时将值转为显示名 |
| 表单下拉 | `get_dict_items('gender')` | 前端获取字典项列表 |
| 单值翻译 | `get_dict_label('status', 1)` | 将 1 转为 "正常" |
| 业务校验 | `value in get_dict_map('xxx')` | 校验值是否在字典范围内 |
### 缓存失效
字典数据变更时(新增/编辑/删除字典项),通过 `invalidate_dicts_by_ids` 按字典 ID 批量清除:
```python
# src/modules/dictionary/dict_item/service.py
class DictItemService(BaseService(DictItem]):
repo = dictitem_repo
model = DictItem
def add(self, request, data):
"""新增字典项,新增后失效所属字典的缓存"""
result = super().add(request, data)
if data.dictId:
invalidate_dicts_by_ids({data.dictId})
return result
def update(self, request, data):
"""更新字典项,更新后失效新旧所属字典的缓存"""
old = self.repo.get_by_id(data.id) if data.id else None
result = super().update(request, data)
dict_ids = {data.dictId} if data.dictId else set()
if old and old.dict_id:
dict_ids.add(old.dict_id)
invalidate_dicts_by_ids(dict_ids)
return result
def delete(self, ids):
"""删除字典项,删除后失效所属字典的缓存"""
from utils.request import parse_id_list
id_list = parse_id_list(str(ids))
dict_ids = set()
if id_list:
for row in dictitem_repo.filter(DictItem.id.in_(id_list), DictItem.is_delete == 0).all():
if row.dict_id:
dict_ids.add(row.dict_id)
result = super().delete(ids)
invalidate_dicts_by_ids(dict_ids)
return result
```
## L2 缓存:Redis 缓存
### Redis 连接管理
```python
# src/core/redis.py
import redis
from config.redis import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_AUTH
# 同步 Redis 客户端(全局单例)
_redis_client = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD if REDIS_AUTH else None,
decode_responses=True,
)
redis = _redis_client
```
- 全局单例,所有请求共享同一 Redis 客户端
- 同步客户端,与全站同步 DB/Redis 调用风格一致
- 连接池由 `redis-py` 内置管理
### 缓存应用场景
| 应用场景 | 数据结构 | Key 格式 | TTL | 说明 |
|:---|:---|:---|:---|:---|
| **JWT 黑名单** | String | `jwt:blacklist:{token_hash}` | Token 剩余有效期 | 登出时将 Token 加入黑名单 |
| **登录失败锁** | String | `login:lock:{username}` | 600 秒 | 连续 5 次失败后锁定 |
| **登录失败计数** | String (Lua) | `login:fail:{username}` | 300 秒 | 原子递增 + 首次设置 TTL |
| **滑动窗口限流** | String (Lua) | `rl:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器(前缀可配置) |
| **权限列表缓存** | String (JSON) | `perm:list:{user_id}:{version}` | 7200 秒 | 用户权限字符串列表(版本号策略) |
| **菜单缓存** | String (JSON) | `perm:menu:{user_id}:{version}` | 7200 秒 | 用户菜单树(版本号策略) |
| **用户有效状态** | String | `user:active:{user_id}` | 300 秒 | login_required 每请求校验 |
| **验证码** | String | `captcha:{uuid}` | 300 秒 | 图形验证码文本 |
:::tip 字典缓存不在 Redis 中
数据字典(`dict_util`)采用纯进程内 TTL 缓存,不经过 Redis。原因是字典读取落在同步 service 路径,无法使用请求级异步 Redis 客户端。详见上方「进程内字典缓存」章节。
:::
### Lua 脚本
#### 原子递增 + 过期
```python
# src/core/redis.py
_INCR_EXPIRE_LUA = """
local count = redis.call('incr', KEYS[1])
if count == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
return count
"""
async def incr_with_expire(redis, key: str, seconds: int) -> int:
"""原子执行 incr,并在 key 首次创建时一并设置过期时间"""
return redis.eval(_INCR_EXPIRE_LUA, 1, key, seconds)
```
:::tip 为什么需要 Lua 脚本?
`incr` 和 `expire` 分开执行时,两步之间若进程崩溃,key 会以无 TTL 的状态永久残留,导致基于 Redis 的限流/登录锁定永久封禁。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 突发问题。
## 缓存策略对比
| 策略 | 说明 | 适用场景 | 应用 |
|:---|:---|:---|:---|
| **Cache-Aside** | 先查缓存,未命中查 DB,写回缓存 | 读多写少 | 数据字典、权限列表 |
| **Write-Through** | 写操作同时更新缓存 | 数据一致性要求高 | 字典项变更时主动失效 |
| **Write-Behind** | 写操作只更新缓存,异步写 DB | 写入性能要求高 | 未使用 |
| **TTL 过期** | 缓存自动过期 | 允许短暂不一致 | 登录锁、限流计数 |
| **主动失效** | 数据变更时主动清除缓存 | 数据一致性要求高 | 字典缓存、权限缓存 |
## 缓存失效策略
### 主动失效
数据变更时主动清除相关缓存,保证数据一致性:
```python
# 字典项变更 → 按字典 ID 批量清除字典缓存
invalidate_dicts_by_ids(dict_ids)
# 菜单变更 → 递增全局版本号,所有用户权限缓存自然失效
await invalidate_menu_change()
# 用户角色变更 → 删除该用户的权限和菜单缓存
await invalidate_user_perms(user_id)
# 角色菜单变更 → 清除该角色下所有用户的权限缓存
await invalidate_role_perms(role_id)
```
### TTL 自动过期
对于允许短暂不一致的数据,使用 TTL 自动过期:
```
数据字典缓存:TTL 60 秒(1 分钟,兜底过期,实际变更靠主动失效即时生效)
登录失败计数:TTL 300 秒(5 分钟)
滑动窗口限流:TTL 2 倍窗口时长
验证码:TTL 300 秒(5 分钟)
```
### 缓存穿透防护
:::warning 缓存穿透
当查询一个不存在的字典编码时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。`dict_util` 通过缓存空列表来防护:即使 `_load_dict_items` 返回空列表,也写入缓存,避免反复查询数据库。
:::
```python
def get_dict_items(code):
now = time.time()
hit = _dict_cache.get(code)
if hit and hit[0] > now:
return hit[1]
items = _load_dict_items(code) # 字典不存在时返回 []
_dict_cache[code] = (now + _CACHE_TTL, items) # 空列表也缓存,防止穿透
return items
```
## 缓存监控
Redis 缓存的运行状态可通过以下方式监控:
1. **Redis INFO**:连接数、内存使用、命中率
2. **应用日志**:缓存命中/未命中日志(DEBUG 级别)
3. **Redis TTL**:检查 key 的剩余有效期
```bash
# 查看 Redis 缓存统计
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# 查看指定 key 的 TTL
redis-cli TTL "dict:status_type"
# 查看所有字典缓存 key
redis-cli KEYS "dict:*"
```
## 总结
采用进程内缓存(L1)+ Redis 缓存(L2)的两级缓存架构。L1 缓存提供纳秒级访问速度,适合高频读取的字典数据;L2 缓存提供跨进程数据共享,适合分布式场景。缓存失效采用主动失效 + TTL 过期的混合策略,数据变更时主动清除缓存保证一致性,允许短暂不一致的场景使用 TTL 自动过期。Lua 脚本保证 Redis 操作的原子性,防止进程崩溃导致的缓存异常。
@@ -0,0 +1,478 @@
# 数据库设计
本章详细描述 的数据库设计,包括核心表结构、通用字段规范、ER 关系和命名约定。
:::tip 设计原则
所有业务表继承 `base_model` 基类,自动获得 6 个通用字段(id / create_user / create_time / update_user / update_time / is_delete)。表名使用 `DB_PREFIX` 前缀(默认 `tornado_`),软删除通过 `is_delete` 字段实现。
:::
## 通用字段规范
所有业务表共享以下通用字段,由 `base_model` 基类自动提供:
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|:---|:---|:---|:---|:---|
| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | — | 主键自增 |
| `create_user` | VARCHAR(50) | NULLABLE | NULL | 创建人(realname) |
| `create_time` | DATETIME | NOT NULL | CURRENT_TIMESTAMP | 创建时间(UTC) |
| `update_user` | VARCHAR(50) | NULLABLE | NULL | 更新人(realname) |
| `update_time` | DATETIME | NOT NULL | CURRENT_TIMESTAMP | 更新时间(UTC,onupdate) |
| `is_delete` | INT | NOT NULL, INDEX | 0 | 软删除:0=正常,1=已删除 |
## 核心表结构
### 系统管理模块
#### 用户表 `tornado_user`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 用户ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录账号 |
| password | VARCHAR(200) | NOT NULL | 密码(bcrypt 哈希) |
| salt | VARCHAR(50) | NULLABLE | 密码盐值 |
| realname | VARCHAR(50) | NULLABLE | 真实姓名 |
| avatar | VARCHAR(500) | NULLABLE | 头像地址 |
| email | VARCHAR(100) | NULLABLE | 邮箱 |
| mobile | VARCHAR(20) | NULLABLE | 手机号 |
| gender | INT | DEFAULT 0 | 性别:0=未知 1=男 2=女 |
| dept_id | INT | NULLABLE, INDEX | 部门ID(外键) |
| position_id | INT | NULLABLE, INDEX | 岗位ID(外键) |
| level_id | INT | NULLABLE, INDEX | 职级ID(外键) |
| status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 |
| create_user | VARCHAR(50) | NULLABLE | 通用字段 |
| create_time | DATETIME | DEFAULT NOW() | 通用字段 |
| update_user | VARCHAR(50) | NULLABLE | 通用字段 |
| update_time | DATETIME | DEFAULT NOW() | 通用字段 |
| is_delete | INT | DEFAULT 0, INDEX | 通用字段 |
#### 角色表 `tornado_role`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 角色ID |
| name | VARCHAR(100) | NOT NULL, INDEX | 角色名称 |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 角色编码 |
| status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 |
| sort | INT | DEFAULT 0 | 排序 |
| note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | |
#### 菜单表 `tornado_menu`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 菜单ID |
| parent_id | INT | DEFAULT 0, INDEX | 父菜单ID(0=顶级) |
| name | VARCHAR(100) | NOT NULL | 菜单名称 |
| type | INT | NOT NULL | 类型:1=目录 2=菜单 3=按钮 |
| path | VARCHAR(200) | NULLABLE | 路由路径 |
| component | VARCHAR(200) | NULLABLE | 前端组件路径 |
| permission | VARCHAR(200) | NULLABLE | 权限标识(如 sys:user:add) |
| icon | VARCHAR(100) | NULLABLE | 菜单图标 |
| sort | INT | DEFAULT 0 | 排序 |
| visible | INT | DEFAULT 1 | 是否可见:1=是 2=否 |
| status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 |
| + 通用字段 | | | |
#### 部门表 `tornado_dept`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 部门ID |
| parent_id | INT | DEFAULT 0, INDEX | 父部门ID(0=顶级) |
| name | VARCHAR(100) | NOT NULL | 部门名称 |
| leader | VARCHAR(50) | NULLABLE | 负责人 |
| mobile | VARCHAR(20) | NULLABLE | 联系电话 |
| email | VARCHAR(100) | NULLABLE | 邮箱 |
| sort | INT | DEFAULT 0 | 排序 |
| status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 |
| + 通用字段 | | | |
#### 岗位表 `tornado_position`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 岗位ID |
| name | VARCHAR(255) | NOT NULL, INDEX | 岗位名称 |
| status | INT | DEFAULT 0, INDEX | 状态:1=在用 2=停用 |
| sort | INT | DEFAULT 0 | 排序 |
| + 通用字段 | | | |
#### 职级表 `tornado_level`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 职级ID |
| name | VARCHAR(255) | NOT NULL, INDEX | 职级名称 |
| status | INT | DEFAULT 0, INDEX | 状态:1=在用 2=停用 |
| sort | INT | DEFAULT 0 | 排序 |
| + 通用字段 | | | |
### 关联表
#### 用户角色关联表 `tornado_user_role`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 主键 |
| user_id | INT | NOT NULL, INDEX | 用户ID |
| role_id | INT | NOT NULL, INDEX | 角色ID |
| + 通用字段 | | | |
#### 角色菜单关联表 `tornado_role_menu`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 主键 |
| role_id | INT | NOT NULL, INDEX | 角色ID |
| menu_id | INT | NOT NULL, INDEX | 菜单ID |
| + 通用字段 | | | |
### 数据字典模块
#### 字典表 `tornado_dict`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 字典ID |
| name | VARCHAR(200) | NOT NULL | 字典名称 |
| code | VARCHAR(200) | NOT NULL, UNIQUE | 字典编码 |
| status | INT | DEFAULT 1 | 状态 |
| note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | |
#### 字典项表 `tornado_dict_item`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 字典项ID |
| dict_code | VARCHAR(200) | NOT NULL, INDEX | 所属字典编码 |
| label | VARCHAR(200) | NOT NULL | 显示名 |
| value | VARCHAR(200) | NOT NULL | 字典值 |
| sort | INT | DEFAULT 0 | 排序 |
| status | INT | DEFAULT 1 | 状态 |
| note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | |
### 内容管理模块
#### 文章表 `tornado_article`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 文章ID |
| category_id | INT | NULLABLE, INDEX | 分类ID |
| title | VARCHAR(200) | NOT NULL | 文章标题 |
| cover | VARCHAR(500) | NULLABLE | 封面图片 |
| summary | VARCHAR(500) | NULLABLE | 摘要 |
| content | TEXT | NULLABLE | 正文(富文本) |
| author | VARCHAR(50) | NULLABLE | 作者 |
| source | VARCHAR(200) | NULLABLE | 来源 |
| view_count | INT | DEFAULT 0 | 浏览次数 |
| is_top | INT | DEFAULT 0 | 是否置顶 |
| status | INT | DEFAULT 1 | 状态 |
| sort | INT | DEFAULT 0 | 排序 |
| + 通用字段 | | | |
#### 文章分类表 `tornado_category`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 分类ID |
| parent_id | INT | DEFAULT 0, INDEX | 父分类ID |
| name | VARCHAR(100) | NOT NULL | 分类名称 |
| sort | INT | DEFAULT 0 | 排序 |
| status | INT | DEFAULT 1 | 状态 |
| + 通用字段 | | | |
#### 通知公告表 `tornado_notice`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 通知ID |
| title | VARCHAR(200) | NOT NULL | 标题 |
| content | TEXT | NULLABLE | 内容 |
| type | INT | DEFAULT 1 | 类型:1=通知 2=公告 |
| status | INT | DEFAULT 1 | 状态 |
| + 通用字段 | | | |
### 日志模块
#### 登录日志表 `tornado_login_log`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 日志ID |
| username | VARCHAR(50) | NULLABLE | 登录账号 |
| ip | VARCHAR(50) | NULLABLE | 登录IP |
| location | VARCHAR(200) | NULLABLE | 登录地点 |
| browser | VARCHAR(100) | NULLABLE | 浏览器 |
| os | VARCHAR(100) | NULLABLE | 操作系统 |
| status | INT | DEFAULT 1 | 状态:1=成功 2=失败 |
| msg | VARCHAR(500) | NULLABLE | 提示消息 |
| + 通用字段 | | | |
#### 操作日志表 `tornado_operation_log`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 日志ID |
| username | VARCHAR(50) | NULLABLE | 操作人 |
| module | VARCHAR(100) | NULLABLE | 操作模块 |
| action | VARCHAR(100) | NULLABLE | 操作动作 |
| method | VARCHAR(10) | NULLABLE | 请求方法 |
| url | VARCHAR(500) | NULLABLE | 请求URL |
| params | TEXT | NULLABLE | 请求参数 |
| ip | VARCHAR(50) | NULLABLE | 操作IP |
| status | INT | DEFAULT 1 | 状态 |
| duration | INT | NULLABLE | 耗时(毫秒) |
| + 通用字段 | | | |
### 定时任务模块
#### 任务表 `tornado_job`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 任务ID |
| name | VARCHAR(200) | NOT NULL | 任务名称 |
| job_group | VARCHAR(100) | NULLABLE | 任务分组 |
| invoke_target | VARCHAR(500) | NOT NULL | 调用目标 |
| cron_expression | VARCHAR(200) | NOT NULL | Cron 表达式 |
| misfire_policy | INT | DEFAULT 3 | 执行策略 |
| concurrent | INT | DEFAULT 0 | 是否并发 |
| status | INT | DEFAULT 1 | 状态:1=运行 2=暂停 |
| note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | |
#### 任务日志表 `tornado_job_log`
| 字段名 | 类型 | 约束 | 说明 |
|:---|:---|:---|:---|
| id | INT | PK | 日志ID |
| job_id | INT | NOT NULL, INDEX | 任务ID |
| job_name | VARCHAR(200) | NULLABLE | 任务名称 |
| invoke_target | VARCHAR(500) | NULLABLE | 调用目标 |
| job_message | VARCHAR(500) | NULLABLE | 执行结果 |
| status | INT | DEFAULT 1 | 状态 |
| exception_info | TEXT | NULLABLE | 异常信息 |
| start_time | DATETIME | NULLABLE | 开始时间 |
| end_time | DATETIME | NULLABLE | 结束时间 |
| + 通用字段 | | | |
## ER 关系图
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ tornado │ │ tornado │ │ tornado │
│ _user │ │ _user_role │ │ _role │
│──────────────│ │──────────────│ │──────────────│
│ id (PK) │←───│ user_id (FK) │ │ id (PK) │
│ username │ │ role_id (FK) │───→│ name │
│ password │ └──────────────┘ │ code │
│ realname │ └──────┬───────┘
│ dept_id (FK) │───→┌──────────────┐ │
│ position_id │ │ tornado │ │
│ (FK) │ │ _dept │ │
│ level_id (FK)│ │──────────────│ │
│ │ │ id (PK) │ │
│ │ │ parent_id │ │
│ │ │ name │ │
│ │ └──────────────┘ │
│ │ │
│ │ ┌──────────────┐ │
│ │ │ tornado │ │
│ │ │ _role_menu │ │
│ │ │──────────────│ │
│ │ │ role_id (FK) │←───────────┘
│ │ │ menu_id (FK) │───→┌──────────────┐
│ │ └──────────────┘ │ tornado │
└──────────────┘ │ _menu │
│──────────────│
┌──────────────┐ │ id (PK) │
│ tornado │ │ parent_id │
│ _position │ │ name │
│──────────────│ │ permission │
│ id (PK) │ │ type │
│ name │ └──────────────┘
└──────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ tornado │ │ tornado │ │ tornado │
│ _dict │ │ _dict_item │ │ _config │
│──────────────│ │──────────────│ │──────────────│
│ id (PK) │←───│ dict_code │ │ id (PK) │
│ name │ │ label │ │ name │
│ code │ │ value │ │ code │
└──────────────┘ └──────────────┘ └──────────────┘
┌──────────────┐ ┌──────────────┐
│ tornado │ │ tornado │
│ _article │ │ _category │
│──────────────│ │──────────────│
│ id (PK) │ │ id (PK) │
│ category_id │───→│ parent_id │
│ title │ │ name │
│ content │ └──────────────┘
└──────────────┘
```
## 命名约定
| 约定项 | 规范 | 示例 |
|:---|:---|:---|
| 表名 | `{DB_PREFIX}{module_name}` | `tornado_position` |
| 字段名 | 小写下划线(snake_case) | `create_user`、`dict_code` |
| 主键 | `id`,INT,AUTO_INCREMENT | — |
| 外键 | `{关联表名}_id` | `user_id`、`dept_id` |
| 索引 | 外键字段、高频查询字段 | `status`、`username` |
| 布尔状态 | INT 类型,0/1 或 1/2 | `is_delete: 0/1`、`status: 1/2` |
| 时间字段 | DATETIME 类型 | `create_time`、`update_time` |
:::warning 外键约束
不使用数据库层面的外键约束(FOREIGN KEY),而是通过应用层代码维护引用完整性。这样做的好处是:简化跨库迁移、避免级联删除风险、提升写入性能。删除关联数据时,由 Service 层的 `_before_delete` 钩子检查引用关系。
:::
## 多数据库驱动支持
通过 `DB_DRIVER` 环境变量切换数据库驱动,`src/core/database.py` 的 `build_engine()` 函数按驱动构建方言感知的 SQLAlchemy 引擎:
| 驱动 | DB_DRIVER | 连接 URL 格式 | 特殊处理 |
|:---|:---|:---|:---|
| MySQL | `mysql`(默认) | `mysql+pymysql://user:pass@host:3306/db` | `CLIENT.FOUND_ROWS` 避免重复提交时 rowcount=0 误判 |
| PostgreSQL | `postgresql` | `postgresql+psycopg://user:pass@host:5432/db` | `options="-c timezone=UTC"` 确保 naive UTC 一致 |
| SQL Server | `mssql` | `mssql+pymssql://user:pass@host:1433/db` | `login_timeout` 连接超时 |
| SQLite | `sqlite` | `sqlite:///./db.sqlite3` | `check_same_thread=False`,跳过连接池参数 |
| Oracle | `oracle` | `oracle+oracledb://user:pass@host:1521/db` | thin 模式,UTF-8 编码 |
```python
# src/core/database.py - 引擎构建核心逻辑
# ============================================================
# 引擎构建
# ============================================================
def build_engine(driver: str | None = None, database_url: str | None = None):
"""按 DB_DRIVER 构建方言感知的 SQLAlchemy 引擎
- mysql: connect_timeout + client_flag FOUND_ROWS(UPDATE rowcount=匹配行数,
避免重复提交相同内容时 rowcount=0 被误判"更新失败");pymysql 懒加载
- postgresql: connect_timeout + options="-c timezone=UTC"(aware-UTC 默认值落库为
naive UTC,与 base_model.py 的 naive-UTC 读取约定一致,防时区偏移)
- mssql: login_timeout(pymssql 连接超时参数)
- oracle: oracledb 懒加载(thin 模式,UTF-8);connect_args 传 encoding/nencoding
- sqlite: 仅 check_same_thread=False,跳过连接池容量参数(QueuePool 默认即可)
"""
from core.config import (
SQLALCHEMY_DATABASE_URL, DB_DRIVER, TORNADO_DEBUG,
DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_RECYCLE,
DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT,
)
from core.config.database import _normalize_driver
drv = _normalize_driver(driver or DB_DRIVER)
url = database_url or SQLALCHEMY_DATABASE_URL
common = {"pool_pre_ping": True, "echo": TORNADO_DEBUG, "echo_pool": TORNADO_DEBUG}
if drv == 'mysql':
# 懒加载:仅 mysql 分支需要 pymysql,sqlite/pg/mssql 无需安装
from pymysql.constants import CLIENT
return create_engine(
url,
pool_size=DB_POOL_SIZE,
pool_recycle=DB_POOL_RECYCLE,
pool_timeout=DB_POOL_TIMEOUT,
max_overflow=DB_MAX_OVERFLOW,
pool_use_lifo=True,
connect_args={
"connect_timeout": DB_CONNECT_TIMEOUT,
"client_flag": CLIENT.FOUND_ROWS,
},
**common,
)
if drv == 'postgresql':
return create_engine(
url,
pool_size=DB_POOL_SIZE,
pool_recycle=DB_POOL_RECYCLE,
pool_timeout=DB_POOL_TIMEOUT,
max_overflow=DB_MAX_OVERFLOW,
pool_use_lifo=True,
connect_args={
"connect_timeout": DB_CONNECT_TIMEOUT,
"options": "-c timezone=UTC",
},
**common,
)
if drv == 'mssql':
return create_engine(
url,
pool_size=DB_POOL_SIZE,
pool_recycle=DB_POOL_RECYCLE,
pool_timeout=DB_POOL_TIMEOUT,
max_overflow=DB_MAX_OVERFLOW,
pool_use_lifo=True,
connect_args={"login_timeout": DB_CONNECT_TIMEOUT},
**common,
)
if drv == 'oracle':
# 懒加载:仅 oracle 分支需要 oracledb(thin 模式,无需 Oracle Instant Client)
import oracledb # noqa: F401
return create_engine(
url,
pool_size=DB_POOL_SIZE,
pool_recycle=DB_POOL_RECYCLE,
pool_timeout=DB_POOL_TIMEOUT,
max_overflow=DB_MAX_OVERFLOW,
pool_use_lifo=True,
connect_args={"encoding": "UTF-8", "nencoding": "UTF-8"},
**common,
)
# sqlite
return create_engine(url, connect_args={"check_same_thread": False}, **common)
```
业务代码无需修改,SQLAlchemy 自动处理方言差异(如分页语法、自增策略等)。
### 会话管理
```python
# src/core/database.py
from sqlalchemy.orm import scoped_session, sessionmaker
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = scoped_session(SessionLocal)
```
`scoped_session` 基于 thread-local 实现线程级隔离,每个线程持有独立的数据库会话。Repository 层通过 `self.db` 使用会话,写操作内部自动 `db.commit()`,Service 层 `try/finally` 确保 `repo.close()` 释放会话。
try:
independent_session.add(obj)
independent_session.commit()
except Exception:
independent_session.rollback()
raise
finally:
independent_session.close()
```
:::tip 约束命名约定
`database.py` 中定义了自定义约束命名约定,索引前缀使用 `idx_` 而非默认的 `ix_`:
```python
convention = {
"ix": "idx_%(table_name)s_%(column_0_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=convention)
Base = declarative_base(metadata=metadata)
```
:::
## 总结
数据库设计以「通用字段 + 软删除 + 应用层外键」为核心策略。所有业务表继承 `BaseModel` 获得统一的通用字段,通过 `is_delete` 实现软删除,通过应用层代码维护引用完整性。核心表覆盖系统管理(用户/角色/菜单/部门/岗位/职级)、数据字典、内容管理(文章/分类/通知)、日志管理、定时任务等功能域。多数据库驱动通过 `src/core/database.py` 按方言构建引擎,业务代码与具体数据库解耦。会话通过 `scoped_session` 实现线程级隔离。
@@ -0,0 +1,255 @@
# 目录结构设计
本章详细说明项目目录结构设计,包括后端 `src/` 和前端 `ui/src/` 的完整目录树及各目录的设计意图。
:::tip 设计原则
目录结构遵循「按功能分组、按职责分层」的原则。后端 `src/` 按技术层次组织(config / core / common / api / modules),前端 `ui/src/` 按功能领域组织(api / views / components / store)。每个目录有明确的边界和单一职责。
:::
## 后端目录结构
```
src/
├── app.py // 应用工厂
├── bootstrap.py // 启动引导
│
├── config/ // 配置模块
│ ├── __init__.py
│ ├── app.py // 应用基础配置
│ ├── auth.py // JWT 与登录安全配置
│ ├── captcha.py // 验证码配置
│ ├── database.py // 数据库配置
│ ├── email.py // 邮件配置
│ ├── logging.py // 日志配置
│ └── redis.py // Redis 配置
│
├── core/ // 核心基础设施
│ ├── __init__.py
│ ├── base_handler.py // 请求处理器基类
│ ├── base_model.py // ORM 模型基类
│ ├── base_repository.py // 通用 Repository 基类
│ ├── base_service.py // 通用 Service 基类
│ ├── base_schema.py // 通用 Schema 基类
│ ├── context.py // 上下文变量
│ ├── database.py // 数据库引擎与会话管理
│ ├── exceptions.py // 异常体系
│ ├── jwt.py // JWT 令牌处理
│ ├── logger.py // 日志配置
│ ├── metrics.py // 性能指标
│ ├── password.py // 密码加密工具
│ ├── redis.py // Redis 客户端
│ ├── response.py // 统一响应封装
│ └── tracer.py // 链路追踪
│
├── common/ // 公共模块
│ ├── __init__.py
│ ├── permission.py // 权限解析
│ ├── middleware/ // 中间件
│ │ ├── __init__.py
│ │ ├── access_decorators.py // 访问控制装饰器
│ │ ├── authentication.py // 认证中间件
│ │ ├── cors.py // 跨域处理
│ │ ├── login_log.py // 登录日志装饰器
│ │ ├── metrics.py // 请求指标中间件
│ │ ├── operation_log.py // 操作日志装饰器
│ │ └── trace_id.py // 链路追踪中间件
│ └── utils/ // 工具函数
│ ├── __init__.py
│ ├── captcha.py // 验证码工具
│ ├── crypto.py // 密码加密工具
│ ├── dict_util.py // 数据字典工具
│ ├── file.py // 文件工具
│ ├── helpers.py // 通用工具函数
│ ├── ip2region.py // IP 地理位置解析
│ ├── rich_text.py // 富文本处理
│ └── validators.py // 校验工具
│
├── modules/ // 业务模块
│ ├── __init__.py
│ ├── auth/ // 认证模块
│ ├── user/ // 用户管理
│ ├── role/ // 角色管理
│ ├── menu/ // 菜单管理
│ ├── dept/ // 部门管理
│ ├── position/ // 岗位管理
│ ├── level/ // 职级管理
│ ├── dict/ // 字典管理
│ ├── dict_item/ // 字典项管理
│ ├── config/ // 系统配置
│ ├── config_item/ // 配置项管理
│ ├── notice/ // 通知公告
│ ├── link/ // 友情链接
│ ├── category/ // 分类管理
│ ├── article/ // 文章管理
│ ├── param/ // 参数管理
│ ├── city/ // 城市级联
│ ├── file_template/ // 文件模板
│ ├── job/ // 定时任务
│ ├── job_log/ // 任务日志
│ ├── upload/ // 文件上传
│ ├── login_log/ // 登录日志
│ ├── operation_log/ // 操作日志
│ ├── user_role/ // 用户角色关联
│ ├── role_menu/ // 角色菜单关联
│ ├── example/ // 示例模块
│ └── generator/ // 代码生成器
│
├── api/ // API 路由定义
│ ├── __init__.py
│ └── v1/ // v1 版本路由
│ ├── __init__.py
│ ├── router.py // 路由聚合
│ ├── auth.py // 认证路由
│ ├── index.py // 首页路由
│ ├── upload.py // 上传路由
│ ├── user.py // 用户路由
│ ├── role.py // 角色路由
│ ├── menu.py // 菜单路由
│ ├── dept.py // 部门路由
│ ├── position.py // 岗位路由
│ ├── level.py // 职级路由
│ ├── dict.py // 字典路由
│ ├── dict_item.py // 字典项路由
│ ├── config.py // 配置路由
│ ├── config_item.py // 配置项路由
│ ├── city.py // 城市路由
│ ├── notice.py // 公告路由
│ ├── link.py // 友链路由
│ ├── category.py // 分类路由
│ ├── article.py // 文章路由
│ ├── param.py // 参数路由
│ ├── file_template.py // 文件模板路由
│ ├── job.py // 定时任务路由
│ ├── job_log.py // 任务日志路由
│ ├── generator.py // 代码生成器路由
│ ├── operation_log.py // 操作日志路由
│ ├── login_log.py // 登录日志路由
│ └── example.py // 示例路由
│
├── extensions/ // 扩展注册
│ ├── __init__.py // 扩展初始化
│ ├── sqlalchemy.py // 数据库扩展
│ ├── redis.py // Redis 扩展
│ ├── swagger.py // Swagger 占位
│ └── sentry.py // Sentry 占位
│
└── constants/ // 常量定义
└── __init__.py // 常量定义
```
### 目录设计意图
| 目录 | 设计意图 |
|:---|:---|
| `config/` | 配置按功能拆分子模块,统一从 `.env` 加载,避免单一大文件 |
| `core/` | 基础设施层,提供所有模块共用的基类、工具和配置。不包含业务逻辑 |
| `common/middleware/` | 中间件独立于 core,职责单一,便于按需注册和测试 |
| `common/utils/` | 工具函数按功能拆分,提供密码、文件、缓存等通用能力 |
| `api/v1/` | 路由装配层,将所有模块的 Handler 集中注册,便于查看全量 API |
| `modules/` | 业务模块层,每个模块包含 handlers + schemas + service + repository + models |
| `extensions/` | 扩展注册,导出 SQLAlchemy/Redis 等单例,供全项目使用 |
| `constants/` | 常量独立管理,避免硬编码散落各处 |
## 前端目录结构
```
ui/src/
├── main.ts // 入口文件
├── App.vue // 根组件
├── api/ // API 接口定义
│ ├── system/ // 系统管理接口
│ ├── content/ // 内容管理接口
│ ├── data/ // 数据管理接口
│ ├── monitor/ // 监控管理接口
│ ├── tool/ // 工具接口
│ ├── file/ // 文件接口
│ ├── region/ // 地区接口
│ ├── setting/ // 设置接口
│ ├── dashboard/ // 仪表盘接口
│ └── common/ // 公共接口
├── views/ // 页面视图
│ ├── login/ // 登录页
│ ├── dashboard/ // 仪表盘
│ ├── system/ // 系统管理页面
│ ├── content/ // 内容管理页面
│ ├── data/ // 数据管理页面
│ ├── monitor/ // 监控管理页面
│ ├── file/ // 文件管理页面
│ ├── tool/ // 工具页面
│ ├── setting/ // 设置页面
│ ├── exception/ // 异常页面
│ └── about/ // 关于页面
├── components/ // 公共组件
│ ├── Table/ // 表格组件
│ ├── Form/ // 表单组件
│ ├── Modal/ // 弹窗组件
│ ├── Upload/ // 上传组件
│ ├── Editor/ // 富文本编辑器
│ ├── Excel/ // Excel 导入导出
│ ├── Select/ // 选择器组件
│ ├── Cropper/ // 图片裁剪
│ ├── Qrcode/ // 二维码
│ ├── Page/ // 分页组件
│ ├── Password/ // 密码组件
│ ├── Authority/ // 权限组件
│ ├── ChinaArea/ // 中国行政区划
│ ├── Lockscreen/ // 锁屏
│ ├── CountTo/ // 数字动画
│ ├── Render/ // 自定义渲染
│ ├── Region/ // 区域选择
│ ├── TableSelect/ // 表格选择
│ ├── icon/ // 图标组件
│ ├── importFile/ // 文件导入
│ ├── numberInput/ // 数字输入
│ └── pagination/ // 分页组件
├── store/ // 状态管理
│ ├── index.ts // Store 入口
│ └── modules/ // 状态模块
├── router/ // 路由配置
│ ├── index.ts // 路由实例
│ └── menus/ // 菜单路由
├── hooks/ // 组合式函数
│ ├── core/ // 核心 hooks
│ ├── event/ // 事件 hooks
│ ├── setting/ // 设置 hooks
│ └── web/ // Web hooks
├── layout/ // 布局组件
│ └── components/ // 布局子组件
├── directives/ // 自定义指令
├── enums/ // 前端枚举
├── plugins/ // 插件注册
├── settings/ // 应用设置
├── styles/ // 全局样式
├── utils/ // 工具函数
│ ├── http/ // Axios 封装
│ ├── auth.ts // Token 管理
│ ├── dateUtil.ts // 日期格式化
│ ├── downloadFile.ts // 文件下载
│ ├── validate.ts // 表单校验
│ └── Storage.ts // 本地存储
└── assets/ // 静态资源
├── icons/ // 图标
└── images/ // 图片
```
### 前后端目录对应关系
| 前端目录 | 后端目录 | 说明 |
|:---|:---|:---|
| `api/system/position.ts` | `api/v1/position.py` | API 接口定义一一对应 |
| `views/system/position/` | `modules/position/` | 页面视图对应业务模块 |
| `store/modules/user.ts` | `modules/auth/service.py` | 用户状态对应认证服务 |
| `components/` | `common/utils/` + `core/` | 公共组件对应后端工具 |
## 目录规范
:::warning 目录命名规范
1. **后端**:目录名使用小写下划线(snake_case),与 Python 模块命名一致
2. **前端**:目录名使用小写 kebab-case 或 camelCase,与 Vue 生态习惯一致
3. **模块文件**:每个模块固定包含 `handlers.py` / `schemas.py` / `service.py` / `repository.py` / `models.py` 五个文件
4. **路由文件**:每个模块在 `api/v1/` 下对应一个路由文件,导出 `routes` 列表
:::
## 总结
目录结构是项目可维护性的基础。后端按技术层次(config / core / common / api / modules)组织,前端按功能领域(api / views / components / store)组织。每个目录有明确的边界和单一职责,模块内部遵循固定的文件结构(handlers + schemas + service + repository + models)。这种设计使得新开发者能快速定位代码,新模块能按既定模式快速创建。
@@ -0,0 +1,539 @@
# 扩展性设计
本章详细描述系统的扩展性设计,包括模块可插拔、数据库驱动可切换、中间件可插拔三大扩展维度。
:::tip 扩展性设计原则
遵循开闭原则(OCP):对扩展开放,对修改关闭。新功能通过添加新模块实现,而非修改现有代码。核心框架通过基类、装饰器、中间件等机制提供扩展点,业务模块只需声明差异即可接入。
:::
## 一、模块可插拔设计
### 模块标准结构
每个业务模块遵循固定的四文件结构,新增模块只需创建这四个文件即可接入系统:
```
src/modules/{group}/{name}/
├── models.py # ORM 模型
├── repository.py # 数据访问层
├── schemas.py # 请求校验
└── service.py # 业务逻辑层
```
### 新增模块步骤
以新增「培训管理(Training)」模块为例。
**步骤一:创建模块目录和文件**
创建 `src/modules/training/` 目录,包含以下四个文件:
`models.py` — ORM 模型:
```python
# src/modules/training/models.py
from sqlalchemy import Column, String, Integer, text
from core.base_model import BaseModel
from core.base_model import base_model
from core.config import DB_PREFIX
class Training(BaseModel):
__tablename__ = DB_PREFIX + "training"
__table_comment__ = "培训记录表"
title = Column(String(200), nullable=False, comment="培训标题")
content = Column(String(2000), nullable=True, comment="培训内容")
trainer = Column(String(50), nullable=True, comment="培训讲师")
status = Column(Integer, default=1, server_default=text('1'), comment="状态")
sort = Column(Integer, default=0, server_default=text('0'), comment="排序")
```
`repository.py` — 数据访问层:
```python
# src/modules/training/repository.py
from training.models import Training
from core.base_repository import BaseRepository
class TrainingRepository(BaseRepository(Training]):
pass
training_repo = TrainingRepository(Training)
```
`schemas.py` — 请求校验:
```python
# src/modules/training/schemas.py
from pydantic import BaseModel, Field
from core.base_schema import BaseSchema
class TrainingForm(BaseSchema):
title: str = Field(..., min_length=1, max_length=200, description="培训标题")
content: str = Field(None, max_length=2000, description="培训内容")
trainer: str = Field(None, max_length=50, description="培训讲师")
status: int = Field(..., ge=1, le=2, description="状态")
sort: int = Field(..., ge=0, le=99999, description="排序")
class TrainingStatusForm(BaseModel):
id: int = Field(..., gt=0, description="培训ID")
status: int = Field(..., ge=1, le=2, description="状态")
```
`service.py` — 业务逻辑层:
```python
# src/modules/training/service.py
from core.base_service import BaseService
from training.models import Training
from training.repository import training_repo
class TrainingService(BaseService(Training]):
repo = training_repo
model = Training
page_like_fields = ('title', 'trainer')
page_eq_fields = ('status',)
page_order_by = (('sort', 'asc'),)
unique_fields = {'title': '培训标题不能重复'}
training_service = TrainingService()
```
**步骤二:创建 Handler**
创建 `src/modules/training/handlers.py`:
```python
# src/modules/training/handlers.py
from common.middleware.access_decorators import check_demo, permission_required
from common.middleware.operation_log import operation_log
from core import response as R
from core.base_handler import BaseHandler
from modules.training.service import training_service
class TrainingPageHandler(BaseHandler):
@permission_required("sys:training:page")
async def get(self):
return await training_service.get_page(self)
class TrainingDetailHandler(BaseHandler):
@permission_required("sys:training:detail")
async def get(self, id):
return R.ok(self, data=training_service.get_detail(id))
class TrainingAddHandler(BaseHandler):
@permission_required("sys:training:add")
@check_demo
@operation_log("培训管理", "添加")
async def post(self):
return await training_service.add(self)
class TrainingUpdateHandler(BaseHandler):
@permission_required("sys:training:update")
@check_demo
@operation_log("培训管理", "更新")
async def put(self):
return await training_service.update(self)
class TrainingDeleteHandler(BaseHandler):
@permission_required("sys:training:delete")
@check_demo
@operation_log("培训管理", "删除")
async def delete(self, id):
return await training_service.delete(self, id)
class TrainingStatusHandler(BaseHandler):
@permission_required("sys:training:status")
@check_demo
@operation_log("培训管理", "设置状态")
async def put(self):
return await training_service.update_status(self)
class TrainingBatchDeleteHandler(BaseHandler):
@permission_required("sys:training:delete")
@check_demo
@operation_log("培训管理", "批量删除")
async def post(self):
return await training_service.batch_delete(self)
```
**步骤三:注册路由**
在 `src/api/v1/` 下创建路由文件,并在 `router.py` 中注册:
```python
# src/api/v1/training.py
from modules.training.handlers import (
TrainingAddHandler, TrainingBatchDeleteHandler, TrainingDeleteHandler,
TrainingDetailHandler, TrainingPageHandler, TrainingStatusHandler,
TrainingUpdateHandler,
)
routes = [
(r"/training/page", TrainingPageHandler),
(r"/training/detail/(\d+)", TrainingDetailHandler),
(r"/training/add", TrainingAddHandler),
(r"/training/update", TrainingUpdateHandler),
(r"/training/delete/(\d+)", TrainingDeleteHandler),
(r"/training/status", TrainingStatusHandler),
(r"/training/batchDelete", TrainingBatchDeleteHandler),
]
```
```python
# src/api/v1/router.py
from api.v1 import training
_RESOURCE_MODULES = [
# ... 其他模块 ...
training,
]
```
**步骤四:配置菜单权限**
在数据库 `tornado_menu` 表中插入对应的菜单和权限节点记录。权限标识格式为 `sys:{module}:{action}`,如 `sys:training:add`、`sys:training:page`。
### 模块解耦
模块之间通过以下方式解耦:
| 解耦方式 | 说明 | 示例 |
|:---|:---|:---|
| **Repository 单例** | 模块级单例,通过 import 引用 | `from modules.user.repository import UserRepository` |
| **Service 单例** | 模块级单例,通过 import 引用 | `from user.service import user_service` |
| **跨模块引用** | 通过 Repository 引用其他模块的模型 | PositionService 引用 User 模型检查引用 |
| **事件机制** | 暂未实现(未来可通过事件总线解耦) | — |
### 复杂模块扩展
对于业务逻辑复杂的模块(如 User、Role、Menu),在继承 `BaseService` 的基础上添加自定义方法:
```python
# src/modules/user/service.py
class UserService(BaseService(User]):
repo = user_repo
model = User
page_like_fields = ('username', 'realname')
page_eq_fields = ('status', 'dept_id')
# 自定义方法:用户详情(含角色列表)
def get_user_detail(self, user_id):
user = self.repo.get_by_id(user_id)
if not user:
return None
data = user.to_dict()
data['roleIds'] = get_user_role_ids(user_id)
data['roleNames'] = get_user_role_names(user_id)
return data
# 自定义方法:修改密码
def change_password(self, user_id, old_pwd, new_pwd):
user = self.repo.get_by_id(user_id)
if not verify_password(old_pwd, user.password):
return R.failed("原密码错误")
user.password = hash_password(new_pwd)
user_repo.create(user)
return R.ok(msg="密码修改成功")
# 覆盖钩子:新增前哈希密码
def _before_add(self, request, data):
data.password = hash_password(data.password or DEFAULT_PASSWORD)
# 覆盖钩子:删除前检查
def _before_delete(self, ids):
if 1 in parse_id_list(str(ids)):
return "超级管理员不能删除"
return None
```
## 二、数据库驱动可切换
### 驱动注册机制
项目通过 `_SUPPORTED_DRIVERS` 声明支持的驱动,`_normalize_driver()` 处理别名映射和校验,`build_database_url()` 按驱动构建连接串:
```python
# src/config/database.py(简化)
import os
from urllib.parse import quote_plus
# 支持的数据库驱动
_SUPPORTED_DRIVERS = ("mysql", "postgresql", "mssql", "sqlite", "oracle")
def _normalize_driver(driver: str) -> str:
"""归一化 DB_DRIVER:别名映射 + 校验"""
name = str(driver).strip().lower()
alias_map = {
'postgres': 'postgresql',
'pg': 'postgresql',
'sqlserver': 'mssql',
'sql_server': 'mssql',
'sqlite3': 'sqlite',
}
name = alias_map.get(name, name)
if name not in _SUPPORTED_DRIVERS:
raise ValueError(f"不支持的 DB_DRIVER: {driver!r}")
return name
DB_DRIVER = _normalize_driver(os.getenv('DB_DRIVER', 'mysql'))
DB_HOST = os.getenv('DB_HOST', '127.0.0.1')
DB_PORT = int(os.getenv('DB_PORT', '3306'))
DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.tornado.elevue2')
DB_USERNAME = os.getenv('DB_USERNAME', 'root')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
def build_database_url(driver: str = None) -> str:
"""按驱动构建 SQLAlchemy 连接串"""
drv = _normalize_driver(driver if driver is not None else DB_DRIVER)
if drv == 'sqlite':
return 'sqlite:///./' + DB_DATABASE
base = quote_plus(DB_USERNAME) + ':' + quote_plus(DB_PASSWORD) + '@' + DB_HOST + ':' + str(DB_PORT)
if drv == 'mysql':
return 'mysql+pymysql://' + base + '/' + DB_DATABASE + '?charset=utf8mb4'
if drv == 'postgresql':
return 'postgresql+psycopg://' + base + '/' + DB_DATABASE
if drv == 'oracle':
return 'oracle+oracledb://' + base + '/?service_name=' + quote_plus(DB_DATABASE)
# mssql
return 'mssql+pymssql://' + base + '/' + DB_DATABASE + '?charset=utf8'
```
各驱动的连接串格式:
| 驱动 | 连接串格式 | Python 包 |
|:----|:----|:----|
| mysql | `mysql+pymysql://user:pass@host:port/db?charset=utf8mb4` | PyMySQL |
| postgresql | `postgresql+psycopg://user:pass@host:port/db` | psycopg[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` | 内置 |
### 切换方式
只需修改 `.env` 文件中的 `DB_DRIVER` 和对应的连接参数:
```bash
# MySQL(默认)
DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=djangoadmin
DB_USER=root
DB_PASSWORD=your_password
# PostgreSQL
DB_DRIVER=postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=djangoadmin
DB_USER=postgres
DB_PASSWORD=your_password
# SQLite
DB_DRIVER=sqlite
DB_DATABASE=./data.db
# SQL Server
DB_DRIVER=mssql
DB_HOST=127.0.0.1
DB_PORT=1433
DB_NAME=djangoadmin
DB_USER=sa
DB_PASSWORD=your_password
```
:::warning 驱动切换注意事项
1. 切换驱动后需安装对应的 Python 包(如 `psycopg[binary]` for PostgreSQL)
2. 数据迁移使用 `scripts/migrate_db.py` 工具
3. SQLite 不支持并发写入,仅适用于开发/测试环境
4. Oracle 需要额外的客户端库配置
:::
### 跨库数据迁移
```bash
# 干跑:核对迁移计划
python scripts/migrate_db.py --src-driver mysql --src-host ... --dst-driver postgresql --dst-host ... --dry-run
# 执行迁移
python scripts/migrate_db.py --src-driver mysql --src-host ... --dst-driver postgresql --dst-host ...
# 幂等重跑(先删除目标库数据)
python scripts/migrate_db.py --src-driver mysql --src-host ... --dst-driver postgresql --dst-host ... --drop-target-first
```
## 三、横切关注点扩展
### Handler 生命周期
Tornado 通过 Handler 生命周期方法实现横切关注点:
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
def set_default_headers(self):
"""设置响应头:CORS + trace_id"""
cros_required(self)
apply_trace_id(self)
@login_required
def prepare(self):
"""请求预处理:JWT 认证"""
pass
def on_finish(self):
"""请求结束后:释放数据库会话"""
db.close()
```
### 自定义装饰器
通过 `permission_required` 和 `check_demo` 的模式,可以创建自定义装饰器:
```python
# src/common/middleware/operation_log.py
def operation_log(module: str, action: str):
"""操作日志记录装饰器"""
def decorator(func):
@wraps(func)
async def wrapper(self, *args, **kwargs):
start = time.time()
result = await func(self, *args, **kwargs)
duration = time.time() - start
save_log(module, action, duration)
return result
return wrapper
return decorator
```
使用示例:
```python
class TrainingAddHandler(BaseHandler):
@permission_required("sys:training:add")
@check_demo
@operation_log("培训管理", "添加")
async def post(self):
return await training_service.add(self)
```
装饰器执行顺序(从外到内):`@permission_required` → `@check_demo` → `@operation_log` → 方法体。
## 四、装饰器扩展
### 自定义装饰器
通过 `permission_required` 和 `check_demo` 的模式,可以创建自定义装饰器:
```python
# src/common/decorators.py
# 自定义装饰器示例:操作日志记录
def log_operation(module: str, action: str):
"""
操作日志记录装饰器
自动记录接口调用的模块、操作类型、执行耗时
Args:
module: 所属模块名称,如 "培训管理"
action: 操作类型,如 "新增"、"编辑"、"删除"
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 记录开始时间,用于计算接口执行耗时
start = time.time()
# 执行原业务函数
result = await func(*args, **kwargs)
# 计算耗时(秒)
duration = time.time() - start
# 异步记录操作日志到数据库(包含模块、操作、耗时等信息)
save_log(module, action, duration)
return result
return wrapper
return decorator
# src/modules/training/handlers.py
# 使用示例:多个装饰器叠加使用
# 执行顺序:从外到内(先 permission_required,再 check_demo,再 operation_log)
class TrainingAddHandler(BaseHandler):
@permission_required("sys:training:add") # 权限校验
@check_demo # 演示环境拦截
@operation_log("培训管理", "添加") # 操作日志记录
async def post(self):
"""新增培训记录"""
return await training_service.add(self)
```
## 五、配置扩展
### 环境变量驱动
所有可配置项通过 `.env` 文件管理,新增配置项只需:
1. 在 `.env` 文件中添加变量
2. 在 `src/config/` 对应模块中读取
3. 在业务代码中引用
```bash
# .env
CUSTOM_FEATURE_ENABLED=true
CUSTOM_TIMEOUT=30
```
```python
# src/config/custom.py
CUSTOM_FEATURE_ENABLED = os.getenv("CUSTOM_FEATURE_ENABLED", "false").lower() == "true"
CUSTOM_TIMEOUT = int(os.getenv("CUSTOM_TIMEOUT", "30"))
```
## 六、前端扩展
### 新增页面
1. 在 `ui/src/api/` 创建 API 文件
2. 在 `ui/src/views/` 创建页面组件
3. 在数据库 `tornado_menu` 表中配置菜单路由
### 新增组件
公共组件放在 `ui/src/components/` 目录,遵循 Vue3 组件规范。
## 扩展性矩阵
| 扩展维度 | 扩展方式 | 修改范围 | 复杂度 |
|:---|:---|:---|:---|
| 新增 CRUD 模块 | 创建 4 文件 + 注册路由 | 新增文件,不改现有代码 | 低 |
| 新增复杂模块 | 继承基类 + 自定义方法 | 新增文件,不改现有代码 | 中 |
| 切换数据库驱动 | 修改 .env 配置 | 仅改配置 | 低 |
| 新增中间件 | 创建中间件 + 注册 | 新增文件 + 改 app.py | 低 |
| 新增装饰器 | 创建装饰器 + 应用 | 新增文件 | 低 |
| 新增配置项 | .env + config 模块 | 新增配置 | 低 |
| 新增前端页面 | API + Views + Menu | 新增文件 + 改数据库 | 中 |
## 总结
通过分层架构、基类模板方法、装饰器、中间件、环境变量配置等机制,实现了高度的可扩展性。新增 CRUD 模块只需 4 个标准文件 + 1 行路由注册,不修改任何现有代码。数据库驱动通过环境变量切换,中间件通过注册机制插拔。这种设计使得系统在功能持续增长的同时,保持代码结构的清晰和稳定。
@@ -0,0 +1,511 @@
# 分层架构设计
本章详细描述系统的分层架构设计,包括各层的职责边界、依赖方向、通信方式,并以岗位(Position)模块为实例贯穿说明。
## 架构概览
:::tip 架构分层原则
采用经典的分层架构,核心原则是:**依赖单向、职责单一、接口隔离**。上层只依赖下层接口,下层不感知上层存在。
:::
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP 层 (Handler) │
│ src/modules/{module}/handlers.py │
│ 职责:路由定义 · 权限装饰器 · 操作日志 · 响应返回 │
│ 原则:不含业务逻辑,仅做请求转发 │
└──────────────────────────┬──────────────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────────────────────┐
│ 业务层 (Service) │
│ src/modules/{module}/service.py │
│ 职责:业务逻辑 · 校验规则 · 字段组装 · 序列化加工 │
│ 原则:不直接操作数据库,通过 Repository 访问数据 │
└──────────────────────────┬──────────────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────────────────────┐
│ 数据层 (Repository) │
│ src/modules/{module}/repository.py │
│ 职责:数据库 CRUD · 查询条件组装 · 软删除过滤 │
│ 原则:只做数据访问原语,不做业务校验和响应封装 │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Model 层 (ORM 模型) │
│ src/modules/{module}/models.py │
│ 职责:表结构定义 · 字段映射 · 序列化方法 │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 数据库 (MySQL / PostgreSQL / etc.) │
└─────────────────────────────────────────────────────────────┘
```
## 各层详细职责
### 第一层:HTTP 层(Handler)
**目录位置**:`src/modules/{module}/handlers.py`
**核心职责**:
1. **权限控制**:通过 `@permission_required` 装饰器声明所需权限
2. **演示模式保护**:通过 `@check_demo` 装饰器阻止演示环境的写操作
3. **操作日志**:通过 `@operation_log` 装饰器记录写操作日志
4. **响应返回**:直接返回 Service 层的 `R` 对象
**设计原则**:
- 不包含任何业务逻辑
- 不直接访问数据库
- 仅做请求转发和响应返回
- 权限字符串遵循 `sys:{module}:{action}` 格式
**代码示例**(岗位模块):
```python
# src/modules/position/handlers.py
# +======================================================================
# | 模块: 岗位请求处理器层
# | 说明: 承接岗位相关的 HTTP 请求,校验权限/演示模式并记录操作日志后,
# | 转交岗位服务层处理
# +======================================================================
"""岗位请求处理器。"""
from common.middleware.access_decorators import check_demo, permission_required
from common.middleware.operation_log import operation_log
from core import response as R
from core.base_handler import BaseHandler
from modules.position.service import position_service
# ======================================================================
# 查询岗位分页数据
# ======================================================================
class PositionPageHandler(BaseHandler):
"""查询岗位分页数据。"""
@permission_required("sys:position:list")
async def get(self):
"""GET 请求:返回岗位分页列表。"""
return await position_service.get_page(self)
# ======================================================================
# 查询岗位详情
# ======================================================================
class PositionDetailHandler(BaseHandler):
"""查询岗位详情。"""
@permission_required("sys:position:detail")
async def get(self, id):
"""GET 请求:按 ID 返回岗位详情。
:param id: 岗位 ID(路径参数)
"""
return R.ok(self, data=position_service.get_detail(id))
# ======================================================================
# 添加岗位
# ======================================================================
class PositionAddHandler(BaseHandler):
"""添加岗位。"""
@permission_required("sys:position:add")
@check_demo
@operation_log("岗位管理", "添加")
async def post(self):
"""POST 请求:新增岗位。"""
return await position_service.add(self)
# ======================================================================
# 更新岗位
# ======================================================================
class PositionUpdateHandler(BaseHandler):
"""更新岗位。"""
@permission_required("sys:position:update")
@check_demo
@operation_log("岗位管理", "更新")
async def put(self):
"""PUT 请求:更新岗位信息。"""
return await position_service.update(self)
# ======================================================================
# 删除岗位
# ======================================================================
class PositionDeleteHandler(BaseHandler):
"""删除岗位。"""
@permission_required("sys:position:delete")
@check_demo
@operation_log("岗位管理", "删除")
async def delete(self, id):
"""DELETE 请求:按 ID 删除岗位(软删除)。
:param id: 岗位 ID(路径参数)
"""
return await position_service.delete(self, id)
# ======================================================================
# 设置岗位状态
# ======================================================================
class PositionStatusHandler(BaseHandler):
"""设置岗位状态。"""
@permission_required("sys:position:status")
@check_demo
@operation_log("岗位管理", "设置状态")
async def put(self):
"""PUT 请求:修改岗位状态(如启用 / 禁用)。"""
return await position_service.update_status(self)
# ======================================================================
# 获取岗位下拉列表
# ======================================================================
class PositionGetListHandler(BaseHandler):
"""获取岗位下拉列表。"""
async def get(self):
"""GET 请求:返回岗位下拉列表(无需权限校验)。"""
return await position_service.get_options(self)
# ======================================================================
# 批量删除岗位
# ======================================================================
class PositionBatchDeleteHandler(BaseHandler):
"""批量删除岗位。"""
@permission_required("sys:position:delete")
@check_demo
@operation_log("岗位管理", "批量删除")
async def post(self):
"""POST 请求:批量删除岗位(软删除)。"""
return await position_service.batch_delete(self)
```
:::tip 装饰器顺序
装饰器按从外到内执行:先 `@permission_required`(鉴权),再 `@check_demo`(演示模式),最后 `@operation_log`(操作日志)。所有 Handler 方法都是 `async def`,因为 Tornado Handler 方法默认异步。
:::
### 第二层:业务层(Service)
**目录位置**:`src/modules/{module}/service.py`
**核心职责**:
1. **业务逻辑编排**:组合多个数据操作完成业务流程
2. **唯一性校验**:通过 `unique_fields` 声明唯一约束
3. **字段组装**:`_build_create_fields` / `_build_update_fields` 组装入库字段
4. **序列化加工**:`_serialize` / `_serialize_detail` 加工响应数据
5. **文件处理**:文件字段迁移、URL 补全
6. **前置/后置钩子**:`_before_add` / `_before_update` / `_before_delete` 扩展点
**设计原则**:
- 通过 Repository 访问数据库,不直接使用 `db.query()`
- 返回 `R` 对象,通过 `self.finish()` 写出 JSON
- 通过声明差异点定制通用 CRUD,减少重复代码
**代码示例**(岗位模块):
```python
# src/modules/position/service.py
# +======================================================================
# | 模块: 岗位业务逻辑层
# | 说明: 定义岗位服务,声明差异点以复用通用 CRUD 流程,并导出服务单例
# +======================================================================
"""岗位业务逻辑。"""
from core.base_service import BaseService
from modules.position.models import Position
from modules.position.repository import PositionRepository
from modules.position.schemas import PositionSchema, PositionStatusSchema
# ======================================================================
# 岗位服务
# ======================================================================
class PositionService(BaseService):
"""岗位服务。"""
# ============================================================
# 基础声明
# ============================================================
model = Position
repo_cls = PositionRepository
# ============================================================
# 差异点声明
# ============================================================
# 校验模型
create_schema = PositionSchema
update_schema = PositionSchema
status_schema = PositionStatusSchema
# 分页查询字段
page_like_fields = ("name",)
page_eq_fields = ("status",)
# 分页排序与下拉过滤
page_order_by = (("sort", "asc"),)
options_filter = {"status": 1}
# ======================================================================
# 服务单例
# ======================================================================
position_service = PositionService()
```
:::tip Service 层的两种模式
简单 CRUD 模块继承 `BaseService`,只需声明差异点即可获得完整的增删改查能力。复杂模块(如用户、角色、菜单)在继承基础上添加自定义方法,或完全自定义 Service 函数。
:::
### 第三层:数据层(Repository)
**目录位置**:`src/modules/{module}/repository.py`
**核心职责**:
1. **CRUD 操作**:`create` / `update` / `batch_delete` / `get_by_id` 等
2. **查询构建**:`filter` / `filter_by` / `get_one` / `all` / `count`
3. **分页查询**:`paginate` 返回 `(items, total)` 元组
4. **软删除过滤**:等值方法自动追加 `is_delete=0`
5. **数据清洗**:`_clean_model_data` 将 camelCase 转 snake_case
**设计原则**:
- 只做数据访问原语,不做 R 封装
- 等值方法自动软删过滤,`filter()` 为原始出口
- Service 层通过 `repo_cls` 关联 Repository 类,基类自动实例化
**代码示例**(岗位模块):
```python
# src/modules/position/repository.py
# +======================================================================
# | 模块: 岗位数据访问层
# | 说明: 定义岗位仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力
# +======================================================================
"""岗位数据访问。"""
from core.base_repository import BaseRepository
from modules.position.models import Position
# ======================================================================
# 岗位仓储
# ======================================================================
class PositionRepository(BaseRepository):
"""岗位仓储。"""
# ============================================================
# 绑定的模型类
# ============================================================
model = Position
```
:::tip Repository 基类提供的方法
`BaseRepository` 提供以下通用方法,子类可直接使用:
- **查询**:`get_by_id` / `get_by_ids` / `get_one` / `all` / `count` / `exists` / `exists_by_field`
- **分页**:`paginate` 返回 `(items, total)`
- **写操作**:`create` / `update` / `batch_delete`
- **条件构建**:`filter`(原始出口) / `filter_by`(等值出口,自动软删过滤)
:::
### 第四层:Model 层(ORM 模型)
**目录位置**:`src/modules/{module}/models.py`
**核心职责**:
1. **表结构定义**:通过 SQLAlchemy Column 定义字段
2. **通用字段继承**:`BaseModel` 提供 id/create_user/create_time/update_user/update_time/is_delete
3. **序列化方法**:`to_dict()` 自动排除敏感字段(password/salt/is_delete),驼峰命名
**代码示例**(岗位模块):
```python
# src/modules/position/models.py
from sqlalchemy import Column, Integer, String, text
from config.database import DB_PREFIX
from core.base_model import BaseModel
class Position(BaseModel):
"""岗位模型。"""
__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="岗位状态")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
```
### Schema 层(请求校验)
**目录位置**:`src/modules/{module}/schemas.py`
**核心职责**:
1. **请求参数校验**:使用 Pydantic v2 的 `BaseModel` + `Field` 声明约束
2. **创建/编辑共用**:继承 `BaseSchema`(自带可选 id 字段)
3. **状态更新独立**:单独建类,只含 id + status
**代码示例**(岗位模块):
```python
# src/modules/position/schemas.py
from pydantic import BaseModel, Field
from core.base_schema import BaseSchema
class PositionSchema(BaseSchema):
"""岗位创建/编辑表单。"""
name: str = Field(..., min_length=1, max_length=150, description="岗位名称")
status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用")
sort: int = Field(..., ge=0, le=99999, description="岗位排序")
class PositionStatusSchema(BaseModel):
"""岗位状态更新表单。"""
id: int = Field(..., gt=0, description="岗位ID")
status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用")
```
## 路由注册
**目录位置**:`src/api/v1/{module}.py`
每个模块在 `src/api/v1/` 下定义路由文件,导出 `routes` 列表,由 `router.py` 统一聚合。
**代码示例**(岗位模块):
```python
# src/api/v1/position.py
"""岗位相关路由。"""
from modules.position.handlers import (
PositionAddHandler, PositionBatchDeleteHandler, PositionDeleteHandler,
PositionDetailHandler, PositionGetListHandler, PositionPageHandler,
PositionStatusHandler, PositionUpdateHandler,
)
routes = [
(r"/position/page", PositionPageHandler),
(r"/position/detail/(\d+)", PositionDetailHandler),
(r"/position/getPositionList", PositionGetListHandler),
(r"/position/add", PositionAddHandler),
(r"/position/update", PositionUpdateHandler),
(r"/position/delete/(\d+)", PositionDeleteHandler),
(r"/position/status", PositionStatusHandler),
(r"/position/batchDelete", PositionBatchDeleteHandler),
]
```
`router.py` 中统一注册:
```python
# src/api/v1/router.py
from api.v1 import position # 导入模块
_RESOURCE_MODULES = [
# ... 其他模块 ...
position, # 注册
]
def build_routes() -> list:
routes = []
for module in _RESOURCE_MODULES:
routes.extend([(V1_PREFIX + path, handler) for path, handler in module.routes])
routes.append((V1_PREFIX + "/health", HealthHandler))
return routes
```
## 依赖方向
```
HTTP 层 (Handler) ──依赖──→ Service 层 ──依赖──→ Repository 层 ──依赖──→ Model
│ │ │
│ │ │
▼ ▼ ▼
R (响应封装) R (响应封装) BaseRepository (基类)
装饰器(BaseHandler) BaseRepository SQLAlchemy ORM
access_decorators Pydantic Schema
```
:::warning 依赖规则
- HTTP 层只依赖 Service 层,不直接调用 Repository
- Service 层通过 Repository 访问数据库,不直接使用 `db.query()`
- Repository 层只依赖 Model 和数据库会话
- 下层不感知上层存在,不调用上层方法
:::
## 完整调用链路示例
以添加岗位为例,展示各层之间的调用关系:
```
POST /api/v1/position/add
│
├─ 1. Handler (handlers.py)
│ @permission_required("sys:position:add")
│ @check_demo
│ @operation_log("岗位管理", "添加")
│ async def post(self):
│ return await position_service.add(self)
│
├─ 2. Service (service.py → base_service.py)
│ async def add(self, handler):
│ form = self._validate(handler, self.create_schema) # Pydantic 校验
│ err = self._check_unique(form) # 唯一性校验
│ self._before_add(handler, data) # 前置钩子
│ fields = self._build_create_fields(data) # 字段组装
│ self.repo.create(self.model(**fields)) # 调用 Repository
│ return R.ok(handler, msg="添加成功")
│
├─ 3. Repository (base_repository.py)
│ def create(self, obj):
│ obj.create_user = str(self._uid()) # 自动写入创建人
│ self.db.add(obj)
│ self.db.commit()
│ return obj
│
├─ 4. Model (base_model.py)
│ class Position(BaseModel):
│ __tablename__ = "tornado_position"
│ name = Column(String(255), ...)
│
└─ 5. Database
INSERT INTO tornado_position (name, status, sort, create_user, create_time)
VALUES ('高级工程师', 1, 10, '1', '2026-09-16 10:00:00')
```
## 各层文件职责一览
| 文件 | 层次 | 职责 | 是否包含业务逻辑 |
|:---|:---:|:---|:---:|
| `handlers.py` | HTTP | 路由、权限、日志、请求转发 | 否 |
| `schemas.py` | HTTP | 请求参数校验规则 | 否 |
| `service.py` | 业务 | 业务逻辑、校验、序列化 | 是 |
| `repository.py` | 数据 | 数据库 CRUD、查询构建 | 否 |
| `models.py` | 数据 | 表结构定义、字段映射 | 否 |
## 总结
分层架构通过依赖单向和职责单一实现了良好的关注点分离。HTTP 层只关心请求接收和响应返回,Service 层只关心业务逻辑编排,Repository 层只关心数据访问。这种分层使得每一层都可以独立测试、独立替换,系统在模块数量增长时仍能保持清晰的代码结构。以岗位模块为例,完整实现一个 CRUD 模块只需 6 个文件(handlers + schemas + service + repository + models + api路由),其中 Repository 和 Model 几乎零代码,开发者只需关注 Service 层的业务差异点。
@@ -0,0 +1,216 @@
# 整体架构图
本章从全局视角阐述系统的整体技术架构,涵盖前端、后端、数据库及基础设施等核心组成部分。
## 架构总览
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser (Vue3 + ElementPlus) │
│ Axios HTTP Request / Vite Dev Proxy │
└──────────────────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Nginx (反向代理) │
│ 静态资源 / SSL 终止 / 负载均衡 / gzip 压缩 │
└──────────────────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Tornado Application │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Handler 生命周期 │ │
│ │ set_default_headers() → prepare() → handler_method() → on_finish()│ │
│ │ CORS + trace_id JWT 认证 权限+日志+业务逻辑 释放DB会话 │ │
│ └───────────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Service Layer (业务逻辑层) │ │
│ │ BaseService / 自定义 Service │ │
│ │ 唯一性校验 · 字段组装 · 序列化 · 文件处理 │ │
│ └───────────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Repository Layer (数据访问层) │ │
│ │ BaseRepository │ │
│ │ CRUD · 软删除 · 分页 · 等值查询 · 原始条件查询 │ │
│ └───────────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ ┌──────────────────────────────────────────┐ │
│ │ SQLAlchemy 2.0 │ │ Redis (同步客户端) │ │
│ │ ORM → SQL 生成 │ │ Token 黑名单 · 登录锁 · 字典缓存 · 权限缓存│ │
│ └──────────┬──────────┘ └──────────────────────────────────────────┘ │
└─────────────┼───────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Database (MySQL / PostgreSQL / SQL Server / SQLite / Oracle)│
│ 表前缀: tornado_ · 软删除: is_delete │
└─────────────────────────────────────────────────────────────────────────┘
```
## 架构特征
### 1. 前后端分离
前端 Vue3 应用通过 HTTP API 调用后端 Tornado 服务,两者独立开发、独立部署。前端开发阶段通过 Vite 的 `proxy` 配置将 `/api` 请求代理到后端 `http://127.0.0.1:8041`,生产环境由 Nginx 统一反向代理。
### 2. 分层架构
后端严格分为四层,每层职责单一、依赖单向:
| 层次 | 目录 | 职责 |
|:---:|:---:|:---|
| HTTP 层 | `modules/{module}/handlers.py` | 路由定义、权限装饰器、操作日志、响应返回 |
| 业务层 | `modules/{module}/service.py` | 业务逻辑、校验规则、序列化加工 |
| 数据层 | `modules/{module}/repository.py` | 数据库 CRUD、查询条件组装 |
| 模型层 | `modules/{module}/models.py` | 表结构定义、字段映射 |
### 3. 模板方法模式
通过 `BaseRepository` 和 `BaseService` 基类,将通用 CRUD 流程固化为模板方法。子类只需声明差异点(过滤字段、排序规则、唯一性校验等),即可获得完整的增删改查能力。
### 4. Handler 生命周期
Tornado 通过 Handler 生命周期方法实现横切关注点:
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
def set_default_headers(self):
"""① 设置 CORS 头 + trace_id"""
cros_required(self)
apply_trace_id(self)
@login_required
def prepare(self):
"""② JWT 认证(白名单 URL 跳过)"""
pass
def on_finish(self):
"""④ 请求结束后释放数据库会话"""
db.close()
def write_error(self, status_code, **kwargs):
"""⑤ 异常统一响应"""
...
```
Handler 方法中通过装饰器实现权限/演示模式/操作日志:
```python
class PositionAddHandler(BaseHandler):
@permission_required("sys:position:add") # RBAC 权限校验
@check_demo # 演示模式拦截
@operation_log("岗位管理", "添加") # 操作日志记录
async def post(self):
return await position_service.add(self)
```
### 5. 全局异常处理
`BaseHandler.write_error()` 统一捕获异常并返回标准响应:
| 异常类型 | 触发场景 | 响应 |
|:---|:---|:---|
| `AuthError` | JWT 过期 / 未登录 | `{"code": 401, "msg": "登录过期"}` |
| `PermissionDeniedError` | 权限不足 | `{"code": 403, "msg": "权限不足"}` |
| `BusinessError` | 业务逻辑错误 | `{"code": 1, "msg": "..."}` |
| `ValidationError` | Pydantic 校验失败 | `{"code": 1, "msg": "field: message"}` |
| `NotFoundError` | 记录不存在 | `{"code": 404, "msg": "记录不存在"}` |
| `Exception` | 未捕获异常 | `{"code": 500, "msg": "服务器内部错误"}` |
### 6. 统一响应格式
所有 API 端点返回统一的 JSON 结构:
```json
{
"code": 0,
"data": {},
"msg": "操作成功",
"ok": true
}
```
成功响应 `code=0`,失败响应 `code=1`(或其他业务码),前端无需针对不同接口做差异化解析。
### 7. 软删除策略
所有业务表通过 `is_delete` 字段实现软删除(0=正常,1=已删除)。`BaseRepository` 的等值查询方法自动追加 `is_delete=0` 过滤,业务代码无需关心已删除数据。
### 8. 事务管理
数据库事务由 Repository 层的 `create()` / `update()` / `batch_delete()` 方法内部 `db.commit()` 管理。Service 层的 `try/finally` 块确保 `repo.close()` 释放会话。`BaseHandler.on_finish()` 兜底关闭会话,避免长连接泄漏。
### 9. 多数据库驱动
通过 `DB_DRIVER` 环境变量切换数据库驱动(MySQL / PostgreSQL / SQL Server / SQLite / Oracle),SQLAlchemy 的方言层屏蔽了底层差异,业务代码无需修改。
### 10. 应用启动流程
```python
# app.py(入口)→ bootstrap.py(启动引导)→ src/app.py(应用工厂)
# 启动流程:
# 1. bootstrap.main() 解析 --port 参数
# 2. create_app() 构建 Application(路由、模板、静态目录)
# 3. register_extensions() 初始化扩展(SQLAlchemy、Redis)
# 4. HTTPServer.listen(port) 开始监听
# 5. init_scheduler() 启动定时任务调度器
# 6. 注册 SIGINT/SIGTERM 信号实现优雅关闭
# 7. ioloop.start() 启动事件循环
```
## 技术分层图
```
┌──────────────────────────────────────────────────────────────┐
│ Frontend (Vue3) │
│ ElementPlus · Pinia · Vue Router · Axios │
└──────────────────────────┬───────────────────────────────────┘
│ HTTP / JSON
┌──────────────────────────┴───────────────────────────────────┐
│ Tornado Application │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ Handler │ │ Router │ │ Exception Handler │ │
│ │ ───────── │ │ ───────── │ │ ───────────────── │ │
│ │ CORS │ │ auth │ │ BaseAppError │ │
│ │ trace_id │ │ v1 (login │ │ write_error() │ │
│ │ login_ │ │ required) │ │ 统一 {code,data,msg} │ │
│ │ required │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Handlers (modules/{module}/handlers.py) │ │
│ │ @permission_required · @check_demo · @operation_log │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Services (modules/{module}/service.py) │ │
│ │ BaseService · 自定义 Service │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Repositories (modules/{module}/repository.py) │ │
│ │ BaseRepository · 自定义 Repository │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Models (modules/{module}/models.py) │ │
│ │ BaseModel → SQLAlchemy ORM │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌────────────┐ ┌─────────────┐
│ Database │ │ Redis │
│ (MySQL等) │ │ (同步客户端) │
└────────────┘ └─────────────┘
```
## 总结
系统采用经典的前后端分离 + 分层架构设计,通过 Handler 生命周期方法和装饰器实现横切关注点(认证、权限、日志),通过基类模板方法消除重复 CRUD 代码,通过 Pydantic 实现请求参数的声明式校验。整体架构在保持简洁的同时,具备良好的可扩展性和可维护性。
@@ -0,0 +1,328 @@
# 请求完整链路
本章以一个具体的 API 请求为例,详细描述从浏览器发起 HTTP 请求到返回响应的完整链路,帮助开发者理解每一层的职责和数据流转方式。
## 请求链路总览
:::tip 链路概览
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → Tornado → Handler 生命周期(set_default_headers → prepare → handler_method → on_finish)→ 响应返回浏览器
:::
以下以 `POST /api/v1/position/add` 添加岗位为例,逐步拆解完整链路。
## Tornado 请求处理模型
Tornado 通过 **Handler 生命周期方法** 和 **装饰器** 实现横切关注点:
```
┌─────────────────────────────────────────────────────────────────┐
│ Tornado Handler 生命周期 │
│ │
│ ① set_default_headers() → 设置 CORS 头 + trace_id │
│ ② prepare() → @login_required(JWT 认证) │
│ ③ handler method → @permission_required + @check_demo │
│ + @operation_log + 业务逻辑 │
│ ④ on_finish() → db.close()(释放数据库会话) │
│ │
│ 异常时:write_error() → 统一异常响应 + 日志记录 │
└─────────────────────────────────────────────────────────────────┘
```
## 第一阶段:网络层
### 1. 浏览器发起请求
```javascript
// 前端 Axios 调用
const res = await request({
url: '/api/v1/position/add',
method: 'post',
data: {
name: '高级工程师',
status: 1,
sort: 10
}
});
```
前端通过 Axios 发起 HTTP POST 请求,携带:
- `Authorization: Bearer <JWT Token>` 请求头
- `Content-Type: application/json` 请求头
- JSON 请求体
### 2. Nginx 反向代理(生产环境)
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8041;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
Nginx 将请求代理到 Tornado 服务,同时附加客户端真实 IP 到 `X-Forwarded-For` 头。
## 第二阶段:Handler 生命周期
### 3. 路由匹配
Tornado 根据 HTTP 方法 + URL 路径匹配到对应的 Handler 类:
```python
# src/api/v1/router.py
def build_routes() -> list:
routes = []
for module in _RESOURCE_MODULES:
routes.extend([(V1_PREFIX + path, handler) for path, handler in module.routes])
routes.append((V1_PREFIX + "/health", HealthHandler))
return routes
```
路由表中 `POST /api/v1/position/add` 匹配到 `PositionAddHandler`。
### 4. set_default_headers() — 设置响应头
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
def set_default_headers(self):
super().set_default_headers()
cros_required(self) # CORS 跨域头
apply_trace_id(self) # 链路追踪 ID
```
- **CORS**:检查 `Origin` 头是否在白名单中,设置 `Access-Control-Allow-*` 响应头
- **trace_id**:生成唯一追踪 ID,写入响应头和上下文变量
### 5. prepare() — JWT 认证
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
@login_required
def prepare(self):
pass
```
`login_required` 装饰器在 `prepare()` 阶段执行认证:
```python
# src/common/middleware/authentication.py
def login_required(func):
def wrapper(self, *args, **kwargs):
request_url = self.request.path
# 白名单(/login、/captcha)和 OPTIONS 预检直接放行
if request_url not in IGNORE_URL and self.request.method != "OPTIONS":
# 1. 从 Authorization 头提取 Bearer Token
success, access_token, _ = get_access_token(self.request)
if not success or not access_token:
return R.failed(self, code=401, msg="登录过期")
# 2. JWT 解密验证(签名 + 有效期)
result = parse_payload(access_token)
if result["code"] != 0:
return R.failed(self, code=401, msg="登录过期")
# 3. 黑名单校验(Redis,不可用时 fail-open 放行)
if is_token_blacklisted(access_token):
return R.failed(self, code=401, msg="登录过期")
# 4. 将用户信息写入上下文变量
data = result["data"]
current_user_id.set(int(data["userId"]))
current_username.set(data.get("username", ""))
current_realname.set(data.get("realname", ""))
return func(self, *args, **kwargs)
return wrapper
```
认证失败时直接返回 `{"code": 401, "msg": "登录过期"}`,不进入后续 Handler 方法。
### 6. Handler 方法 — 权限校验 + 业务处理
```python
# src/modules/position/handlers.py
class PositionAddHandler(BaseHandler):
@permission_required("sys:position:add") # ① RBAC 权限校验
@check_demo # ② 演示模式拦截
@operation_log("岗位管理", "添加") # ③ 操作日志记录
async def post(self):
return await position_service.add(self) # ④ 调用 Service 层
```
**装饰器执行顺序**(从外到内):
```
① permission_required → 校验用户是否拥有 sys:position:add 权限
├── user_id == 1(admin)→ 直接放行
└── 其他用户 → 查询权限列表(Redis 缓存)→ 匹配则放行,否则返回 403
② check_demo → TORNADO_DEMO=True 时返回 "演示环境,暂无操作权限"
③ operation_log → 记录操作日志(请求参数、操作人、IP、耗时等)
④ handler method → 调用 position_service.add(self)
```
### 7. Service 层 — 业务逻辑
```python
# src/core/base_service.py
class BaseService:
async def add(self, handler) -> R:
try:
# 1. 解析请求体 JSON + Pydantic 校验
form = self._validate(handler, self.create_schema)
data = form.model_dump()
# 2. 唯一性校验
err = self._check_unique(form)
if err:
return R.failed(handler, err)
# 3. 前置扩展钩子
self._before_add(handler, data)
# 4. 组装入库字段
fields = self._build_create_fields(data)
# 5. 调用 Repository 创建记录
self.repo.create(self.model(**fields))
# 6. 返回成功响应
return R.ok(handler, msg="添加成功")
finally:
self.repo.close()
```
### 8. Repository 层 — 数据访问
```python
# src/core/base_repository.py
class BaseRepository:
def create(self, obj: T) -> T:
# 自动写入创建人
if hasattr(obj, "create_user"):
obj.create_user = str(self._uid())
self.db.add(obj)
self.db.commit()
return obj
```
### 9. 响应返回
```python
# src/core/response.py
def ok(self, data=None, msg="操作成功", code=0, **kwargs):
body = {"code": code, "data": data, "msg": msg, "ok": True}
if kwargs:
body.update(kwargs)
self._api_result = body
self.finish(body) # Tornado 写出 JSON 响应
```
`R.ok()` / `R.failed()` 内部调用 `self.finish(body)`,Tornado 自动序列化为 JSON 并发送 HTTP 响应。
### 10. on_finish() — 释放资源
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
def on_finish(self):
try:
db.close() # 兜底关闭数据库会话
except Exception:
pass
```
请求结束后关闭数据库会话,避免长连接泄漏。Service 层的 `finally` 块已关闭时此处为幂等 no-op。
### 11. 异常处理
```python
# src/core/base_handler.py
class BaseHandler(tornado.web.RequestHandler):
def write_error(self, status_code, **kwargs):
exc = kwargs.get("exc_info", (None, None, None))[1]
if isinstance(exc, BaseAppError):
# 业务异常:按类型分级记录日志
self.set_status(exc.status_code)
self.finish({"code": exc.code, "data": None, "msg": exc.msg, "ok": False})
else:
# 未预期异常:记录完整堆栈
self.set_status(500)
self.finish({"code": 500, "data": None, "msg": "服务器内部错误", "ok": False})
```
| 异常类型 | 触发场景 | 响应 |
|:---|:---|:---|
| `AuthError` | JWT 过期 / 无权限 | `{"code": 401, "msg": "登录过期"}` |
| `PermissionDeniedError` | 权限不足 | `{"code": 403, "msg": "权限不足"}` |
| `ValidationError` | Pydantic 校验失败 | `{"code": 1, "msg": "field: message"}` |
| `BusinessError` | 业务逻辑错误 | `{"code": 1, "msg": "..."}` |
| `NotFoundError` | 记录不存在 | `{"code": 404, "msg": "记录不存在"}` |
| `Exception` | 未捕获异常 | `{"code": 500, "msg": "服务器内部错误"}` |
## 完整链路流程图
```
[浏览器]
│ POST /api/v1/position/add
│ Authorization: Bearer <token>
▼
[Nginx] ── 反向代理转发
│
▼
[Tornado 路由匹配] ── 匹配 PositionAddHandler
│
▼
[set_default_headers()] ── 设置 CORS 头 + trace_id
│
▼
[prepare() + @login_required] ── JWT 认证
│ ├─ 白名单(/login、/captcha)→ 跳过
│ ├─ Token 无效/过期 → 返回 {code:401, msg:"登录过期"}
│ ├─ Token 在黑名单中 → 返回 {code:401, msg:"登录过期"}
│ └─ 认证通过 → current_user_id / current_username / current_realname 写入上下文
│
▼
[@permission_required("sys:position:add")] ── RBAC 权限校验
│ ├─ user_id == 1 → 直接放行
│ ├─ 权限不足 → 返回 {code:403, msg:"权限不足"}
│ └─ 权限通过 → 继续
│
▼
[@check_demo] ── 演示模式拦截
│ ├─ TORNADO_DEMO=True → 返回 {code:1, msg:"演示环境,暂无操作权限"}
│ └─ 非演示模式 → 继续
│
▼
[@operation_log("岗位管理", "添加")] ── 操作日志装饰器
│
▼
[async def post(self)] ── Handler 方法
│
▼
[position_service.add(self)] ── Service 层
├─ parse_json(handler) ── 解析请求体 JSON
├─ validate(schema, data) ── Pydantic 校验
├─ _check_unique(form) ── 唯一性校验(DB 查询)
├─ _before_add(handler, data) ── 前置扩展钩子
├─ _build_create_fields(data) ── 组装入库字段
└─ repo.create(model(**fields)) ── Repository 层
│
▼
[BaseRepository.create(obj)]
├─ obj.create_user = str(uid) ── 自动写入创建人
├─ db.add(obj) + db.commit() ── 提交事务
└─ return obj
│
▼
[R.ok(handler, msg="添加成功")] ── 统一响应
├─ self._api_result = body ── 保存结果供日志解析
└─ self.finish(body) ── 写出 JSON 响应
│
▼
[on_finish()] ── 释放资源
└─ db.close() ── 关闭数据库会话
│
▼
[浏览器] ← { "code": 0, "data": null, "msg": "添加成功", "ok": true }
```
## 总结
Tornado 的请求处理通过 Handler 生命周期方法(`set_default_headers` → `prepare` → `handler_method` → `on_finish`)和装饰器(`@permission_required`、`@check_demo`、`@operation_log`)实现横切关注点。认证在 `prepare()` 阶段完成,权限/演示模式/操作日志通过装饰器在 Handler 方法执行前校验,数据库会话在 `on_finish()` 中兜底关闭。这种设计使得每一层职责单一、可独立测试。
@@ -0,0 +1,735 @@
# 安全架构设计
本章详细阐述系统的安全架构设计,围绕认证、授权、防护、审计四大安全领域展开,旨在帮助开发者全面理解系统的安全保障体系,确保在开发与部署过程中遵循安全规范,有效防范各类安全风险。
**认证(Authentication)**:系统采用基于 JWT(JSON Web Token)的无状态认证机制,用户通过用户名密码登录后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头传递,由 `login_required` 依赖统一拦截并验证 Token 的有效性(签名校验、有效期检查、Redis 黑名单过滤)。同时,系统内置登录失败锁定策略,连续失败 5 次即锁定账号 10 分钟,有效防御暴力破解攻击。
**授权(Authorization)**:基于 RBAC(基于角色的访问控制)模型设计,权限控制粒度细化至接口级。用户通过角色继承权限节点(如 `sys:position:add`),由 `@permission_required` 装饰器在路由层统一拦截校验,权限数据采用 Redis 缓存加速查询,管理员(`userId == 1`)自动放行。权限变更时,相关缓存主动失效,确保权限更新的实时性。
**防护(Protection)**:系统从多维度构建安全防护体系。限流中间件基于 Redis 滑动窗口算法,有效防御 CC 攻击和接口滥用;参数校验通过 Pydantic Schema 自动拦截非法输入,防止 SQL 注入和 XSS 攻击;敏感密码采用 bcrypt 加盐哈希存储,即使数据库泄露也无法还原明文;演示模式下 `@check_demo` 装饰器统一拦截写操作,保护测试数据不被污染;跨域配置精确控制可信域名,防止 CSRF 攻击。
**审计(Audit)**:系统提供完备的操作审计能力。操作日志中间件自动记录所有写操作(POST/PUT/DELETE/PATCH)的请求路径、参数、操作人、IP 地址及执行结果,存入 `tornado_operation_log` 表;登录日志独立记录每次登录尝试(含 IP、User-Agent、登录状态),便于安全事件追溯与异常行为分析;定时任务每次执行记录至 `tornado_job_log` 表,确保后台任务的执行过程可审计。
:::tip 安全设计原则
纵深防御:认证、授权、限流、参数校验多层防护,单一防线失效时仍有其他机制兜底
最小权限:用户仅能访问已授权接口,角色权限按需分配,遵循"够用即可"原则
数据不落地:敏感信息(Token、密码)不在日志中明文记录,密码哈希不可逆
安全默认:框架默认配置为安全模式(如 DEBUG 关闭、限流开启),如需放宽需显式配置
:::
## 安全架构总览
```
┌─────────────────────────────────────────────────────────────────────┐
│ 安全防御层次 │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 网络层防护 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ CORS │ │ 限流 │ │ XSS 防护 │ │ 上传体积限制 │ │ │
│ │ │ 跨域控制 │ │ 滑动窗口 │ │ 输入清洗 │ │ DoS 防御 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 认证层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ JWT 认证 │ │ 验证码 │ │ 登录锁定 │ │ Token 黑名单 │ │ │
│ │ │ Bearer │ │ 图形验证 │ │ 失败计数 │ │ 登出失效 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 授权层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ RBAC 权限 │ │ 演示模式保护 │ │ 超级管理员跳过 │ │ │
│ │ │ permission_ │ │ check_demo │ │ user_id=1 bypass │ │ │
│ │ │ required │ │ │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 数据层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ 密码加密 │ │ 参数校验 │ │ SQL 注入 │ │ 操作日志 │ │ │
│ │ │ bcrypt │ │ Pydantic │ │ ORM 防护 │ │ 审计追踪 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 一、认证机制
### JWT 令牌认证
:::tip JWT 工作流程
用户登录成功后,服务端签发 JWT Token 返回给前端。前端将 Token 存储在本地,后续每次请求通过 `Authorization: Bearer <token>` 头携带。服务端验证 Token 签名和有效期,解析出用户信息。
:::
1. 工作流程时序图
![](/volume3/security/jwt_token.png)
2. 纯文本树状时序图
```
[前端] [后端] [Redis]
│ │ │
│── POST /login ─────────→│ │
│ {username, pwd, │ │
│ captcha, uuid} │ │
│ │── 验证码校验 (uuid) ──→│
│ │←─ captcha_text ────────│
│ │ │
│ │── 密码校验 │
│ │── 签发JWT │
│ │── 存储Token ──────────→│
│←─ {token} ─────────────│ │
│ │ │
│── GET /api/v1/xxx ────→│ │
│ Authorization: │ │
│ Bearer <token> │ │
│ │── 验证JWT │
│ │── 检查黑名单 ─────────→│
│ │←─ exists ─────────────│
│←─ {data} ──────────────│ │
```
:::tip 流程说明
1. 前端提交用户名、密码、验证码及 UUID
2. 后端校验验证码,验证密码,生成 JWT 并缓存至 Redis
3. 前端后续请求携带 JWT,后端校验 Token 有效性并检查黑名单
4. 校验通过后返回业务数据
:::
#### JWT 配置
```python
# src/config/auth.py
# ============================================================
# JWT 配置
# ============================================================
# JWT签名密钥(必须通过环境变量设置,不能使用默认值上线)
JWT_SECRET = os.getenv('JWT_SECRET', '')
# JWT令牌过期时间(分钟)
JWT_EXPIRE_MINUTES = int(os.getenv('JWT_EXPIRE_MINUTES', '20'))
JWT_ALGORITHM = "HS256" # 签名算法
```
#### Token 签发
```python
# src/core/jwt.py
def create_token(payload, timeout=None):
"""
生成JWT令牌
=============================================================
根据传入的载荷数据生成JWT访问令牌,自动添加过期时间。
使用强密钥确保令牌安全性,符合RFC 7518安全规范。
参数:
payload (dict): JWT载荷数据,通常包含用户ID、用户名等信息
timeout (int): 令牌过期时间(分钟),默认使用DEFAULT_TIMEOUT_MINUTES
返回:
str: 生成的JWT令牌字符串
使用示例:
>>> payload = {'userId': 1001, 'username': 'admin'}
>>> token = create_token(payload, timeout=30)
>>> print(token)
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
注意事项:
1. 密钥长度必须至少32字节,否则会发出警告
2. 生产环境建议通过环境变量设置JWT_SECRET
3. 令牌过期后需要重新登录获取新令牌
"""
try:
# 设置过期时间
# 说明:在载荷中添加exp字段,指定令牌的过期时间点
# 计算方式:当前UTC时间 + 指定的分钟数
if timeout is None:
timeout = DEFAULT_TIMEOUT_MINUTES
# 创建payload副本,避免修改原始数据
payload_copy = payload.copy()
# 添加过期时间(exp)和签发时间(iat)
# exp: 过期时间,超过此时间令牌无效
# iat: 签发时间,用于令牌生命周期计算
current_time = datetime.datetime.now(tz=datetime.timezone.utc)
payload_copy['exp'] = current_time + datetime.timedelta(minutes=timeout)
payload_copy['iat'] = current_time
# 声明类型和加密算法
# 说明:定义JWT头部信息,包含令牌类型和加密算法
headers = {
"typ": "JWT", # 令牌类型
"alg": JWT_ALGORITHM # 加密算法
}
# 生成JWT令牌
# 函数:jwt.encode()
# 参数说明:
# payload: JWT载荷数据
# key: 加密密钥(盐)
# algorithm: 加密算法(HS256)
# headers: JWT头部信息
token = jwt.encode(
payload=payload_copy,
key=JWT_SECRET,
algorithm=JWT_ALGORITHM,
headers=headers
)
# 记录日志(可选,生产环境建议使用DEBUG级别)
logger.debug(f"生成JWT令牌成功,过期时间:{timeout}分钟")
return token
except Exception as e:
# 捕获并记录生成令牌时的异常
logger.error(f"生成JWT令牌失败: {str(e)}")
raise
```
#### Token 验证
```python
# src/common/middleware/authentication.py
# ============================================================
# 登录认证装饰器
# ============================================================
def login_required(func):
"""登录检测装饰器:校验 JWT(含黑名单)并将 userId 写入上下文。"""
def wrapper(self, *args, **kwargs):
request_url = self.request.path
if request_url not in IGNORE_URL and self.request.method != "OPTIONS":
success, access_token, _ = get_access_token(self.request)
if not success or not access_token:
return R.failed(self, code=401, msg="登录过期")
result = parse_payload(access_token)
if result["code"] != 0:
return R.failed(self, code=401, msg="登录过期")
if is_token_blacklisted(access_token):
return R.failed(self, code=401, msg="登录过期")
data = result["data"]
current_user_id.set(int(data["userId"]))
current_username.set(data.get("username", ""))
current_realname.set(data.get("realname", ""))
return func(self, *args, **kwargs)
return wrapper
raise AuthorizationException(code=401, msg=result['msg'])
data = result['data']
user_id = int(data.get('userId', 0))
# 用户有效状态校验:优先读 Redis 缓存(状态变更/删除时已即时失效),
# 未命中再回源 DB 并回填缓存,避免每请求查库;Redis 不可用时缓存读失败自动回源 DB
from utils.perm_cache import get_cached_user_active, set_cached_user_active
active = await get_cached_user_active(user_id)
if active is None:
from modules.user.repository import UserRepository
user = UserRepository().get_by_id, user_id)
active = bool(user and user.status == 1)
await set_cached_user_active(user_id, active)
if not active:
raise AuthorizationException(code=401, msg="用户已被禁用或删除,请联系管理员")
# 写入上下文变量(contextvars),供仓储/服务层读取
current_user_id.set(user_id)
current_username.set(data.get('username', ''))
current_realname.set(data.get('realname', ''))
# 滑动窗口续签:token 生命过半时自动签发新 token,通过响应头返回
await _maybe_refresh_token(request, access_token, data)
```
#### Token 黑名单
用户登出时,将当前 Token 加入 Redis 黑名单,TTL 等于 Token 剩余有效期:
```python
# src/modules/auth/handlers.py
# ============================================================
# 退出登录
# ============================================================
class LogoutHandler(BaseHandler):
async def get(self):
"""用户退出登录:将当前访问令牌加入黑名单。"""
success, access_token, _ = get_access_token(self.request)
if success and access_token:
add_token_to_blacklist(access_token)
# 记录退出登录日志
username = get_username(request)
realname = get_realname(request)
login_log.record_login_log, request, username, 2, 0, 0, method='v1.logout',
request_method=request.method, create_user=realname, result="退出成功")
return R.ok(msg="注销成功")
```
### 登录验证码
:::tip 验证码防暴力破解
每次登录前需先获取验证码,验证码存储在 Redis 中(TTL 120 秒),登录时校验验证码正确性。验证码使用 UUID 作为 key,避免被猜测。
:::
```python
# 获取验证码
GET /api/v1/captcha
Response: { "key": "xxx", "captcha": "data:image/png;base64,iVBORw0KG..." }
# 登录时校验
POST /api/v1/login
Body: { "username": "admin", "password": "123456", "code": "a3Xb", "key": "xxx" }
```
### 登录失败锁定
连续 5 次登录失败后,账号自动锁定 10 分钟:
```python
# src/modules/auth/service.py
async def login(request, data):
redis = get_redis()
# 检查锁定状态
lock_key = f"login:lock:{data.username}"
is_locked = redis.get(lock_key)
if is_locked:
return R.failed("账号已锁定,请10分钟后再试")
# 验证用户名密码
user = UserRepository().get_one, username=data.username)
if not user or not verify_password(data.password, user.password):
# 原子递增失败计数
fail_key = f"login:fail:{data.username}"
count = await incr_with_expire(redis, fail_key, 300)
if count >= 5:
redis.set(lock_key, 1, ex=600) # 锁定 10 分钟
return R.failed(f"用户名或密码错误,剩余{5-count}次机会")
# 登录成功,清除失败计数
redis.delete(f"login:fail:{data.username}")
```
### 密码安全
```python
# src/core/password.py
# +======================================================================
# | 模块: 密码加密工具
# | 说明: 基于 bcrypt 的双重加盐方案
# +======================================================================
"""密码加密工具:基于 bcrypt 的双重加盐方案。"""
import secrets
import bcrypt
# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms)
BCRYPT_ROUNDS = 12
# ============================================================
# 密码工具函数
# ============================================================
def generate_salt(length: int = 10) -> str:
"""生成随机盐值"""
return secrets.token_urlsafe(length)[:length]
def encrypt_password(password: str, salt: str) -> str:
"""使用 bcrypt 加密密码(外部 salt 双重加盐)"""
combined = (password + salt).encode('utf-8')
hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
return hashed.decode('utf-8')
def verify_password(password: str, salt: str, hashed: str) -> bool:
"""验证密码是否正确"""
combined = (password + salt).encode('utf-8')
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
```
- 使用 bcrypt 算法,每次哈希自动生成随机盐
- 密码不可逆,数据库泄露后无法还原明文
- 默认密码在首次登录时强制修改
## 二、授权机制
### RBAC 权限模型
```
用户(User) ──N:M──→ 角色(Role) ──N:M──→ 菜单/权限(Menu)
│ │ │
│ user_role │ role_menu │ permission
│ 关联表 │ 关联表 │ 权限标识
└────────────────────┘ │
▼
sys:user:add
sys:user:page
sys:role:update
...
```
权限标识格式:`sys:{module}:{action}`
- `sys`:系统前缀
- `{module}`:模块名(user / role / menu / position / level 等)
- `{action}`:操作名(page / list / detail / add / update / delete / status / batchDelete / export / import)
### permission_required 装饰器
```python
# src/common/middleware/access_decorators.py
# ============================================================
# 辅助函数
# ============================================================
def _extract_request(*args, **kwargs):
"""从函数参数中提取 Request 对象"""
req = kwargs.get('request')
if req is not None:
return req
for arg in args:
if isinstance(arg, Request):
return arg
return None
# ============================================================
# 节点权限鉴权装饰器
# ============================================================
def permission_required(permission: str):
"""节点权限鉴权装饰器,userId==1(管理员)直接放行"""
def decorator(func):
def _check(self):
"""同步权限校验(直查DB,无Redis依赖,供线程池/回退场景使用)"""
userId = current_user_id.get()
if userId == 1:
return None
# 懒加载业务 service,避免 core → 业务模块顶层依赖
from menu import service as menu
permission_list = menu.get_permissions_list_sync(userId)
if permission not in permission_list:
return R.failed("权限不足")
return None
async def _check_cached(self):
"""异步权限校验(走 Redis 缓存,未命中才回源DB)"""
userId = current_user_id.get()
if userId == 1:
return None
# 懒加载业务 service,避免 core → 业务模块顶层依赖
from menu import service as menu
permission_list = await menu.get_permissions_list(userId)
if permission not in permission_list:
return R.failed("权限不足")
return None
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
request = _extract_request(*args, **kwargs)
denied = await _check_cached(request)
if denied:
return denied
return await func(*args, **kwargs)
return async_wrapper
@wraps(func)
def sync_wrapper(*args, **kwargs):
request = _extract_request(*args, **kwargs)
# 同步端点由 Tornado 在线程池中执行,直接走同步直查 DB 校验;
# 不再每请求 asyncio.run 新建事件循环(避免跨 loop 复用 Redis 连接
# 被静默吞掉、缓存形同虚设的性能与正确性问题)
denied = _check(request)
if denied:
return denied
return func(*args, **kwargs)
return sync_wrapper
return decorator
```
:::tip 同步/异步双通道
装饰器根据被装饰函数的类型自动选择校验通道:
- `async def` 端点 → `_check_cached` 走 Redis 缓存(异步)
- `def` 端点 → `_check` 直查 DB(同步,在 Tornado 线程池中执行)
这样避免了在同步端点中 `asyncio.run()` 新建事件循环导致的跨 loop 复用 Redis 连接问题。
:::
### 权限缓存
权限缓存采用「用户级缓存 + 全局版本号」双层策略(`src/common/permission.py`):
```python
# src/common/permission.py
_VERSION_KEY = 'perm:version' # 全局版本号
_PERM_PREFIX = 'perm:list:' # 用户权限列表前缀
_MENU_PREFIX = 'perm:menu:' # 用户菜单树前缀
_CACHE_TTL = 7200 # 缓存过期时间(2小时)
async def get_cached_permissions(user_id: int):
"""获取缓存的用户权限列表,未命中返回 None"""
redis = get_redis()
ver = await _get_version()
key = f'{_PERM_PREFIX}{user_id}:{ver}'
data = redis.get(key)
if data is not None:
return json.loads(data)
return None
async def set_cached_permissions(user_id: int, perm_list: list):
"""缓存用户权限列表(空列表不缓存,避免缓存空壳后无法感知后续数据变更)"""
if not perm_list:
return
redis = get_redis()
ver = await _get_version()
key = f'{_PERM_PREFIX}{user_id}:{ver}'
redis.set(key, json.dumps(perm_list), ex=_CACHE_TTL)
```
缓存失效策略:
```python
# src/common/permission.py
async def invalidate_menu_change():
"""菜单增删改时调用,递增全局版本号使所有用户缓存自然失效"""
await _incr_version()
async def invalidate_user_perms(user_id: int):
"""用户角色变更时调用,删除该用户的权限和菜单缓存"""
redis = get_redis()
ver = await _get_version()
keys = [
f'{_PERM_PREFIX}{user_id}:{ver}',
f'{_MENU_PREFIX}{user_id}:{ver}',
]
redis.delete(*keys)
async def invalidate_role_perms(role_id: int):
"""角色菜单变更时调用,清理该角色下所有用户的权限和菜单缓存"""
# 查询该角色下所有用户 ID
user_ids = _query_user_ids, role_id)
if not user_ids:
return
redis = get_redis()
ver = await _get_version()
keys = []
for uid in user_ids:
keys.append(f'{_PERM_PREFIX}{uid}:{ver}')
keys.append(f'{_MENU_PREFIX}{uid}:{ver}')
if keys:
redis.delete(*keys)
```
:::tip 版本号策略的优势
传统方案在菜单变更时需要遍历所有用户删除缓存,版本号方案只需递增一个全局 key,所有用户的旧版本缓存自然失效(key 中包含版本号,读取时用新版本号拼 key,旧 key 不再被访问,等 TTL 过期自动清除)。
:::
### check_demo 装饰器
```python
# src/common/middleware/access_decorators.py
def check_demo(func):
"""演示模式拦截装饰器,放在 @permission_required 之后、路由函数之前"""
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
if TORNADO_DEMO:
return R.failed("演示环境,暂无操作权限")
return await func(*args, **kwargs)
return async_wrapper
@wraps(func)
def sync_wrapper(*args, **kwargs):
if TORNADO_DEMO:
return R.failed("演示环境,暂无操作权限")
return func(*args, **kwargs)
return sync_wrapper
```
- 保护所有写操作(add / update / delete / status / batchDelete / import)
- 在 `permission_required` 之后应用
- 根据被装饰函数类型自动选择同步/异步包装
- `TORNADO_DEMO=True` 时,所有写操作返回失败
## 三、网络层防护
### CORS 跨域控制
```python
# src/common/middleware/cors.py
from config.app import TORNADO_CORS_ORIGINS
_ALLOWED_ORIGINS = {o.strip() for o in TORNADO_CORS_ORIGINS.split(",") if o.strip()}
def cros_required(self):
"""为响应设置跨域相关头。"""
origin = self.request.headers.get("Origin", "")
if _ALLOWED_ORIGINS:
if origin in _ALLOWED_ORIGINS:
self.set_header("Access-Control-Allow-Origin", origin)
self.set_header("Access-Control-Allow-Credentials", "true")
self.set_header("Vary", "Origin")
else:
self.set_header("Access-Control-Allow-Origin", "*")
self.set_header("Access-Control-Allow-Headers", "*")
self.set_header("Access-Control-Expose-Headers", "*")
self.set_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
self.set_header("Access-Control-Max-Age", "3600")
```
在 `BaseHandler.set_default_headers()` 中调用,每个请求自动设置 CORS 头。
- 生产环境应配置具体的 `TORNADO_CORS_ORIGINS`,避免使用 `*`
- 配置白名单后仅对匹配来源反射来源并允许携带凭据
### XSS 防护
```python
# src/common/utils/rich_text.py
import bleach
def sanitize_html(content: str) -> str:
"""XSS 清洗:移除危险标签和属性"""
allowed_tags = [
'p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'img', 'table', 'tr', 'td', 'th', 'tbody', 'thead'
]
allowed_attrs = {
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height'],
}
return bleach.clean(content, tags=allowed_tags, attributes=allowed_attrs)
```
- 富文本内容入库前进行 XSS 清洗
- 使用 `bleach` 库白名单过滤危险标签和属性
- 图片 `src` 属性只允许合法 URL
- 按 `Content-Length` 提前拒绝超大请求体
- 避免超大 multipart 落临时盘后再由业务层校验
## 四、数据层防护
### SQL 注入防护
:::tip ORM 天然防注入
SQLAlchemy ORM 使用参数化查询(Parameterized Query),所有用户输入通过参数绑定传递,不直接拼接 SQL 字符串,从根本上防止 SQL 注入。
:::
```python
# SQLAlchemy 参数化查询示例
UserRepository().get_one(username="admin")
# 实际执行:SELECT * FROM tornado_user WHERE username = :username AND is_delete = 0
# 参数绑定:{'username': 'admin'}
```
### 参数校验
```python
# Pydantic 声明式校验
class PositionForm(BaseSchema):
name: str = Field(..., min_length=1, max_length=150)
status: int = Field(..., ge=1, le=2)
sort: int = Field(..., ge=0, le=99999)
@field_validator('name')
@classmethod
def validate_name(cls, v):
if not v.strip():
raise ValueError('岗位名称不能为空')
return v.strip()
```
- 所有请求参数通过 Pydantic 声明式校验
- 自动拒绝不符合约束的请求
- 校验错误统一返回格式
### 敏感字段保护
- 密码使用 bcrypt 哈希存储,不可逆
- `to_dict()` 序列化时自动排除 `is_delete`、`password`、`salt`
- JWT 密钥通过环境变量配置,不硬编码
## 五、审计追踪
### 操作日志
```python
# src/common/middleware/operation_log.py
def operation_log(module: str, action: str):
"""操作日志记录装饰器:自动记录写操作的模块、操作类型、执行耗时。"""
def decorator(func):
@wraps(func)
async def wrapper(self, *args, **kwargs):
start = time.time()
result = await func(self, *args, **kwargs)
duration = time.time() - start
# 记录操作日志
log_data = {
'module': module,
'action': action,
'ip': get_client_ip(self.request),
'duration': int(duration * 1000),
}
save_operation_log(log_data)
return response
```
自动记录所有写操作的日志,包括:
- 操作人、操作模块、操作动作
- 请求 URL、请求参数
- 操作 IP、操作时间
- 响应状态、执行耗时
### 登录日志
每次登录/登出自动记录到 `tornado_login_log` 表,包括登录账号、IP、浏览器、操作系统、登录状态等。
## 安全配置清单
| 配置项 | 环境变量 | 默认值 | 说明 |
|:---|:---|:---|:---|
| JWT 密钥 | `JWT_SECRET` | 必须配置 | HS256 签名密钥 |
| JWT 过期时间 | `JWT_EXPIRE_MINUTES` | 20 | Token 有效期(分钟) |
| 密码错误锁定 | — | 5 次/10 分钟 | 连续失败后锁定 |
| 验证码有效期 | — | 120 秒 | 验证码 Redis TTL |
| 限流开关 | `RATE_LIMIT_ENABLED` | False | 滑动窗口限流 |
| 限流阈值 | `RATE_LIMIT_LIMIT` | 100 | 每窗口最大请求数 |
| 限流窗口 | `RATE_LIMIT_WINDOW_SECONDS` | 60 | 窗口时长(秒) |
| CORS 源 | `CORS_ORIGINS` | * | 允许的跨域源 |
| 上传限制 | `MAX_UPLOAD_SIZE` | 10MB | 最大上传体积 |
| 演示模式 | `TORNADO_DEMO` | False | 锁定写操作 |
| 数据库密码 | `DB_PASSWORD` | — | 数据库连接密码 |
| Redis 密码 | `REDIS_PASSWORD` | — | Redis 连接密码 |
:::warning 生产环境安全检查
1. `JWT_SECRET` 必须使用强随机字符串,不能使用默认值
2. `CORS_ORIGINS` 必须配置具体的域名,不能使用 `*`
3. `TORNADO_DEMO` 在生产环境应为 `False`
4. 数据库和 Redis 必须设置密码
5. 启用 HTTPS(由 Nginx/负载均衡器处理)
:::
## 总结
的安全架构涵盖认证(JWT + 验证码 + 登录锁定 + Token 黑名单)、授权(RBAC + permission_required + check_demo)、防护(CORS + 限流 + XSS + 上传限制 + SQL 注入防护)、审计(操作日志 + 登录日志)四大领域。通过纵深防御策略,在网络层、认证层、授权层、数据层分别设置安全屏障,确保系统在面对各类安全威胁时具备足够的防护能力。生产部署时需特别关注 JWT 密钥强度、CORS 配置、HTTPS 启用等关键安全项。
@@ -0,0 +1,133 @@
# 技术选型说明
本章详细阐述系统各技术组件的选型理由与版本约束说明,旨在帮助开发者理解技术选型的依据与权衡。
## 后端框架选型
### Tornado
:::tip 为什么选择 Tornado?
Tornado 是 Python 生态中成熟的异步 Web 框架,基于自有事件循环实现非阻塞 I/O。对于后台管理系统这类以 CRUD 为主的场景,Tornado 的轻量灵活和稳定可靠使其成为理想选择。
:::
| 特性 | 说明 |
|:---|:---|
| **原生异步** | 基于自有事件循环,`async/await` 一等公民,充分利用 I/O 等待时间 |
| **请求校验** | Pydantic v2 声明式校验,Service 层自动执行 |
| **类型安全** | 全链路类型提示,IDE 补全完善 |
| **轻量灵活** | 核心概念少,无强依赖,学习曲线平缓 |
| **稳定可靠** | 成熟的生产级框架,广泛应用于高并发场景 |
### 选择 Tornado 的核心理由
1. **原生异步**:后台管理系统需要处理并发请求(文件上传、批量操作、定时任务),Tornado 的原生异步模型能充分利用 I/O 等待时间
2. **Pydantic 校验**:声明式的数据校验,减少参数校验代码量
3. **SQLAlchemy 解耦**:ORM 不与框架强绑定,支持多数据库驱动切换(MySQL / PostgreSQL / SQL Server / SQLite / Oracle)
4. **轻量灵活**:无强依赖,核心概念少,易于理解和扩展
5. **稳定可靠**:经过多年生产验证的成熟框架
## ORM 选型
### SQLAlchemy 2.0
:::tip 为什么选择 SQLAlchemy?
SQLAlchemy 是 Python 生态中最成熟、功能最完备的 ORM 框架,2.0 版本统一了 Core 和 ORM 的 API 风格,同时保持了对多种数据库方言的原生支持。
:::
| 特性 | 说明 |
|:---|:---|
| **声明式模型** | 基于 `declarative_base()` 定义模型类,字段与数据库列一一映射 |
| **多数据库方言** | 通过 `DB_DRIVER` 切换 MySQL / PostgreSQL / SQL Server / SQLite / Oracle |
| **会话管理** | `scoped_session` + thread-local 实现请求级会话隔离 |
| **连接池** | 内置连接池(`QueuePool`),支持 `POOL_SIZE` / `MAX_OVERFLOW` 配置 |
| **软删除** | 通过 `is_delete` 字段 + Repository 基类自动过滤实现 |
| **读写分离** | 支持写引擎 + 读引擎分离,通过 `READ_DATABASE_URL` 配置 |
版本约束:`SQLAlchemy >= 2.0.51`
## 请求校验选型
### Pydantic v2
| 特性 | 说明 |
|:---|:---|
| **声明式校验** | 通过 `BaseModel` + `Field(...)` 声明字段约束,自动校验 |
| **自定义验证器** | `@field_validator` 支持复杂的业务校验逻辑 |
| **高性能** | v2 核心用 Rust 重写,校验速度较 v1 提升 5-50 倍 |
| **错误聚合** | Service 层 `validate()` 方法将校验错误提取为统一提示 |
版本约束:`pydantic >= 2.13.4`
## 前端技术栈
### Vue3 + Vite + ElementPlus
| 组件 | 版本 | 选型理由 |
|:---|:---|:---|
| **Vue3** | 3.x | Composition API、更好的 TypeScript 支持 |
| **Vite** | 5.x | 极速 HMR、ESM 原生支持、开箱即用的 TypeScript 支持 |
| **ElementPlus** | 2.x | Vue3 生态最成熟的 UI 组件库,企业级后台管理系统首选 |
| **Pinia** | 2.x | Vue3 官方推荐的状态管理方案,替代 Vuex |
| **Vue Router** | 4.x | Vue3 官方路由,支持动态路由、路由守卫 |
| **Axios** | 1.x | HTTP 客户端,支持请求/响应拦截器、取消请求 |
## 缓存与消息中间件
### Redis
:::tip Redis 在系统中的角色
Redis 在系统中承担多个关键职责:JWT 令牌黑名单、登录失败锁定、数据字典缓存、权限缓存等。采用同步客户端(`redis`)与全站同步 DB/Redis 调用风格一致。
:::
| 用途 | 数据结构 | 过期策略 |
|:---|:---|:---|
| JWT 黑名单 | String | 等于 Token 剩余有效期 |
| 登录失败锁 | String | 指定锁定时长 |
| 数据字典缓存 | String (JSON) | TTL 可配置 |
| 权限列表缓存 | String (JSON) | 变更时主动失效(按版本号) |
版本约束:`redis >= 5.0.0`(同步客户端)
## 定时任务
### APScheduler
| 特性 | 说明 |
|:---|:---|
| **调度策略** | 支持 Cron、Interval、Date 三种触发器 |
| **持久化** | 任务定义存储在数据库,重启后自动恢复 |
| **日志记录** | 每次执行记录到 `tornado_job_log` 表 |
## 密码安全
### bcrypt 双盐方案
| 特性 | 说明 |
|:---|:---|
| **双盐机制** | 系统生成随机盐 + bcrypt 自带盐,双重保护 |
| **自动升级** | 登录时检测旧版无盐哈希,自动升级为双盐方案 |
| **安全存储** | 盐值存储在用户表 `salt` 字段,哈希存储在 `password` 字段 |
## 其他关键依赖
| 依赖 | 用途 |
|:---|:---|
| `PyJWT` | JWT 令牌签发与验证(HS256 算法),`src/core/jwt.py` |
| `bcrypt` | 密码哈希(带随机盐),`src/core/password.py` |
| `openpyxl` | Excel 导入导出 |
| `Pillow` | 验证码图片生成,`src/common/utils/captcha.py` |
| `python-dotenv` | `.env` 配置文件加载 |
| `alembic` | 数据库迁移,`migrations/` 目录 |
| `APScheduler` | 定时任务调度,`src/modules/job/scheduler.py` |
| `bleach` | 富文本 XSS 清洗,`src/common/utils/rich_text.py` |
| `ip2region` | IP 地理位置解析,`src/common/utils/ip2region.py` |
## 版本锁定策略
:::warning 版本管理
所有依赖版本在 `pyproject.toml` 中锁定(Poetry 管理),`requirements.txt` 为 UTF-8 降级副本。生产环境建议使用 `poetry export` 生成精确版本锁文件。
:::
## 总结
项目的技术选型以「高性能、类型安全、可扩展」为核心原则。Tornado + SQLAlchemy 2.0 + Pydantic v2 构成后端铁三角,Vue3 + Vite + ElementPlus 提供现代化前端体验,Redis 承担缓存与中间件职责。各组件版本经过充分测试验证,形成稳定的技术底座。选型时优先考虑组件的独立性和可替换性,避免强耦合,为未来的架构演进预留空间。
@@ -0,0 +1,194 @@
# Tornado IOLoop 与异步模型
本章详细说明 Tornado 的异步编程模型,包括 IOLoop 事件循环、协程使用方式、与同步 SQLAlchemy 的协作,以及需要避免的反模式。
:::tip 核心原则
Tornado 基于单线程事件循环(IOLoop),Handler 方法默认为协程(`async def`)。正确理解 IOLoop 与同步阻塞操作的关系,是避免事件循环阻塞、保证系统并发性能的关键。
:::
## Tornado 的执行模型
Tornado 使用单线程 IOLoop 事件循环处理请求:
```
IOLoop 事件循环
│
├─ 请求1 进入 → Handler.get() 协程 → await → 挂起
│ │
├─ 请求2 进入 → Handler.get() 协程 → await → 挂起
│ │
├─ 请求1 的 await 完成 → 恢复执行 → 返回响应
│
├─ 请求2 的 await 完成 → 恢复执行 → 返回响应
│
└─ ...
```
Tornado **不会**自动将同步函数放入线程池。在 `async def` 中直接调用同步阻塞操作会阻塞整个 IOLoop。本项目的 DB 和 Redis 操作均为同步调用,通常毫秒级完成,短暂阻塞在实际业务中可接受。
## 决策树
```
Handler/Service 需要做什么?
│
├─ 包含 await 操作(Redis / 异步 HTTP / 异步文件读取)
│ └─ 使用 async def + await
│
├─ 纯同步操作(DB CRUD / 文件处理)
│ └─ 使用 async def(Tornado Handler 默认)+ 直接调用同步代码
│
└─ 混合操作(同步 DB + 异步 Redis)
└─ 使用 async def,同步部分直接调用,异步部分 await
```
## Tornado 与 SQLAlchemy 的协作
本项目使用 **同步 SQLAlchemy**(scoped_session),在 Tornado 的 `async def` 中直接调用:
```python
# src/modules/link/handlers.py
class LinkPageHandler(BaseHandler):
@permission_required("sys:link:list")
async def get(self):
# 直接调用同步 Service(内部使用同步 SQLAlchemy)
return await link_service.get_page(self)
```
```python
# src/modules/link/service.py
class LinkService(BaseService):
# 同步方法,直接调用 Repository
def get_page(self, handler):
# Repository 内部使用同步 SQLAlchemy Session
return self.repo.paginate(...)
```
:::warning 为什么同步 DB 操作在 Tornado 中可行?
本项目的同步 SQLAlchemy 操作通常执行很快(毫秒级),短暂阻塞 IOLoop 在实际业务中可接受。如果遇到慢查询场景,可考虑使用 `tornado.ioloop.IOLoop.current().run_in_executor()` 将其放入线程池。
:::
## Handler 方法的异步模式
### 标准模式:async def
所有 Handler 方法默认使用 `async def`:
```python
class LinkAddHandler(BaseHandler):
@permission_required("sys:link:add")
@check_demo
@operation_log("友链管理", "添加")
async def post(self):
return await link_service.add(self)
```
### 涉及 Redis 的同步操作
本项目使用 **同步 Redis 客户端**(`from core.redis import redis`),Redis 操作与 DB 操作一样是同步调用:
```python
# src/modules/auth/service.py
from core.redis import redis
async def login(self):
# 检查登录失败锁定(同步 Redis 操作)
lock_key = f"login:lock:{ip}:{username}"
is_locked = redis.get(lock_key)
if is_locked:
return R.failed(self, "账号已锁定,请稍后再试")
# 验证用户名密码(同步 DB 操作)
user = UserRepository().get_by_username(username)
if not user:
# 记录失败次数(同步 Redis 操作)
redis.incr(f"login:fail:{ip}:{username}")
redis.expire(f"login:fail:{ip}:{username}", 300)
return R.failed(self, "用户名或密码错误")
# 签发 JWT Token(同步操作)
token = create_token({"userId": user.id})
return R.ok(self, msg="登录成功", data={"access_token": token})
```
## 各模块的异步使用模式
| 模块 | 模式 | 原因 |
|:---|:---|:---|
| Link / Position / Level | `async def` + 同步 DB | 纯 DB CRUD,同步操作快速返回 |
| Dept / Menu / Category | `async def` + 同步 DB | 纯 DB CRUD(含树形查询) |
| Role / User | `async def` + 同步 DB | 纯 DB CRUD(含关联查询) |
| Article / Notice | `async def` + 同步 DB + 文件 | DB CRUD + 文件处理 |
| Auth (login/logout) | `async def` + 混合 | 同步 DB + 异步 Redis |
| Dict / Config | `async def` + 同步 DB | DB CRUD + Redis 缓存 |
| Job (scheduler) | 同步 `def` | APScheduler 在独立线程执行 |
## IOLoop 启动与关闭
### 启动流程
```python
# src/bootstrap.py
def main():
app = create_app()
http_server = tornado.httpserver.HTTPServer(app)
http_server.listen(options.port)
# 启动定时任务调度器
from modules.job.scheduler import init_scheduler
init_scheduler()
ioloop = tornado.ioloop.IOLoop.current()
# 注册信号处理(优雅关闭)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda s, f: ioloop.add_callback_from_signal(_shutdown))
ioloop.start()
```
### 优雅关闭
```python
def _shutdown():
"""收到退出信号时关闭调度器并停止事件循环。"""
from modules.job.scheduler import shutdown_scheduler
shutdown_scheduler()
ioloop.stop()
```
## 禁止的模式
:::danger 避免在 Handler 中执行长时间同步操作
在 `async def` 中执行长时间同步操作(如大文件读写、复杂计算)会阻塞整个 IOLoop,导致所有并发请求被阻塞。
:::
### 错误示例
```python
# 错误!在 async def 中执行长时间同步操作
async def get(self):
# 大文件读取阻塞 IOLoop
with open("huge_file.csv", "r") as f:
data = f.read() # 阻塞!
self.finish({"code": 0, "data": data})
```
### 正确示例
```python
# 正确:使用 run_in_executor 将阻塞操作放入线程池
async def get(self):
import asyncio
loop = asyncio.get_event_loop()
data = await loop.run_in_executor(None, self._read_file, "huge_file.csv")
self.finish({"code": 0, "data": data})
def _read_file(self, path):
with open(path, "r") as f:
return f.read()
```
## 总结
Tornado 基于单线程 IOLoop 事件循环,Handler 方法默认 `async def`。本项目使用同步 SQLAlchemy,DB 操作通常毫秒级,直接在协程中调用可接受。涉及 Redis 等异步操作时使用 `await`。避免在协程中执行长时间同步操作,必要时使用 `run_in_executor` 放入线程池。定时任务调度器在独立线程运行,不受 IOLoop 影响。