签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -0,0 +1,217 @@
# 路由注册(Flask Blueprint)
Flask 使用 `Blueprint` 机制实现模块化路由注册。每个业务模块定义自己的 Blueprint,然后在路由注册中心统一挂载到应用。
## Blueprint 概念
`Blueprint` 是 Flask 提供的模块化路由组织方式。它允许将路由、视图函数、模板等组织在一个模块中,然后在应用中注册使用。
## 模块 Blueprint 定义
每个业务模块在 `router.py` 文件中定义自己的 Blueprint:
```python
# src/modules/system/position/router.py
from flask import Blueprint, request
from src.core.permission import check_permission
from src.core.response import R
from src.common.utils.request import parse_request_body, parse_id_list
# 定义 Blueprint
# 参数说明:
# - 'position': Blueprint 名称,用于内部标识
# - __name__: 模块名称,用于确定静态文件等资源路径
# - url_prefix='/position': 路由前缀,所有路由都会添加此前缀
position_blue = Blueprint('position', __name__, url_prefix='/position')
@position_blue.route('/page', methods=['GET'])
@check_permission('sys:position:page')
def page():
"""分页查询岗位列表"""
from src.modules.system.position.service import get_position_page
return get_position_page()
@position_blue.route('/detail/<int:id>', methods=['GET'])
@check_permission('sys:position:detail')
def detail(id):
"""查询岗位详情"""
from src.modules.system.position.service import get_position_detail
return get_position_detail(id)
@position_blue.route('/add', methods=['POST'])
@check_permission('sys:position:add')
def add():
"""新增岗位"""
from src.modules.system.position.service import add_position
from src.modules.system.position.schema import PositionForm
data = parse_request_body(request, PositionForm)
return add_position(data)
@position_blue.route('/update', methods=['PUT'])
@check_permission('sys:position:update')
def update():
"""更新岗位"""
from src.modules.system.position.service import update_position
from src.modules.system.position.schema import PositionForm
data = parse_request_body(request, PositionForm)
return update_position(data)
@position_blue.route('/delete/<int:id>', methods=['DELETE'])
@check_permission('sys:position:delete')
def delete(id):
"""删除岗位"""
from src.modules.system.position.service import delete_position
return delete_position(id)
@position_blue.route('/status', methods=['PUT'])
@check_permission('sys:position:status')
def status():
"""更新岗位状态"""
from src.modules.system.position.service import update_position_status
from src.modules.system.position.schema import PositionStatusForm
data = parse_request_body(request, PositionStatusForm)
return update_position_status(data)
```
## 路由注册中心
所有模块的 Blueprint 在 `src/routes/v1.py` 中统一注册:
```python
# src/routes/v1.py
from flask import Blueprint
from src.modules.system.position.router import position_blue
from src.modules.system.level.router import level_blue
from src.modules.system.dept.router import dept_blue
from src.modules.system.role.router import role_blue
from src.modules.system.menu.router import menu_blue
from src.modules.system.user.router import user_blue
# ... 其他模块导入
# 全部业务蓝图(按领域分组排序)
BLUEPRINTS = [
# 系统管理模块
user_blue, role_blue, menu_blue, dept_blue, position_blue, level_blue,
# 内容管理模块
# article_blue, category_blue, ...
# 监控管理模块
# job_blue, job_log_blue, ...
# ... 其他模块
]
def register_v1(app):
"""
注册 API v1 路由(所有业务蓝图挂载到 /api/v1)。
Args:
app: Flask 应用实例
"""
# 创建 v1 父 Blueprint
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
# 注册所有子 Blueprint
for blue in BLUEPRINTS:
v1.register_blueprint(blue)
# 将 v1 注册到应用
app.register_blueprint(v1)
return app
```
## 路由注册流程
```
┌─────────────────────────────────────────────────────────────┐
│ Flask 应用 (app) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ v1 Blueprint (prefix: /api/v1) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ position_blue │ │ level_blue │ │ dept_blue │
│ /position │ │ /level │ │ /dept │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
▼ ▼ ▼
/api/v1/position/* /api/v1/level/* /api/v1/dept/*
```
## Blueprint 参数说明
```python
position_blue = Blueprint(
'position', # Blueprint 名称
__name__, # 模块名称
url_prefix='/position', # 路由前缀
# template_folder='templates', # 模板文件夹(可选)
# static_folder='static', # 静态文件夹(可选)
)
```
| 参数 | 说明 |
|:----|:----|
| `name` | Blueprint 名称,用于内部标识,必须唯一 |
| `import_name` | 模块名称,通常使用 `__name__` |
| `url_prefix` | 路由前缀,所有路由都会添加此前缀 |
| `template_folder` | 模板文件夹路径(相对于 Blueprint) |
| `static_folder` | 静态文件夹路径(相对于 Blueprint) |
## 路由装饰器
Flask 使用 `@blueprint.route()` 装饰器定义路由:
```python
@position_blue.route('/page', methods=['GET'])
def page():
"""分页查询岗位列表"""
pass
```
| 参数 | 说明 |
|:----|:----|
| `rule` | 路由规则,如 `/page`、`/detail/<int:id>` |
| `methods` | HTTP 方法列表,如 `['GET']`、`['POST']` |
### 动态路由参数
```python
@position_blue.route('/detail/<int:id>', methods=['GET'])
def detail(id):
"""查询岗位详情"""
pass
```
| 转换器 | 说明 | 示例 |
|:----|:----|:----|
| `<int:id>` | 整数参数 | `/detail/123` |
| `<string:name>` | 字符串参数(默认) | `/detail/abc` |
| `<float:price>` | 浮点数参数 | `/price/12.5` |
| `<uuid:id>` | UUID 参数 | `/detail/550e8400-e29b-41d4-a716-446655440000` |
## 开发要点
1. **每个模块定义自己的 Blueprint**:在 `router.py` 文件中定义
2. **统一在 `v1.py` 中注册**:将所有 Blueprint 添加到 `BLUEPRINTS` 列表
3. **使用 `check_permission` 装饰器**:实现 RBAC 权限控制
4. **路由前缀与模块目录名保持一致**:便于代码导航
5. **使用中文注释**:方便理解路由功能
## 总结
Flask Blueprint 是模块化路由组织的核心机制。每个业务模块定义自己的 Blueprint,在 `src/routes/v1.py` 中统一注册到 `v1` 父 Blueprint,最终挂载到 Flask 应用。通过 `url_prefix` 设置路由前缀,使用 `@blueprint.route()` 装饰器定义路由,结合 `check_permission` 实现 RBAC 权限控制。
@@ -0,0 +1,382 @@
# 完整代码清单
本章汇总 Position 岗位模块涉及的全部文件,便于开发时快速查阅和复制。
## 后端文件清单
### 1. 数据模型 `src/modules/system/position/models.py`
```python
"""
岗位模型模块
=============================================================
岗位模型定义,包含名称、排序、状态等属性。
"""
from sqlalchemy import Column, String, Integer, text
from src.common.base_db import BaseDB
from src.common.base_model import BaseModel
from config.database import DB_PREFIX
# ============================================================
# 岗位模型
# ============================================================
class Position(BaseModel, BaseDB):
"""岗位模型类"""
# ============================================================
# 表名配置
# ============================================================
__tablename__ = DB_PREFIX + "position"
__table_comment__ = "岗位表"
# ============================================================
# 字段定义
# ============================================================
# 岗位名称
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
# ============================================================
# 内置方法
# ============================================================
def __repr__(self):
"""返回岗位名称作为字符串表示"""
return '岗位:{}'.format(self.name)
```
### 2. 表单验证 `src/modules/system/position/schema.py`
```python
"""
岗位表单验证模块
=============================================================
提供岗位相关的表单验证功能,包括:
- 岗位添加/更新表单验证
- 岗位状态设置表单验证
"""
from wtforms import ValidationError, StringField, IntegerField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm
from src.modules.system.position.repository import PositionRepository
# ============================================================
# 岗位表单验证(添加和更新共用)
# ============================================================
class PositionForm(BaseForm):
"""
岗位表单验证类
Attributes:
id: 岗位ID(更新时必填,添加时可选)
name: 岗位名称(必填,最长150字符,不可重复)
status: 岗位状态(必填,1-在用,2-停用)
sort: 岗位排序(可选,0-99999之间的整数)
"""
id = IntegerField(
label='岗位ID',
validators=[Optional(), NumberRange(min=1, message='岗位ID必须大于0')],
)
name = StringField(
label='岗位名称',
validators=[
DataRequired(message='岗位名称不能为空'),
Length(max=150, message='岗位名称长度不得超过150个字符')
],
)
status = IntegerField(
label='岗位状态',
validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
],
)
sort = IntegerField(
label='岗位排序',
validators=[Optional(), NumberRange(min=0, max=99999, message='岗位排序值在0~99999之间')],
)
def validate_name(self, field):
"""岗位名称唯一性校验"""
if not field.data:
return
current_id = self.id.data if self.id.data else None
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("岗位名称已存在")
# ============================================================
# 状态设置表单
# ============================================================
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField(
label='岗位ID',
validators=[DataRequired(message='岗位ID不能为空'), NumberRange(min=1, message='岗位ID必须大于0')],
)
status = IntegerField(
label='岗位状态',
validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
],
)
```
### 3. 数据访问层 `src/modules/system/position/repository.py`
```python
"""
岗位仓储模块
=============================================================
岗位数据访问层,继承 BaseRepository 获得通用 CRUD 能力。
"""
from src.common.base_repository import BaseRepository
from src.common.constants.common import PAGE_SIZE
from src.modules.system.position.models import Position
class PositionRepository(BaseRepository):
"""岗位数据仓储,提供岗位相关的数据库访问操作"""
model = Position
@classmethod
def page(cls, params: dict):
"""
岗位分页查询
Args:
params: 查询参数字典(pageNo/pageSize/name/status)
Returns:
tuple: (items, total, pages)
"""
return cls.find_page(
page=int(params.get('pageNo', 1)),
page_size=int(params.get('pageSize', PAGE_SIZE)),
filters={'status': params.get('status')},
like_filters={'name': params.get('name')},
order_by='sort',
)
@classmethod
def list_enabled(cls, params=None):
"""
查询启用的岗位列表
Args:
params: 查询参数字典,可包含 name 字段
Returns:
list: 启用状态的岗位实例列表
"""
params = params or {}
return cls.find_list(
filters={'status': 1},
like_filters={'name': params.get('name')},
order_by='sort',
)
```
### 4. 业务逻辑层 `src/modules/system/position/service.py`
```python
"""
岗位管理服务模块
=============================================================
该模块实现了岗位管理相关的所有业务逻辑。
"""
from src.common.base_service import BaseService
from src.core.exceptions import BizError
from src.modules.system.position.schema import PositionForm, PositionStatusForm
from src.modules.system.position.repository import PositionRepository
from src.modules.system.user.repository import UserRepository
class PositionService(BaseService):
"""
岗位服务类
继承 BaseService 获得标准 CRUD 能力,并扩展删除前用户引用校验。
"""
repository = PositionRepository
form = PositionForm
status_form = PositionStatusForm
model_name = "岗位"
@classmethod
def before_delete(cls, ids):
"""
删除前校验钩子
检查岗位是否仍被用户引用,若被引用则拒绝删除。
"""
for pid in ids:
if UserRepository.count_by({'position_id': pid}) > 0:
raise BizError(f"岗位ID {pid} 已被用户引用,无法删除")
# ============================================================
# 模块级代理函数:保持 router 调用兼容
# ============================================================
get_position_page = PositionService.get_page
get_position_detail = PositionService.get_detail
get_info = PositionService.get_info
add_position = PositionService.add
update_position = PositionService.update
delete_positions = PositionService.remove
update_position_status = PositionService.update_status
get_position_list = PositionService.get_list
```
### 5. 路由控制器 `src/modules/system/position/router.py`
```python
"""
岗位管理控制器模块
=============================================================
提供岗位(Position)相关的API接口。
"""
from flask import Blueprint, request
from src.middleware import check_demo, operation_log
from src.core.permission import check_permission
from src.modules.system.position import service as position
from src.common.utils.request import parse_batch_ids, parse_request_body
from src.core import response as R
position_blue = Blueprint('position', __name__, url_prefix='/position')
@position_blue.route('/page', methods=["GET"])
@check_permission("sys:position:page")
@operation_log("岗位管理", "查询")
def page():
"""查询岗位分页数据"""
return R.ok(data=position.get_position_page(request.args))
@position_blue.route('/detail/<int:id>', methods=["GET"])
@check_permission("sys:position:detail")
@operation_log("岗位管理", "详情")
def detail(id):
"""查询岗位详情"""
data = position.get_position_detail(id)
return R.ok(data=data)
@position_blue.route('/add', methods=["POST"])
@check_permission("sys:position:add")
@check_demo
@operation_log("岗位管理", "新增")
def add():
"""添加岗位"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.add_position(json_data)
return R.ok(msg="添加成功")
@position_blue.route('/update', methods=["PUT"])
@check_permission("sys:position:update")
@check_demo
@operation_log("岗位管理", "修改")
def update():
"""更新岗位"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.update_position(json_data)
return R.ok(msg="更新成功")
@position_blue.route('/delete/<string:id>', methods=["DELETE"])
@check_permission("sys:position:delete")
@check_demo
@operation_log("岗位管理", "删除")
def delete(id):
"""删除岗位"""
count = position.delete_positions(id)
return R.ok(msg=f"本次共删除{count}条数据")
@position_blue.route('/batchDelete', methods=["DELETE"])
@check_permission("sys:position:batchDelete")
@check_demo
@operation_log("岗位管理", "批量删除")
def batch_delete():
"""批量删除岗位"""
id_str, err = parse_batch_ids()
if err:
return err
count = position.delete_positions(id_str)
return R.ok(msg=f"本次共删除{count}条数据")
@position_blue.route('/status', methods=["PUT"])
@check_permission("sys:position:status")
@check_demo
@operation_log("岗位管理", "状态变更")
def status():
"""设置岗位状态"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.update_position_status(json_data)
return R.ok(msg="设置成功")
@position_blue.route('/list', methods=["GET"])
@check_permission("sys:position:list")
@operation_log("岗位管理", "查询")
def get_position_list():
"""获取岗位数据列表(无分页)"""
result = position.get_position_list(request.args)
return R.ok(data=result)
```
### 6. 路由注册 `src/routes/v1.py`
```python
from src.modules.system.position.router import position_blue
# ... 其他模块导入
BLUEPRINTS = [
# 系统管理模块
user_blue, role_blue, menu_blue, dept_blue, position_blue, level_blue,
# ... 其他模块
]
```
## API 接口清单
| 方法 | 路径 | 权限节点 | 说明 |
|:---|:---|:---|:---|
| GET | `/api/v1/position/page` | `sys:position:page` | 分页查询 |
| GET | `/api/v1/position/detail/<id>` | `sys:position:detail` | 详情查询 |
| GET | `/api/v1/position/list` | `sys:position:list` | 列表查询(无分页) |
| POST | `/api/v1/position/add` | `sys:position:add` | 新增 |
| PUT | `/api/v1/position/update` | `sys:position:update` | 更新 |
| DELETE | `/api/v1/position/delete/<id>` | `sys:position:delete` | 单条删除 |
| DELETE | `/api/v1/position/batchDelete` | `sys:position:batchDelete` | 批量删除 |
| PUT | `/api/v1/position/status` | `sys:position:status` | 状态变更 |
@@ -0,0 +1,287 @@
# Router 层(Flask Blueprint)
Router 层是 HTTP 接口层,负责定义路由、权限装饰器和请求参数解析。每个业务模块对应一个 router 文件,所有业务逻辑委托给 Service 层处理。
## 文件位置
```
src/modules/system/position/router.py
```
## 完整代码
以岗位模块为例,`src/modules/system/position/router.py` 完整内容:
```python
"""
岗位管理控制器模块
=============================================================
提供岗位(Position)相关的API接口,包括:
- 岗位分页查询
- 岗位详情查询
- 岗位添加、更新、删除
- 岗位状态设置
- 岗位数据列表获取
"""
from flask import Blueprint, request
from src.middleware import check_demo, operation_log
from src.core.permission import check_permission
from src.modules.system.position import service as position
from src.common.utils.request import parse_batch_ids, parse_request_body
from src.core import response as R
position_blue = Blueprint('position', __name__, url_prefix='/position')
# ============================================================
# 岗位分页查询
# ============================================================
@position_blue.route('/page', methods=["GET"])
@check_permission("sys:position:page")
@operation_log("岗位管理", "查询")
def page():
"""
查询岗位分页数据
Returns:
JSON: 岗位分页数据,包含 records(数据列表)、total(总记录数)等
"""
return R.ok(data=position.get_position_page(request.args))
# ============================================================
# 岗位详情查询
# ============================================================
@position_blue.route('/detail/<int:id>', methods=["GET"])
@check_permission("sys:position:detail")
@operation_log("岗位管理", "详情")
def detail(id):
"""
查询岗位详情
Args:
id: 岗位ID
Returns:
JSON: 岗位详细信息
"""
data = position.get_position_detail(id)
return R.ok(data=data)
# ============================================================
# 岗位添加
# ============================================================
@position_blue.route('/add', methods=["POST"])
@check_permission("sys:position:add")
@check_demo
@operation_log("岗位管理", "新增")
def add():
"""
添加岗位
Returns:
JSON: 操作结果
"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.add_position(json_data)
return R.ok(msg="添加成功")
# ============================================================
# 岗位更新
# ============================================================
@position_blue.route('/update', methods=["PUT"])
@check_permission("sys:position:update")
@check_demo
@operation_log("岗位管理", "修改")
def update():
"""
更新岗位
Returns:
JSON: 操作结果
"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.update_position(json_data)
return R.ok(msg="更新成功")
# ============================================================
# 岗位删除
# ============================================================
@position_blue.route('/delete/<string:id>', methods=["DELETE"])
@check_permission("sys:position:delete")
@check_demo
@operation_log("岗位管理", "删除")
def delete(id):
"""
删除岗位
Args:
id: 岗位ID(支持逗号分隔的多个ID)
Returns:
JSON: 操作结果
"""
count = position.delete_positions(id)
return R.ok(msg=f"本次共删除{count}条数据")
# ============================================================
# 岗位批量删除
# ============================================================
@position_blue.route('/batchDelete', methods=["DELETE"])
@check_permission("sys:position:batchDelete")
@check_demo
@operation_log("岗位管理", "批量删除")
def batch_delete():
"""
批量删除岗位
Request Body:
[1, 2, 3] # 岗位ID数组
Returns:
JSON: 操作结果
"""
id_str, err = parse_batch_ids()
if err:
return err
count = position.delete_positions(id_str)
return R.ok(msg=f"本次共删除{count}条数据")
# ============================================================
# 岗位状态设置
# ============================================================
@position_blue.route('/status', methods=["PUT"])
@check_permission("sys:position:status")
@check_demo
@operation_log("岗位管理", "状态变更")
def status():
"""
设置岗位状态
Returns:
JSON: 操作结果
"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.update_position_status(json_data)
return R.ok(msg="设置成功")
# ============================================================
# 岗位列表获取(无分页)
# ============================================================
@position_blue.route('/list', methods=["GET"])
@check_permission("sys:position:list")
@operation_log("岗位管理", "查询")
def get_position_list():
"""
获取岗位数据列表(无分页)
用于下拉选择框,支持 name 模糊匹配。
Returns:
JSON: 岗位列表数据
"""
result = position.get_position_list(request.args)
return R.ok(data=result)
```
## 代码解析
### Blueprint 实例
```python
position_blue = Blueprint('position', __name__, url_prefix='/position')
```
每个 router 文件创建一个独立的 `Blueprint` 实例,后续在 `src/routes/v1.py` 中统一注册。
### 装饰器说明
| 装饰器 | 作用 | 位置 |
|:---|:---|:---|
| `@position_blue.route()` | 定义 HTTP 方法和路径 | 最外层 |
| `@check_permission('sys:position:page')` | RBAC 权限校验 | 路由装饰器之后 |
| `@check_demo` | 演示模式拦截(写操作) | 权限装饰器之后 |
| `@operation_log("岗位管理", "查询")` | 操作日志记录 | 最内层 |
**装饰器顺序必须严格遵守**:路由 → 权限 → 演示模式 → 操作日志。
### 权限字符串命名规范
```
sys:{module}:{action}
```
| 动作 | HTTP 方法 | 说明 |
|:---|:---|:---|
| `page` | GET | 分页查询 |
| `detail` | GET | 详情查询 |
| `list` | GET | 列表查询(无分页) |
| `add` | POST | 新增 |
| `update` | PUT | 更新 |
| `delete` | DELETE | 单条删除 |
| `batchDelete` | DELETE | 批量删除 |
| `status` | PUT | 状态变更 |
### 参数解析
```python
# 解析请求体 JSON(POST/PUT)
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
# 解析批量删除的 ID 数组(DELETE)
id_str, err = parse_batch_ids()
if err:
return err
# 获取查询参数(GET)
params = request.args # 如 request.args.get('name')
```
### 响应封装
```python
# 成功响应
R.ok(data=data) # 返回数据
R.ok(msg="添加成功") # 返回消息
R.ok(data=data, msg="ok") # 同时返回数据和消息
# 失败响应
R.failed(msg="参数错误") # 返回错误消息
```
## 开发要点
1. **只需两步注册**:一行导入 Blueprint,一行添加到 `BLUEPRINTS` 列表
2. **prefix 与模块目录名保持一致**:便于代码导航
3. **所有业务蓝图都挂在 `v1` 路由组下**:自动享受 `login_required` 认证保护
4. **装饰器顺序**:路由 → `@check_permission` → `@check_demo` → `@operation_log`
5. **Router 层不处理业务逻辑**:只做参数解析和响应封装,业务逻辑委托 Service 层
## 总结
Router 层通过 Flask Blueprint 定义路由,使用 `@check_permission` 实现 RBAC 权限控制,`@check_demo` 拦截演示模式写操作,`@operation_log` 自动记录操作日志。参数解析通过 `parse_request_body()` / `parse_batch_ids()` 完成,响应封装通过 `R.ok()` / `R.failed()` 统一格式。
+167
View File
@@ -0,0 +1,167 @@
# 常见问题与排错
本章汇总模块开发过程中的常见问题和解决方案。
## 后端问题
### Q1: 启动报错 `Table 'xxx.flask_position' doesn't exist`
**原因**:模型定义后未执行建表。
**解决**:
```bash
# 方式一:脚本自动建表
python scripts/setup_db.py
# 方式二:Flask-Migrate 迁移
flask db migrate -m "add position table"
flask db upgrade
# 方式三:手动建表(参考 model.md 中的 DDL)
```
### Q2: 接口返回 `{"code": 1, "msg": "权限不足"}`
**原因**:权限节点未配置或未分配给当前用户角色。
**排查步骤**:
1. 确认菜单管理中已添加权限节点,权限标识与代码中 `@check_permission` 完全一致
2. 确认权限节点已分配给当前用户的角色
3. 使用 admin 账号(ID=1)测试,admin 自动跳过权限校验
### Q3: 唯一性校验不生效
**原因**:Schema 中 `validate_` 方法的 `exists_by` 调用错误。
**排查步骤**:
1. 确认 Schema 类中定义了 `validate_{field_name}` 方法
2. 确认 `exists_by({'field': value}, exclude_id=current_id)` 参数正确
3. 确认调用的是 `service.add()` 或 `service.update()`,而非直接操作 repo
### Q4: 分页查询返回空数据
**原因**:分页参数名不匹配或字段配置错误。
**排查步骤**:
1. 前端传参名应为 `pageNo` 和 `pageSize`(驼峰)
2. 确认 Repository 的 `page()` 方法中 `filters` / `like_filters` 字段名与模型一致
3. 检查数据库中是否有数据(`is_delete=0` 的记录)
### Q5: 删除时报错 "存在引用"
**原因**:`before_delete` 钩子检测到关联数据。
**解决**:先调整引用该记录的其他数据,再执行删除。如岗位被用户引用时,需先修改用户的岗位字段。
### Q6: 批量删除报 500 错误
**原因**:`parse_id_list` 函数未正确导入或使用。
**解决**:确保正确导入并使用:
```python
from src.common.utils.request import parse_batch_ids
@position_blue.route('/batchDelete', methods=["DELETE"])
@check_permission("sys:position:batchDelete")
@check_demo
def batch_delete():
id_str, err = parse_batch_ids()
if err:
return err
count = position.delete_positions(id_str)
return R.ok(msg=f"本次共删除{count}条数据")
```
## 前端问题
### Q7: 页面白屏,控制台报 `Failed to fetch dynamically imported module`
**原因**:路由组件路径配置错误。
**排查步骤**:
1. 确认路由中的 `component` 路径与实际文件路径一致
2. 确认后端菜单的 `component` 字段值正确(如 `system/position/index`)
3. 检查文件名大小写是否匹配
### Q8: 搜索功能不生效
**原因**:搜索表单的 `submit` 事件未正确绑定。
**排查步骤**:
1. 确认 `BasicForm` 绑定了 `@submit="handleSearch"` 事件
2. 确认 `handleSearch` 中调用了 `reload({ searchInfo: values })`
3. 确认 Repository 的 `page()` 方法中 `filters` / `like_filters` 配置了对应字段
### Q9: 编辑弹窗不回填数据
**原因**:`openModal` 未传入记录数据。
**解决**:
```javascript
// 正确:第一个参数 true 表示编辑模式,第二个参数是记录数据
openModal(true, record);
```
### Q10: 操作按钮不显示
**原因**:`v-perm` 权限标识与后端不一致。
**排查步骤**:
1. 确认 `v-perm` 中的权限字符串与后端 `@check_permission` 完全一致
2. 确认当前用户拥有对应权限
3. 检查 `TableAction` 中的 `auth` 字段是否正确
## 通用问题
### Q11: 接口返回 401 Unauthorized
**原因**:JWT Token 过期或未携带。
**解决**:
1. 确认请求头包含 `Authorization: Bearer <token>`
2. Token 过期后需调用刷新接口或重新登录
3. 前端 `VAxios` 自动处理 Token 注入,检查是否正确引入
### Q12: 接口返回 403 Forbidden(演示模式)
**原因**:演示模式下写操作被拦截。
**解决**:将 `.env` 中的 `FLASK_DEMO` 设为 `False`,或移除端点上的 `@check_demo` 装饰器(仅开发阶段)。
### Q13: 数据库字段中文乱码
**原因**:数据库或表的字符集不是 UTF-8。
**解决**:
- MySQL:确保数据库和表使用 `utf8mb4` 字符集
- PostgreSQL:建库时指定 `ENCODING 'UTF8'`
## 开发调试技巧
### 查看 SQL 日志
在 `.env` 中开启 SQL 调试:
```bash
DB_DEBUG=True
```
重启后所有 SQL 查询都会输出到控制台。
### 使用 Postman 调试
使用 Postman 或 curl 调试接口:
1. 先调用登录接口获取 Token
2. 在请求头中添加 `Authorization: Bearer <token>`
3. 调用业务接口
### 检查权限配置
如果不确定权限是否配置正确,可以用 admin 账号测试。admin(ID=1)自动跳过所有权限校验。
## 总结
模块开发常见问题主要集中在:字段类型不匹配、唯一性校验遗漏、软删除过滤缺失、分页参数命名不一致、权限节点未配置等。遇到问题时优先检查后端日志和接口返回值,确认接口入参和返回值是否符合预期。
@@ -0,0 +1,185 @@
# 前端 API 层
前端 API 层负责封装后端接口的 HTTP 请求。每个业务模块对应一个 API 文件,使用项目封装的 Axios 工具发送请求。
## 文件位置
```
ui/src/api/system/position.ts
```
## 完整代码
```typescript
import { http } from '@/utils/http/axios';
/**
* @description: 岗位列表(分页)
*/
export function getPositionList(params?) {
return http.request({
url: '/position/page',
method: 'GET',
params,
});
}
/**
* 获取全部岗位列表(无分页,用于下拉选择)
* @param params 参数
* @returns 返回结果
*/
export function getPositionAllList(params?) {
return http.request({
url: '/position/list',
method: 'GET',
params,
});
}
/**
* @description: 根据ID获取详情
*/
export function getPositionDetail(positionId) {
return http.request({
url: '/position/detail/' + positionId,
method: 'get',
});
}
/**
* @description: 添加岗位
*/
export function positionAdd(data: any) {
return http.request({
url: '/position/add',
method: 'POST',
data,
});
}
/**
* @description: 更新岗位
*/
export function positionUpdate(data: any) {
return http.request({
url: '/position/update',
method: 'PUT',
data,
});
}
/**
* @description: 删除岗位
*/
export function positionDelete(positionId) {
return http.request({
url: '/position/delete/' + positionId,
method: 'DELETE',
});
}
/**
* @description: 批量删除岗位
*/
export function positionBatchDelete(data: any) {
return http.request({
url: '/position/batchDelete',
method: 'DELETE',
data,
});
}
```
## 代码解析
### HTTP 客户端
```typescript
import { http } from '@/utils/http/axios';
```
`http` 是项目封装的 Axios 实例,自动处理:
- 请求拦截:添加 JWT Token(`Authorization: Bearer xxx`)
- 响应拦截:统一处理错误码、弹出提示
- 基础路径:自动拼接 `/api/v1` 前缀
### 请求方式
```typescript
http.request({
url: '/position/page', // 接口路径(不含 /api/v1 前缀)
method: 'GET', // HTTP 方法
params, // GET 参数(拼接到 URL 查询字符串)
})
```
| 参数 | 用途 |
|:----|:----|
| `url` | 接口路径,与后端 router 定义的 path 对应 |
| `method` | HTTP 方法:GET / POST / PUT / DELETE |
| `params` | GET 请求参数(拼接到 URL) |
| `data` | POST/PUT/DELETE 请求体(JSON) |
### 函数命名规范
| 模式 | 示例 | 说明 |
|:----|:----|:----|
| `get{Module}List` | `getPositionList` | 分页列表查询 |
| `get{Module}AllList` | `getPositionAllList` | 全量列表查询 |
| `get{Module}Detail` | `getPositionDetail` | 详情查询 |
| `{module}Add` | `positionAdd` | 新增 |
| `{module}Update` | `positionUpdate` | 更新 |
| `{module}Delete` | `positionDelete` | 单条删除 |
| `{module}BatchDelete` | `positionBatchDelete` | 批量删除 |
### 参数传递区别
```typescript
// GET:参数通过 params 传递(拼接到 URL 查询字符串)
http.request({ url: '/position/page', method: 'GET', params })
// POST/PUT/DELETE:参数通过 data 传递(放入请求体 JSON)
http.request({ url: '/position/add', method: 'POST', data })
// 路径参数:直接拼接到 URL
http.request({ url: '/position/delete/' + positionId, method: 'DELETE' })
```
## 页面中的调用方式
```vue
<script setup>
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
// 分页查询
const result = await getPositionList({ pageNo: 1, pageSize: 10, name: '测试' });
// 详情查询
const detail = await getPositionDetail(1);
// 新增
await positionAdd({ name: '新岗位', status: 1, sort: 0 });
// 更新
await positionUpdate({ id: 1, name: '修改后', status: 1, sort: 1 });
// 单条删除
await positionDelete(1);
// 批量删除
await positionBatchDelete([1, 2, 3]);
</script>
```
## 开发要点
1. **一个模块一个文件**:放在 `ui/src/api/{group}/` 目录下
2. **URL 不含 `/api/v1` 前缀**:`http` 自动拼接
3. **GET 用 `params`,POST/PUT/DELETE 用 `data`**:参数传递方式不同
4. **路径参数直接拼接到 URL**:如 `/position/delete/` + id
5. **批量删除的参数是数组**:`data: [1, 2, 3]`
## 总结
前端 API 层使用封装后的 `VAxios` 发送请求,URL 使用相对路径(不含 `/api/v1` 前缀),函数命名采用 `名词+动词` 格式。GET 请求用 `params`,POST/PUT/DELETE 用 `data`,响应拦截器自动处理错误。
@@ -0,0 +1,139 @@
# 前端路由与菜单
前端路由定义页面的访问路径,菜单是路由的可视化展示。项目的路由和菜单由后端动态返回,前端只需注册路由组件映射。
## 路由配置
### 文件位置
路由配置文件按模块分组:
```
ui/src/router/routes/system.ts
```
### 添加路由
在 `system.ts` 中添加岗位管理的路由:
```typescript
{
path: '/system',
name: 'System',
component: Layout,
meta: { title: '系统管理', icon: 'setting' },
children: [
{
path: 'position',
name: 'Position',
component: () => import('/@/views/system/position/index.vue'),
meta: { title: '岗位管理' },
},
// ... 其他系统管理路由
],
}
```
### 路由字段说明
| 字段 | 说明 |
|:----|:----|
| `path` | 路由路径(子路由不带 `/`) |
| `name` | 路由名称(唯一标识) |
| `component` | 组件引用(懒加载) |
| `meta.title` | 菜单显示标题 |
| `meta.icon` | 菜单图标 |
### 路由懒加载
```typescript
component: () => import('/@/views/system/position/index.vue')
```
使用动态 `import()` 实现路由懒加载,访问时才加载组件代码,优化首屏加载速度。
## 后端菜单配置
路由的实际注册由后端菜单数据驱动。在「系统管理 → 菜单管理」中添加菜单节点:
| 字段 | 值 |
|:----|:----|
| 菜单名称 | 岗位管理 |
| 菜单类型 | 菜单(type=1) |
| 路由路径 | `/system/position` |
| 组件路径 | `system/position/index` |
| 菜单图标 | 选择合适图标 |
| 排序号 | 设置显示顺序 |
| 状态 | 启用 |
### 菜单数据返回格式
后端接口 `/api/v1/menu/list` 返回的菜单数据:
```json
{
"code": 0,
"data": [
{
"id": 10,
"name": "系统管理",
"parent_id": 0,
"path": "/system",
"component": "Layout",
"type": 1,
"icon": "setting",
"children": [
{
"id": 101,
"name": "岗位管理",
"parent_id": 10,
"path": "/system/position",
"component": "system/position/index",
"type": 1,
"icon": "post"
}
]
}
]
}
```
### 前端路由生成
前端在登录后请求菜单数据,动态生成路由:
```
登录成功 → 请求 /api/v1/menu/list → 生成动态路由 → 注册到 Vue Router → 渲染菜单
```
## 菜单与权限的关系
```
菜单管理
├── 系统管理(目录)
│ ├── 岗位管理(菜单)
│ │ ├── 岗位查询(权限)→ sys:position:page
│ │ ├── 岗位新增(权限)→ sys:position:add
│ │ ├── 岗位编辑(权限)→ sys:position:update
│ │ └── 岗位删除(权限)→ sys:position:delete
│ └── ...
└── ...
```
| 节点类型 | type | 作用 |
|:----|:----|:----|
| 目录 | 0 | 菜单分组,不对应页面 |
| 菜单 | 1 | 对应一个页面路由 |
| 权限 | 2 | 对应一个操作权限,不在菜单中显示 |
## 开发要点
1. **路由文件按模块分组**:`system.ts`、`content.ts` 等
2. **组件使用懒加载**:`() => import(...)`
3. **菜单数据由后端驱动**:前端只需注册组件映射
4. **组件路径与后端 `component` 字段对应**:如 `system/position/index`
5. **新增模块只需在对应分组路由文件中追加子路由**
## 总结
前端路由使用 Vue Router 4,菜单路由由后端动态返回,前端通过 `addRoute` 动态注册。路由守卫负责权限校验和登录状态检查,未登录自动跳转登录页。
@@ -0,0 +1,512 @@
# 前端页面视图
前端页面视图是用户直接交互的界面。项目使用 Vue3 + ElementPlus + Vite 构建。一个完整的 CRUD 页面由 4 个文件组成:
```
ui/src/views/system/position/
├── index.vue # 主页面:搜索 + 表格 + 操作
├── edit.vue # 编辑弹窗:新增/编辑表单
├── columns.ts # 表格列定义
└── querySchemas.ts # 搜索表单 Schema
```
## 1. 主页面 `index.vue`
主页面是模块的入口页面,包含搜索表单、数据表格和操作按钮。
### 完整代码
```vue
<template>
<PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card>
<el-card :bordered="false" class="proCard">
<BasicTable
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
>
<template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon>
<el-icon class="el-input__icon">
<PlusOutlined />
</el-icon>
</template>
添加岗位
</el-button>
<el-button
type="danger"
@click="handleDelete()"
:disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']"
>
<template #icon>
<el-icon class="el-input__icon">
<Delete />
</el-icon>
</template>
删除
</el-button>
</template>
</BasicTable>
</el-card>
<editDialog
v-if="editVisible"
:positionId="positionId"
v-model:visible="editVisible"
@success="reloadTable('noRefresh')"
/>
</PageWrapper>
</template>
<script lang="ts" setup>
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
import { ColProps } from 'element-plus';
import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index';
import { TableAction } from '@/components/Table';
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
import { columns } from './columns';
import { PlusOutlined } from '@vicons/antd';
import { message, confirm } from '@/utils/auth';
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
const positionId = ref(0);
const editVisible = ref(false);
const selectionData = ref([]);
const tableRef = ref();
/**
* 定义查询参数
*/
const formParams = reactive({
name: '',
status: '',
});
/**
* 定义操作栏
*/
const actionColumn = reactive({
width: 200,
label: '操作',
prop: 'action',
fixed: 'right',
render(record) {
return h(TableAction, {
style: 'button',
actions: [
{
label: '编辑',
icon: 'Edit',
type: 'warning',
onClick: handleEdit.bind(null, record),
auth: ['sys:position:update'],
},
{
label: '删除',
icon: 'Delete',
type: 'danger',
onClick: handleDelete.bind(null, record),
auth: ['sys:position:delete'],
},
],
});
},
});
/**
* 加载数据列表
*/
const loadDataTable = async (res: any) => {
const result = await getPositionList({ ...formParams, ...res });
return result;
};
/**
* 刷新数据列表
*/
function reloadTable(noRefresh = '') {
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
}
/**
* 注册搜索表单
*/
const [register, {}] = useForm({
labelWidth: 80,
layout: 'horizontal',
colProps: { span: 6 } as ColProps,
submitOnReset: true,
schemas,
});
/**
* 执行提交表单
*/
function handleSubmit(values: Recordable) {
handleReset();
for (const key in values) {
formParams[key] = values[key];
}
reloadTable();
}
/**
* 执行重置
*/
function handleReset() {
for (const key in formParams) {
formParams[key] = '';
}
}
/**
* 执行添加
*/
const handleAdd = async () => {
positionId.value = 0;
await nextTick();
editVisible.value = true;
};
/**
* 执行编辑
*/
const handleEdit = async (record: Recordable) => {
positionId.value = record.row.id;
await nextTick();
editVisible.value = true;
};
/**
* 执行删除(单条 + 批量)
*/
async function handleDelete(record: Recordable) {
let ids = [];
if (!record) {
ids = selectionData.value.map(({ id }) => id);
}
await confirm('确定要删除?');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
message('删除成功');
reloadTable();
}
/**
* 选项发生变化
*/
function onSelectionChange(value) {
selectionData.value = value;
}
</script>
```
### 代码解析
**页面结构**:`PageWrapper` 包裹两个 `el-card`,上方是搜索表单,下方是数据表格。
**搜索表单**:通过 `useForm` 注册,Schema 从 `querySchemas.ts` 导入。`submitOnReset: true` 表示重置时自动触发查询。
**数据表格**:通过 `BasicTable` 组件渲染,`request` 属性绑定数据加载函数,`columns` 从 `columns.ts` 导入。
**操作栏**:通过 `actionColumn` 的 `render` 函数使用 `h()` 渲染 `TableAction`,支持权限控制(`auth`)。
**编辑弹窗**:使用 `defineAsyncComponent` 懒加载 `edit.vue`,通过 `v-model:visible` 控制显示,`positionId` 传入编辑记录 ID(0 表示新增)。
**删除逻辑**:单条删除传入 `record`,批量删除从 `selectionData` 取已选 ID。共用同一个 `handleDelete` 方法。
## 2. 编辑弹窗 `edit.vue`
编辑弹窗是独立组件,负责新增和编辑操作的表单展示与提交。
### 完整代码
```vue
<template>
<el-dialog
v-model="props.visible"
:title="props.positionId ? '编辑' : '新增'"
width="500"
:close-on-click-modal="false"
:before-close="dialogClose"
>
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
<el-form-item
label="岗位名称"
prop="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
>
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
<el-form-item label="岗位状态" prop="status">
<el-radio-group v-model="formData.status" name="status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogClose">取消</el-button>
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { onMounted, reactive, shallowRef } from 'vue';
import { message } from '@/utils/auth';
import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']);
const formRef = shallowRef<FormInstance>();
/**
* 定义表单参数
*/
const formData = reactive({
id: '',
name: '',
status: 1,
sort: 0,
});
/**
* 定义接收的参数
*/
const props = defineProps({
visible: {
type: Boolean,
required: true,
default: false,
},
positionId: {
type: Number,
required: true,
default: 0,
},
});
/**
* 执行提交表单
*/
const handleSubmit = async () => {
await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功');
emit('update:visible', false);
emit('success');
};
/**
* 关闭窗体
*/
const dialogClose = () => {
emit('update:visible', false);
};
const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
/**
* 设置表单数据(编辑模式)
*/
const setFormData = async () => {
const data = await getPositionDetail(props.positionId);
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key];
}
}
};
/**
* 钩子函数
*/
onMounted(() => {
if (props.positionId) {
setFormData();
}
});
</script>
```
### 代码解析
**Props 接收**:
- `visible`:控制弹窗显示/隐藏,通过 `v-model:visible` 双向绑定
- `positionId`:编辑记录的 ID,为 0 时表示新增模式
**表单数据**:使用 `reactive` 定义,字段与后端 Schema 对应。
**新增/编辑判断**:`props.positionId ? positionUpdate : positionAdd`,通过 ID 是否为 0 区分。
**防重复提交**:`useLockFn` 包装提交函数,点击后按钮变为 loading 状态,防止重复提交。
**数据回填**:`onMounted` 时判断如果是编辑模式,调用 `getPositionDetail` 获取数据并填充表单。
**事件通信**:
- `emit('update:visible', false)`:关闭弹窗
- `emit('success')`:通知父页面刷新表格
## 3. 表格列定义 `columns.ts`
定义表格的列配置,包括列名、字段名、宽度和自定义渲染。
### 完整代码
```typescript
import { h } from 'vue';
import { ElTag } from 'element-plus';
export const columns = [
{
type: 'selection',
},
{
label: 'ID',
prop: 'id',
fixed: 'left',
width: 50,
},
{
label: '岗位名称',
prop: 'name',
minWidth: 100,
},
{
label: '岗位状态',
prop: 'status',
minWidth: 100,
render(record) {
return h(
ElTag,
{
type: record.row.status == 1 ? 'success' : 'danger',
},
{
default: () => (record.row.status == 1 ? '正常' : '停用'),
},
);
},
},
{
label: '排序',
prop: 'sort',
minWidth: 100,
},
{
label: '创建人',
prop: 'createUser',
minWidth: 100,
},
{
label: '创建时间',
prop: 'createTime',
width: 180,
},
];
```
### 代码解析
**选择列**:`type: 'selection'` 自动添加复选框列,配合 `@selection-change` 事件实现批量选择。
**固定列**:`fixed: 'left'` 固定在左侧,`fixed: 'right'` 固定在右侧(操作列)。
**自定义渲染**:`render(record)` 使用 Vue 的 `h()` 函数创建虚拟 DOM。状态列将 `1/2` 渲染为绿色/红色标签。
**宽度设置**:
- `width`:固定宽度
- `minWidth`:最小宽度,可自适应拉伸
## 4. 搜索表单 `querySchemas.ts`
定义搜索表单的字段配置,使用项目封装的 `FormSchema` 类型。
### 完整代码
```typescript
import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
component: 'Input',
label: '岗位名称',
componentProps: {
placeholder: '请输入岗位名称',
},
},
{
field: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
clearable: true,
options: [
{
label: '正常',
value: '1',
},
{
label: '禁用',
value: '2',
},
],
},
},
];
```
### 代码解析
**FormSchema 字段**:
| 字段 | 说明 |
|:----|:----|
| `field` | 字段名,与后端查询参数对应 |
| `component` | 表单组件类型:`Input` / `Select` / `DatePicker` 等 |
| `label` | 标签文本 |
| `componentProps` | 组件属性,透传给 ElementPlus 组件 |
**Select 组件**:`options` 定义下拉选项,`clearable: true` 允许清空选择。
## 文件关系图
```
index.vue(主页面)
├── import schemas from './querySchemas' ← 搜索表单配置
├── import columns from './columns' ← 表格列配置
├── import editDialog from './edit.vue' ← 编辑弹窗组件
└── import API from '@/api/system/position' ← 接口请求
edit.vue(编辑弹窗)
└── import API from '@/api/system/position' ← 接口请求
```
## 开发要点
1. **一个模块一个目录**:放在 `ui/src/views/{group}/{module}/` 下
2. **4 个文件各司其职**:主页面、编辑弹窗、列定义、搜索 Schema
3. **编辑弹窗是独立组件**:通过 Props 接收 ID,通过 Emit 通知父页面
4. **列定义使用 `h()` 渲染复杂内容**:如状态标签、操作按钮
5. **搜索 Schema 使用 `FormSchema` 类型**:配置化定义表单字段
6. **新增/编辑通过 `positionId` 区分**:0 为新增,非 0 为编辑
7. **防重复提交使用 `useLockFn`**:提交时按钮 loading
## 总结
前端页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套,分页由 `BasicTable` 内置处理。每个模块包含 `index.vue`(列表页)、`edit.vue`(编辑弹窗)、`columns.ts`(列定义)、`querySchemas.ts`(搜索配置)四个文件。
@@ -0,0 +1,126 @@
# 数据模型(Model)
数据模型是模块开发的第一步,用于定义数据库表结构。项目使用 `SQLAlchemy ORM`,模型类通过继承基类自动获得通用字段和方法。
## 文件位置
```
src/modules/system/position/models.py
```
## 完整代码
```python
from sqlalchemy import Column, String, Integer, text
from src.common.base_db import BaseDB
from src.common.base_model import BaseModel
from config.database import DB_PREFIX
class Position(BaseModel, BaseDB):
"""岗位模型类"""
# 表名配置
__tablename__ = DB_PREFIX + "position"
__table_comment__ = "岗位表"
# 业务字段定义
# 岗位名称
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
def __repr__(self):
"""返回岗位名称作为字符串表示"""
return '岗位:{}'.format(self.name)
```
## 代码解析
### 基类继承
```python
class Position(BaseModel, BaseDB):
```
| 基类 | 来源 | 提供能力 |
|:----|:----|:----|
| `BaseModel` | `src/common/base_model.py` | 通用字段:id、create_user、create_time、update_user、update_time、is_delete |
| `BaseDB` | `src/common/base_db.py` | `save()` 持久化方法、`delete()` 删除方法、`to_dict()` 序列化方法 |
### 表名配置
```python
__tablename__ = DB_PREFIX + "position"
```
`DB_PREFIX` 从 `.env` 读取(默认 `flask_`),最终表名为 `flask_position`。所有业务表都使用此前缀,便于区分框架表与业务表。
### 字段定义
```python
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
```
| 参数 | 说明 |
|:----|:----|
| `String(255)` | 字段类型,VARCHAR(255) |
| `nullable=False` | 非空约束 |
| `index=True` | 创建索引,加速查询 |
| `comment` | 字段注释,同步到数据库 |
```python
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
```
`default=0` 是 Python 端默认值,`server_default=text('0')` 是数据库端默认值。两者都设置可确保无论通过 ORM 还是直接 SQL 插入,都能正确赋默认值。
### 枚举值约定
项目中状态字段统一使用整数枚举:
| 值 | 含义 |
|:----|:----|
| 1 | 启用 / 在用 |
| 2 | 停用 / 禁用 |
软删除字段 `is_delete` 由基类提供:`0` = 正常,`1` = 已删除。
## 数据表 DDL(参考)
模型定义对应的 MySQL 建表语句:
```sql
CREATE TABLE `flask_position` (
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '岗位名称',
`status` int DEFAULT '0' COMMENT '岗位状态:1-在用 2-停用',
`sort` int DEFAULT '0' COMMENT '岗位排序',
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`create_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT '0' COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_flask_position_name` (`name`) USING BTREE,
KEY `idx_flask_position_status` (`status`) USING BTREE,
KEY `idx_flask_position_is_delete` (`is_delete`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='岗位表';
```
:::tip 温馨提示
实际开发中不需要手写 DDL。模型定义完成后,框架会通过 `Base.metadata.create_all()` 自动建表,或通过 `flask db migrate` 生成迁移脚本。
:::
## 开发要点
1. **业务字段仅定义差异部分**:通用字段(id、时间、软删除等)由基类自动提供
2. **表名必须加 `DB_PREFIX` 前缀**:保持命名一致性
3. **合理使用索引**:查询频繁的字段(如 name、status)设置 `index=True`
4. **同时设置 `default` 和 `server_default`**:确保 ORM 和直 SQL 两种场景都能正确赋默认值
## 总结
数据模型继承 `BaseModel`(公共字段)和 `BaseDB`(save/delete 方法),表名必须加 `DB_PREFIX` 前缀。查询频繁的字段设置索引,同时设置 `default` 和 `server_default` 确保 ORM 和直 SQL 两种场景都能正确赋默认值。
@@ -0,0 +1,94 @@
# 概述与目标
本章以「岗位管理(Position)」模块为完整案例,手把手演示从零开发一个业务模块的全流程。学完本章后,你将掌握项目中任意模块的开发方式。
## 你将学到什么
通过本章的实战演练,你将完整经历以下开发环节:
| 环节 | 涉及文件 | 说明 |
|:----|:----|:----|
| 数据模型 | `models.py` | 定义数据库表结构 |
| 请求校验 | `schema.py` | WTForms 表单验证 |
| 数据访问 | `repository.py` | 封装数据库查询 |
| 业务逻辑 | `service.py` | 实现增删改查与业务规则 |
| 路由定义 | `router.py` | 定义 HTTP 接口 |
| 路由注册 | `v1.py` | 挂载路由到应用 |
| 权限配置 | 菜单管理 | 配置权限节点 |
| 前端对接 | API + 页面 + 路由 | 前端完整开发 |
## 案例模块:岗位管理
选择「岗位管理」作为案例的原因:
1. **结构典型**:包含完整的 CRUD + 状态管理 + 删除校验,覆盖大多数业务场景
2. **复杂度适中**:字段少(name / status / sort),逻辑清晰,适合入门
3. **依赖简单**:仅被 User 模块引用,无复杂关联关系
4. **代码精简**:后端 5 个文件 + 前端 3 个文件,总计约 500 行代码
## 模块文件总览
```
后端(src/)
├── modules/system/position/
│ ├── models.py # 数据模型
│ ├── schema.py # 表单验证
│ ├── repository.py # 数据访问层
│ ├── service.py # 业务逻辑层
│ └── router.py # 路由定义层
前端(ui/src/)
├── api/system/
│ └── position.ts # API 请求封装
├── views/system/
│ └── position/
│ ├── index.vue # 主页面:搜索 + 表格 + 操作
│ ├── edit.vue # 编辑弹窗:新增/编辑表单
│ ├── columns.ts # 表格列定义
│ └── querySchemas.ts # 搜索表单 Schema
└── router/routes/
└── system.ts # 路由配置(追加)
```
## 数据库表结构
岗位表 `flask_position` 结构如下:
| 字段 | 类型 | 说明 |
|:----|:----|:----|
| id | int | 主键,自增 |
| name | varchar(255) | 岗位名称 |
| status | int | 状态:1-在用 2-停用 |
| sort | int | 排序号 |
| create_user | varchar(255) | 创建人 |
| create_time | datetime | 创建时间 |
| update_user | varchar(255) | 更新人 |
| update_time | datetime | 更新时间 |
| is_delete | int | 软删除标记:0-正常 1-已删除 |
:::tip 温馨提示
其中 `id`、`create_user`、`create_time`、`update_user`、`update_time`、`is_delete` 由基类 `BaseModel` 自动提供,业务字段仅需定义 `name`、`status`、`sort`。
:::
## 开发顺序
建议按以下顺序逐步开发,每完成一步即可验证:
```
1. models.py → 建表,确认数据库中有 flask_position 表
2. schema.py → 定义验证规则(无独立验证方式,后续路由调用时自动校验)
3. repository.py → 数据访问层(通常只需声明类,无需写代码)
4. service.py → 业务逻辑层(声明差异点 + 自定义方法)
5. router.py → 路由定义(声明路由 + 权限装饰器)
6. v1.py → 注册路由(一行代码)
7. 权限配置 → 在菜单管理中添加权限节点
8. 前端对接 → API → 页面 → 路由
```
:::tip 温馨提示
本章每一步都附有完整代码和详细注释。建议对照项目中 `src/modules/system/position/` 目录下的实际源码一起阅读,加深理解。
:::
## 总结
模块开发实战以岗位管理(position)为例,完整演示从 Model → Schema → Repository → Service → Router → 路由注册 → 权限配置 → 前端 API → 前端页面的全流程。每一步都附有完整代码和详细注释,建议对照实际源码一起阅读。
@@ -0,0 +1,106 @@
# 权限节点配置
后端代码中的 `@check_permission("sys:position:page")` 装饰器定义了接口所需的权限字符串,但权限节点本身需要在数据库中配置,才能在「菜单管理」中分配给角色。
## 权限字符串格式
```
sys:{module}:{action}
```
| 部分 | 说明 | 示例 |
|:----|:----|:----|
| `sys` | 系统前缀(固定) | `sys` |
| `module` | 模块名(小写) | `position` |
| `action` | 操作名 | `page` / `add` / `update` / `delete` |
## Position 模块的权限节点
| 权限字符串 | 说明 | HTTP 方法 | 路径 |
|:----|:----|:----|:----|
| `sys:position:page` | 分页查询 | GET | `/api/v1/position/page` |
| `sys:position:detail` | 详情查询 | GET | `/api/v1/position/detail/<id>` |
| `sys:position:list` | 列表查询 | GET | `/api/v1/position/list` |
| `sys:position:add` | 新增岗位 | POST | `/api/v1/position/add` |
| `sys:position:update` | 更新岗位 | PUT | `/api/v1/position/update` |
| `sys:position:delete` | 删除岗位 | DELETE | `/api/v1/position/delete/<id>` |
| `sys:position:batchDelete` | 批量删除 | DELETE | `/api/v1/position/batchDelete` |
| `sys:position:status` | 状态变更 | PUT | `/api/v1/position/status` |
## 配置步骤
### 第一步:添加菜单节点
登录管理后台,进入「系统管理 → 菜单管理」:
1. 找到或创建「岗位管理」菜单项
2. 在该菜单下添加「权限」类型子节点
3. 填写以下信息:
| 字段 | 值 |
|:----|:----|
| 菜单类型 | 权限 |
| 权限标识 | `sys:position:page` |
| 菜单名称 | 岗位查询 |
### 第二步:批量添加权限节点
为每个接口重复上述步骤,添加全部 8 个权限节点。
:::tip 温馨提示
实际开发中,查询类权限(page、detail、list)通常合并为一个节点 `sys:position:list`,减少配置工作量。写操作(add、update、delete、batchDelete、status)建议独立配置,便于细粒度控制。
:::
### 第三步:分配给角色
权限节点创建并启用后,需将其分配给角色,用户才能通过角色继承相应权限。
操作路径:「系统管理 → 角色管理」
1. 在角色列表中找到目标角色,点击「编辑」进入权限配置页面。
2. 在权限树中展开「岗位管理」模块,勾选需要分配的权限节点(如「新增」「编辑」「删除」等)。
3. 点击「保存」提交配置,权限即生效。
:::warning 特别说明
注意:权限分配后,拥有该角色的用户需重新登录或刷新页面才能获取最新权限。若权限未生效,请检查用户是否已正确关联该角色。
:::
## 权限校验流程
```
用户请求 → login_required(JWT 校验) → check_permission(权限校验)
├── 用户 ID = 1(admin)→ 跳过权限校验,直接放行
└── 其他用户 → 查询角色权限列表 → 匹配权限字符串 → 放行 / 拒绝
```
## SQL 直接插入权限节点
如果需要通过 SQL 直接插入权限节点(如初始化脚本),参考以下语句:
```sql
-- 假设岗位管理菜单 ID 为 100
INSERT INTO flask_menu (name, parent_id, path, component, perms, type, icon, sort, create_time, is_delete)
VALUES
('岗位查询', 100, NULL, NULL, 'sys:position:page', '1', NULL, 0, NOW(), 0),
('岗位新增', 100, NULL, NULL, 'sys:position:add', '1', NULL, 0, NOW(), 0),
('岗位编辑', 100, NULL, NULL, 'sys:position:update', '1', NULL, 0, NOW(), 0),
('岗位删除', 100, NULL, NULL, 'sys:position:delete', '1', NULL, 0, NOW(), 0),
('岗位批量删除', 100, NULL, NULL, 'sys:position:batchDelete', '1', NULL, 0, NOW(), 0),
('岗位状态', 100, NULL, NULL, 'sys:position:status', '1', NULL, 0, NOW(), 0);
```
:::danger 特别说明
其中 `type='1'` 表示权限类型节点(非菜单)。
:::
## 开发要点
1. 权限字符串必须与代码中的 `@check_permission` 完全一致。
2. admin 用户(ID=1)自动跳过权限校验:开发阶段可用 admin 测试。
3. 权限节点是菜单的子节点:type 为 `1`(权限),不是 `0`(菜单)。
4. 查询权限可适当合并:减少配置复杂度。
5. 写操作权限建议独立:便于细粒度控制。
## 总结
权限节点是菜单的子节点(type=1),命名规范为 `sys:{模块}:{操作}`。后端通过 `@check_permission` 装饰器校验,前端通过 `v-perm` 指令控制按钮显示。查询权限可适当合并,写操作权限建议独立以实现细粒度控制。
@@ -0,0 +1,196 @@
# Repository 层
Repository 层是数据访问层,封装所有数据库查询操作。项目通过 `BaseRepository` 基类提供通用 CRUD 能力,子类只需声明 `model` 并按需实现 `page()` 和 `list_enabled()` 方法。
## 文件位置
```
src/modules/system/position/repository.py
```
## 完整代码
以岗位模块为例,`src/modules/system/position/repository.py` 完整内容:
```python
"""
岗位仓储模块
=============================================================
岗位数据访问层,继承 BaseRepository 获得通用 CRUD 能力。
"""
from src.common.base_repository import BaseRepository
from src.common.constants.common import PAGE_SIZE
from src.modules.system.position.models import Position
class PositionRepository(BaseRepository):
"""岗位数据仓储,提供岗位相关的数据库访问操作"""
model = Position
@classmethod
def page(cls, params: dict):
"""
岗位分页查询
支持按名称模糊搜索、按状态精确过滤,按 sort 字段升序排列。
Args:
params: 查询参数字典
- pageNo: 当前页码
- pageSize: 每页记录数
- name: 岗位名称(模糊匹配)
- status: 状态(精确匹配)
Returns:
tuple: (items, total, pages)
- items: 当前页数据列表
- total: 总记录数
- pages: 总页数
"""
return cls.find_page(
page=int(params.get('pageNo', 1)),
page_size=int(params.get('pageSize', PAGE_SIZE)),
filters={'status': params.get('status')},
like_filters={'name': params.get('name')},
order_by='sort',
)
@classmethod
def list_enabled(cls, params=None):
"""
查询启用的岗位列表
用于下拉选择场景,仅返回启用状态的岗位(status=1 为硬性条件,不可传参覆盖)。
支持与分页一致的条件筛选(name 模糊匹配)。
Args:
params: 查询参数字典,可包含 name 字段用于模糊搜索
Returns:
list: 启用状态的岗位实例列表,按 sort 升序排列
"""
params = params or {}
return cls.find_list(
filters={'status': 1},
like_filters={'name': params.get('name')},
order_by='sort',
)
```
## 代码解析
### 基类继承
```python
class PositionRepository(BaseRepository):
model = Position
```
`BaseRepository` 是基类,子类只需声明 `model` 即可获得通用 CRUD 能力。所有方法均为 `@classmethod`,无需创建实例。
### 必须实现的方法
| 方法 | 说明 | 何时需要 |
|:---|:---|:---|
| `page(params)` | 分页查询 | 所有模块(Service 的 `get_page` 依赖此方法) |
| `list_enabled(params)` | 无分页列表查询 | 需要下拉选择的模块(Service 的 `get_list` 依赖此方法) |
`page()` 和 `list_enabled()` 内部调用基类的 `find_page()` 和 `find_list()` 方法,传入筛选条件和排序规则。
### BaseRepository 提供的通用方法
| 方法 | 说明 |
|:---|:---|
| `get_by_id(obj_id)` | 按主键查询单条未删除记录 |
| `get_by_ids(ids)` | 按主键列表批量查询未删除记录 |
| `first_by(filters)` | 按等值条件查询单条记录 |
| `exists_by(filters, exclude_id)` | 检查是否存在未删除记录(唯一性校验) |
| `count_by(filters)` | 按等值条件统计未删除记录数 |
| `exists(obj_id)` | 检查未删除记录是否存在 |
| `count_children(parent_id)` | 统计子记录数(树形结构删除前校验) |
| `find_page(...)` | 统一分页查询(支持等值/模糊/范围过滤) |
| `find_list(...)` | 统一不分页查询 |
| `create(**kwargs)` | 创建并保存新实例 |
| `update_by_id(obj_id, data)` | 按主键更新字段 |
| `update_status(obj_id, status)` | 单独更新状态字段 |
| `delete_by_id(obj_id)` | 软删除单条记录 |
| `batch_delete(ids)` | 批量软删除 |
| `batch_restore(ids)` | 批量恢复已删除记录 |
### 何时需要自定义方法
大多数模块只需实现 `page()` 和 `list_enabled()`。当需要以下场景时,才在子类中添加自定义方法:
- **复杂联表查询**:如 User 需要关联 Dept、Role 查询
- **特殊聚合查询**:如统计、分组
- **非标准查询条件**:如区间查询、模糊 + 精确组合
示例(UserRepository):
```python
# src/modules/system/user/repository.py
class UserRepository(BaseRepository):
"""用户数据仓储"""
model = User
@classmethod
def page(cls, params: dict):
"""用户分页查询"""
return cls.find_page(
page=int(params.get('pageNo', 1)),
page_size=int(params.get('pageSize', PAGE_SIZE)),
like_filters={'realname': params.get('realname')},
filters={'gender': params.get('gender'), 'status': params.get('status')},
order_desc='id',
)
```
## Repository 与 Service 的关系
```
┌─────────────────────────────────────────────────────────────┐
│ Router 层 │
│ - 定义路由(Blueprint) │
│ - 权限校验(@check_permission) │
│ - 参数解析(parse_request_body) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Service 层 │
│ - 业务逻辑(BaseService 模板方法 + 钩子) │
│ - 表单校验(WTForms) │
│ - 存在性校验(Repository.exists_by) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Repository 层 │
│ - 数据库查询(classmethod) │
│ - CRUD 操作 │
│ - 软删除过滤(is_delete == 0) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Model 层 │
│ - 表结构定义(BaseModel + BaseDB) │
│ - 字段映射 │
└─────────────────────────────────────────────────────────────┘
```
## 开发要点
1. **所有方法均为 classmethod**:无需创建实例,直接通过类调用
2. **必须实现 `page(params)`**:Service 的 `get_page` 依赖此方法
3. **按需实现 `list_enabled(params)`**:需要下拉选择的模块实现此方法
4. **`find_page` / `find_list` 自动过滤软删除**:且跳过空值查询条件
5. **`exists_by(filters, exclude_id)` 用于唯一性校验**:支持排除自身 ID(更新时校验)
6. **Repository 层不做响应封装**:返回 SQLAlchemy 查询结果,由 Service 层处理响应
## 总结
Repository 层继承 `BaseRepository` 获得通用 CRUD 能力,所有方法均为 `classmethod`。子类只需声明 `model` 并实现 `page()` / `list_enabled()` 方法。`find_page` / `find_list` 自动过滤软删除且跳过空值条件,`exists_by` 用于唯一性校验。Repository 层不做响应封装,由 Service 层处理。
@@ -0,0 +1,138 @@
# 路由注册
路由注册是将模块的 Blueprint 挂载到应用的过程。所有模块的路由在 `src/routes/v1.py` 中统一注册。
## 文件位置
```
src/routes/v1.py
```
## 注册步骤
### 第一步:导入 Blueprint
在 `v1.py` 文件顶部的导入区域添加:
```python
from src.modules.system.position.router import position_blue
```
### 第二步:挂载 Blueprint
在 `BLUEPRINTS` 列表中添加:
```python
BLUEPRINTS = [
# ... 其他 Blueprint
position_blue,
]
```
### 完整上下文
```python
# v1.py 中的路由注册(简化示例)
from flask import Blueprint
from src.modules.system.position.router import position_blue
from src.modules.system.level.router import level_blue
from src.modules.system.dept.router import dept_blue
# ... 其他模块导入
# 全部业务蓝图(按领域分组排序)
BLUEPRINTS = [
position_blue,
level_blue,
dept_blue,
# ... 其他模块注册
]
def register_v1(app):
"""
注册 API v1 路由(所有业务蓝图挂载到 /api/v1)。
Args:
app: Flask 应用实例
"""
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
for blue in BLUEPRINTS:
v1.register_blueprint(blue)
app.register_blueprint(v1)
return app
```
## 最终 API 路径
注册后,Position 模块的接口路径为:
| 方法 | 路径 | 说明 |
|:----|:----|:----|
| GET | `/api/v1/position/page` | 分页查询 |
| GET | `/api/v1/position/detail/<id>` | 详情查询 |
| GET | `/api/v1/position/list` | 列表查询 |
| POST | `/api/v1/position/add` | 新增 |
| PUT | `/api/v1/position/update` | 更新 |
| DELETE | `/api/v1/position/delete/<id>` | 单条删除 |
| DELETE | `/api/v1/position/batchDelete` | 批量删除 |
| PUT | `/api/v1/position/status` | 状态变更 |
## prefix 命名规范
| 规则 | 示例 |
|:----|:----|
| 使用小写英文 | `/position` 而非 `/Position` |
| 使用单数形式 | `/position` 而非 `/positions` |
| 多单词用驼峰或短横线 | `/jobLog` 或 `/job-log` |
| 与模块目录名一致 | `modules/system/position/` → `/position` |
## Blueprint 定义示例
在模块的 `router.py` 文件中定义 Blueprint:
```python
# src/modules/system/position/router.py
from flask import Blueprint, request
from src.middleware import check_demo, operation_log
from src.core.permission import check_permission
from src.modules.system.position import service as position
from src.common.utils.request import parse_request_body
from src.core import response as R
position_blue = Blueprint('position', __name__, url_prefix='/position')
@position_blue.route('/page', methods=["GET"])
@check_permission("sys:position:page")
@operation_log("岗位管理", "查询")
def page():
"""查询岗位分页数据"""
return R.ok(data=position.get_position_page(request.args))
@position_blue.route('/add', methods=["POST"])
@check_permission("sys:position:add")
@check_demo
@operation_log("岗位管理", "新增")
def add():
"""添加岗位"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.add_position(json_data)
return R.ok(msg="添加成功")
```
## 开发要点
1. **只需两步**:一行导入,一行添加到 `BLUEPRINTS` 列表
2. **prefix 与模块目录名保持一致**:便于代码导航
3. **所有业务蓝图都挂在 `v1` 路由组下**:自动享受 `login_required` 认证保护
4. **使用 `check_permission` 装饰器**:实现 RBAC 权限控制
## 总结
路由注册在 `src/routes/v1.py` 中统一管理,`BLUEPRINTS` 列表包含所有业务模块的 Blueprint。所有业务蓝图挂在 `v1` 路由组下,自动享受 `login_required` 认证保护。使用 `check_permission` 装饰器实现 RBAC 权限控制。
@@ -0,0 +1,186 @@
# WTForms Schema
Schema 层负责请求数据的校验。项目使用 `WTForms` 定义表单类,通过 `validators` 约束字段格式,框架在接收请求体时自动执行校验。
## 文件位置
```
src/modules/system/position/schema.py
```
## 完整代码
```python
from wtforms import StringField, IntegerField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm
# ============================================================
# 岗位表单类
# ============================================================
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
sort = IntegerField('岗位排序', validators=[
DataRequired(message='岗位排序不能为空'),
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
])
# ============================================================
# 设置状态表单类
# ============================================================
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField('岗位ID', validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
```
## 代码解析
### 基类选择
| 基类 | 用途 | 包含字段 |
|:----|:----|:----|
| `BaseForm` | 创建/编辑通用表单 | 自带可选 `id` 字段(`Optional`),编辑时传入 |
| 特定操作表单 | 无额外字段,按需定义 |
创建和编辑共用 `PositionForm`:创建时不传 `id`,编辑时传入 `id`。`BaseForm` 的 `id` 字段定义为 `IntegerField('ID', validators=[Optional()])`,创建时可省略。
### Validator 约束
```python
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
])
```
| Validator | 含义 |
|:----|:----|
| `DataRequired` | 必填字段,不允许空值 |
| `Length(min=1, max=150)` | 字符串长度限制 |
| `NumberRange(min=1, max=2)` | 数值范围限制 |
| `Optional` | 可选字段 |
```python
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
```
| 参数 | 含义 |
|:----|:----|
| `min=1` | 最小值(大于等于) |
| `max=2` | 最大值(小于等于) |
| `message` | 校验失败时的错误信息 |
```python
sort = IntegerField('岗位排序', validators=[
DataRequired(message='岗位排序不能为空'),
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
])
```
排序字段允许 0 到 99999 的范围。
### 状态更新单独建类
```python
# ============================================================
# 设置状态表单类
# ============================================================
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField('岗位ID', validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
```
状态更新接口只需要 `id` 和 `status` 两个字段,与创建/编辑表单分开定义,接口语义更清晰。
## 如何在 Router 中使用
```python
from flask import Blueprint, request
from src.common.utils.request import parse_request_body
from src.modules.system.position import service as position
from src.core import response as R
position_blue = Blueprint('position', __name__, url_prefix='/position')
@position_blue.route('/add', methods=['POST'])
def add():
"""新增岗位"""
json_data, err = parse_request_body()
if err:
return R.failed(msg=err)
position.add_position(json_data)
return R.ok(msg="添加成功")
```
`parse_request_body` 函数自动解析请求体 JSON。校验失败时,Service 层抛出 `BizError`,由异常处理器自动返回标准错误响应:
```json
{
"code": 1,
"data": null,
"msg": "岗位名称不能为空"
}
```
## 自定义校验器
如果需要自定义校验逻辑,可以在表单类中定义 `validate_` 开头的方法:
```python
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(max=150, message='岗位名称长度不得超过150个字符')
])
def validate_name(self, field):
"""自定义校验:检查岗位名称是否已存在"""
from src.modules.system.position.repository import PositionRepository
if not field.data:
return
current_id = self.id.data if self.id.data else None
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError('岗位名称已存在')
```
## 开发要点
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,利用其内置的可选 `id` 字段
2. **特殊操作单独建类**:如状态更新、排序调整等,只包含必要的字段
3. **合理设置 Validator**:`DataRequired` / `Length` / `NumberRange` 等,让框架自动校验
4. **message 必填**:用于返回给前端的错误信息,方便调试
5. **唯一性校验**:通过自定义 `validate_` 方法实现,使用 Repository 层的 `exists_by(filters, exclude_id)` 方法
## 总结
WTForms Schema 通过 `BaseForm` 继承公共字段(id 等),使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。唯一性校验使用 `Repository.exists_by({'field': value}, exclude_id=current_id)` 方法,自动过滤软删除记录并支持排除自身。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。
@@ -0,0 +1,144 @@
# Service 层
Service 层是业务逻辑层,负责实现具体的业务规则。简单 CRUD 模块继承 `BaseService` 基类,只需声明差异点即可获得完整的增删改查能力。
## 文件位置
```
src/modules/system/position/service.py
```
## 完整代码
```python
"""
岗位管理服务模块
=============================================================
该模块实现了岗位管理相关的所有业务逻辑。
"""
from src.common.base_service import BaseService
from src.core.exceptions import BizError
from src.modules.system.position.schema import PositionForm, PositionStatusForm
from src.modules.system.position.repository import PositionRepository
from src.modules.system.user.repository import UserRepository
class PositionService(BaseService):
"""
岗位服务类
继承 BaseService 获得标准 CRUD 能力,并扩展删除前用户引用校验。
"""
repository = PositionRepository
form = PositionForm
status_form = PositionStatusForm
model_name = "岗位"
@classmethod
def before_delete(cls, ids):
"""
删除前校验钩子
检查岗位是否仍被用户引用,若被引用则拒绝删除。
Args:
ids: 待删除的岗位ID列表
Raises:
BizError: 岗位被用户引用时抛出
"""
for pid in ids:
if UserRepository.count_by({'position_id': pid}) > 0:
raise BizError(f"岗位ID {pid} 已被用户引用,无法删除")
# ============================================================
# 模块级代理函数:保持 router 调用兼容
# ============================================================
get_position_page = PositionService.get_page
get_position_detail = PositionService.get_detail
get_info = PositionService.get_info
add_position = PositionService.add
update_position = PositionService.update
delete_positions = PositionService.remove
update_position_status = PositionService.update_status
get_position_list = PositionService.get_list
```
## 代码解析
### 必须声明的属性
```python
repository = PositionRepository # 关联的 Repository 类
form = PositionForm # 新增/更新表单校验类
status_form = PositionStatusForm # 状态更新表单校验类
model_name = "岗位" # 业务对象名称,用于错误提示
```
### 表单校验
`BaseService` 通过 `form` 和 `status_form` 属性自动执行表单校验:
- `add()` / `update()` 时使用 `form` 校验
- `update_status()` 时使用 `status_form` 校验
- 校验失败自动抛出 `BizError`
### 生命周期钩子
`BaseService` 提供以下钩子方法,子类可按需覆盖:
| 钩子 | 触发时机 | 说明 |
|:----|:----|:----|
| `before_add(form)` | 新增操作前 | 可返回额外写入字段 |
| `after_add(obj)` | 新增操作后 | 用于缓存清理等 |
| `before_update(form, obj)` | 更新操作前 | 可返回额外更新字段 |
| `after_update(obj)` | 更新操作后 | 用于缓存清理等 |
| `before_delete(ids)` | 删除操作前 | 抛出 BizError 中止删除 |
| `after_delete(count)` | 删除操作后 | 记录审计日志等 |
| `_enrich_page_records(records)` | 分页富化时 | 追加关联数据 |
| `_detail_dict(obj)` | 详情序列化时 | 自定义返回字段 |
### 模块级代理函数
```python
# 模块级代理函数:保持 router 调用兼容
get_position_page = PositionService.get_page
get_position_detail = PositionService.get_detail
add_position = PositionService.add
update_position = PositionService.update
delete_positions = PositionService.remove
update_position_status = PositionService.update_status
get_position_list = PositionService.get_list
```
模块级代理函数将 Service 类方法暴露为模块级函数,供 Router 层调用。Router 层直接导入函数,不依赖 Service 实例。
## 基类提供的通用方法
| 方法 | 说明 |
|:----|:----|
| `get_page(params)` | 分页查询(params 包含 pageNo/pageSize 及筛选条件) |
| `get_detail(id)` | 查询详情(返回字典) |
| `get_info(id)` | 查询模型对象(内部使用) |
| `add(data)` | 新增记录(含表单校验 + 钩子) |
| `update(data)` | 更新记录(含表单校验 + 存在性校验 + 钩子) |
| `remove(ids)` | 批量软删除(逗号分隔 ID 字符串) |
| `update_status(data)` | 更新状态(含状态表单校验) |
| `get_list(params)` | 无分页列表查询(用于下拉选择) |
## 开发要点
1. **必须声明 `repository`**:基类所有数据访问方法依赖此属性
2. **声明 `form` 和 `status_form`**:用于自动表单校验
3. **声明 `model_name`**:用于错误提示信息
4. **删除前校验用 `before_delete` 钩子**:抛出 BizError 中止操作
5. **必须创建模块级代理函数**:供 Router 层调用
6. **Service 层不直接读取 request**:参数通过函数传入(dict 类型)
## 总结
Service 层继承 `BaseService` 获得通用 CRUD 能力,只需声明差异点(`repository`/`form`/`model_name`)并覆写钩子方法。必须创建模块级代理函数供 Router 层调用,Service 层不直接读取 request,入参为数据字典,出参为纯数据,业务失败通过 `raise BizError` 表达。