签入版本
This commit is contained in:
@@ -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. 工作流程时序图
|
||||
|
||||

|
||||
|
||||
|
||||
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 影响。
|
||||
Reference in New Issue
Block a user