From a6e094378cb290c5ce7c3acf0a5919e47a93362d Mon Sep 17 00:00:00 2001 From: DjangoAdmin Dev Date: Fri, 7 Aug 2026 14:29:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P4a=20=E6=9C=8D=E5=8A=A1=E5=B1=82?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E5=9C=B0=E5=9F=BA=EF=BC=88BizError=20+=20bas?= =?UTF-8?q?e=5Frepository=20+=20base=5Fservice=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/core/exceptions.py:BizError/NotFoundError/PermissionDeniedError - 全局异常处理器接入 BizError → R.failed(含事务回滚语义) - src/common/base_repository.py:泛型 CRUD 仓储基类 - src/common/base_service.py:服务基类(入参数据/出参数据范式) 【验收】BizError 端到端转换为 R.failed(code=403/ok=false);应用启动 Co-Authored-By: Claude --- application/middleware/error_handler.py | 12 +++++ src/common/base_repository.py | 65 +++++++++++++++++++++++++ src/common/base_service.py | 33 +++++++++++++ src/core/exceptions.py | 44 +++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 src/common/base_repository.py create mode 100644 src/common/base_service.py create mode 100644 src/core/exceptions.py diff --git a/application/middleware/error_handler.py b/application/middleware/error_handler.py index 720105f..a9d53be 100644 --- a/application/middleware/error_handler.py +++ b/application/middleware/error_handler.py @@ -22,6 +22,7 @@ from flask import request from werkzeug.exceptions import HTTPException from utils import R +from src.core.exceptions import BizError logger = logging.getLogger(__name__) @@ -66,6 +67,17 @@ def register_error_handlers(app): msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误" return R.failed(msg=msg, code=500), 500 + @app.errorhandler(BizError) + def handle_biz_error(e): + """ + 业务异常处理器 + + 业务代码 raise BizError(...) 时统一转换为 R.failed 响应, + 并回滚当前事务(teardown_request 处理)。 + """ + logger.info(f"业务异常: {e.msg} (code={e.code})") + return R.failed(msg=e.msg, code=e.code, data=e.data, **e.kwargs) + @app.errorhandler(Exception) def handle_exception(e): # HTTPException 子类(如 abort(403))已有专用 handler,不重复处理 diff --git a/src/common/base_repository.py b/src/common/base_repository.py new file mode 100644 index 0000000..7858fa5 --- /dev/null +++ b/src/common/base_repository.py @@ -0,0 +1,65 @@ +""" +仓储基类 +============================================================= + +封装通用 CRUD 查询。业务模块的 Repository 继承此类并指定 model。 +依赖 BaseModel 的通用字段(id / is_delete / create_* / update_*)。 + +设计要点: + - 查询默认过滤已删除记录(is_delete == 0) + - 不持有事务:保存沿用 BaseDB.save(),由 AOP 事务拦截器统一提交 +""" + +from typing import Any, List, Optional + +from sqlalchemy import and_ + + +class BaseRepository: + """泛型仓储基类""" + + #: 子类指定模型类 + model = None + + @classmethod + def get_by_id(cls, obj_id) -> Optional[Any]: + """按主键查询未删除记录;ID 无效时返回 None""" + if not obj_id or int(obj_id) <= 0: + return None + return cls.model.query.filter( + and_(cls.model.id == int(obj_id), cls.model.is_delete == 0) + ).first() + + @classmethod + def get_all_active(cls, order_by=None) -> List[Any]: + """查询全部未删除记录""" + query = cls.model.query.filter(cls.model.is_delete == 0) + if order_by is not None: + query = query.order_by(order_by) + return query.all() + + @classmethod + def paginate(cls, query, page: int, page_size: int): + """分页查询:返回 (items, total, pages)""" + total = query.count() + pages = (total + page_size - 1) // page_size if total > 0 else 0 + items = query.limit(page_size).offset((page - 1) * page_size).all() + return items, total, pages + + @classmethod + def update_by_id(cls, obj_id, data: dict) -> int: + """按主键更新字段,返回影响行数""" + return cls.model.query.filter_by(id=obj_id).update(data) + + @classmethod + def create(cls, **kwargs) -> Any: + """创建并保存实例,返回实例对象""" + obj = cls.model(**kwargs) + obj.save() + return obj + + @classmethod + def save(cls, obj) -> Any: + """保存实例,返回实例对象""" + obj.save() + return obj diff --git a/src/common/base_service.py b/src/common/base_service.py new file mode 100644 index 0000000..2067a59 --- /dev/null +++ b/src/common/base_service.py @@ -0,0 +1,33 @@ +""" +服务基类 +============================================================= + +提供服务层通用骨架。服务层编码规范: + - 函数入参为数据(dict / 标量),出参为纯数据 + - 业务失败通过 `raise BizError` 表达(见 src/core/exceptions.py) + - 不直接读取 request、不返回 R 对象(request 解析与 R 包装由 router 层负责) + - 数据访问走 repository(见 src/common/base_repository.py) + +使用示例: + class PositionService(BaseService): + repository = PositionRepository + + def add_position(self, data): + ... +""" + +from src.common.base_repository import BaseRepository + + +class BaseService: + """服务基类""" + + #: 子类指定仓储类 + repository: BaseRepository = None + + @classmethod + def get_info(cls, obj_id): + """通用:按 ID 查询模型对象""" + if cls.repository is None: + return None + return cls.repository.get_by_id(obj_id) diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..4a56125 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,44 @@ +""" +业务异常定义 +============================================================= + +业务代码通过 `raise BizError(...)` 表达业务失败,由全局异常处理器 +统一转换为 R.failed 响应。业务层不再 `return R.failed`,使服务函数 +保持「入参数据、出参数据」的纯函数形态(可单元测试)。 + +使用示例: + from src.core.exceptions import BizError + + def add_position(data): + form = PositionForm(data=data) + if not form.validate(): + raise BizError(regular.get_err(form)) + position.save() +""" + +from src.common.constants.common import MESSAGE_FAIL + + +class BizError(Exception): + """业务异常:统一转为 R.failed 响应""" + + def __init__(self, msg: str = MESSAGE_FAIL, code: int = 1, data=None, **kwargs): + self.msg = msg + self.code = code + self.data = data + self.kwargs = kwargs + super().__init__(msg) + + +class NotFoundError(BizError): + """资源不存在(默认提示)""" + + def __init__(self, msg: str = "资源不存在", **kwargs): + super().__init__(msg=msg, **kwargs) + + +class PermissionDeniedError(BizError): + """权限不足""" + + def __init__(self, msg: str = "权限不足", code: int = 403, **kwargs): + super().__init__(msg=msg, code=code, **kwargs)