Files
student/student_management_system_complete/student_management_system/exceptions.py
T

184 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# exceptions.py
# 业务异常体系 + 全局异常处理器。
#
# 原版的问题:每个 route 各自 try/except,DAO 抛什么、API 捕什么全靠人记,
# 漏捕的地方直接把 SQLAlchemy 堆栈 500 给前端,既难看又泄露表结构。
#
# 改造后:DAO 只负责抛「业务异常」,main.py 注册一次处理器,统一翻译成 HTTP 响应。
import logging
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DataError, IntegrityError, OperationalError, SQLAlchemyError
logger = logging.getLogger(__name__)
# ============================ 业务异常 ============================
class AppException(Exception):
"""所有业务异常的基类。
code 业务错误码,前端按码判断,不依赖文案
message 给用户看的中文提示
http_status 对应 HTTP 状态码
"""
code: int = 40000
http_status: int = status.HTTP_400_BAD_REQUEST
message: str = "请求处理失败"
def __init__(self, message: str | None = None, *, code: int | None = None):
if message:
self.message = message
if code is not None:
self.code = code
super().__init__(self.message)
class NotFoundError(AppException):
code = 40400
http_status = status.HTTP_404_NOT_FOUND
message = "资源不存在"
class ConflictError(AppException):
"""唯一键冲突:学号重复、班级重名、重复就业等。"""
code = 40900
http_status = status.HTTP_409_CONFLICT
message = "数据冲突"
class DuplicateStudentNo(ConflictError):
code = 40901
message = "学号已存在"
class BusinessRuleError(AppException):
"""业务规则不满足:如"已删除的学生不允许修改""班级下还有学生不允许删除"。"""
code = 42200
# 直接写 422,不用 status.HTTP_422_UNPROCESSABLE_ENTITY ——
# Starlette 已经把它标记为 deprecated 并改名,跟着写会一直吃 DeprecationWarning
http_status = 422
message = "业务规则校验失败"
class DataAccessError(AppException):
code = 50001
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
message = "数据访问失败"
# 兼容旧代码:原 dao/student_dao.py 里定义过 BusinessException
BusinessException = AppException
# ============================ 统一响应体 ============================
def ok(data=None, message: str = "success", code: int = 0) -> JSONResponse:
return JSONResponse(status_code=200, content={"code": code, "message": message, "data": data})
def fail(code: int, message: str, http_status: int) -> JSONResponse:
return JSONResponse(status_code=http_status, content={"code": code, "message": message, "data": None})
# ============================ 数据库异常翻译 ============================
# MySQL 的错误码含义(这些是"业务能看懂的错误",不该返回 500):
MYSQL_ERROR_MAP = {
1062: (40900, "数据已存在(唯一键冲突)", 409), # ER_DUP_ENTRY
1451: (40900, "该记录被其他数据引用,无法删除", 409), # ER_ROW_IS_REFERENCED_2
1452: (42200, "关联的数据不存在(外键校验失败)", 422), # ER_NO_REFERENCED_ROW_2
3819: (42201, "数据不满足字段约束,请检查手机号等格式要求", 422), # ER_CHECK_CONSTRAINT_VIOLATED
1265: (40002, "字段值不合法:类型、长度或枚举取值不正确", 400), # ER_WARN_DATA_TRUNCATED
1406: (40003, "字段内容超出长度限制", 400), # ER_DATA_TOO_LONG
1048: (40004, "必填字段不能为空", 400), # ER_BAD_NULL_ERROR
}
def classify_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, int]:
"""把 SQLAlchemy 异常翻译成 (业务错误码, 中文提示, HTTP 状态码)。
单独抽成纯函数是为了能直接被测试覆盖 —— 真实 MySQL 的 CHECK 约束违反
抛的是 OperationalError(3819) 而不是 IntegrityError,这个坑不写测试很容易再踩。
"""
orig = getattr(exc, "orig", None)
err_code = None
if orig is not None:
args = getattr(orig, "args", None) or ()
if args and isinstance(args[0], int):
err_code = args[0]
if err_code in MYSQL_ERROR_MAP:
return MYSQL_ERROR_MAP[err_code]
# SQLite 没有数字错误码,按异常类型区分
if isinstance(exc, IntegrityError):
return 40900, "数据违反唯一性或外键约束,请检查关联数据", 409
if isinstance(exc, DataError):
return 40002, "字段值不合法:类型、长度或枚举取值不正确", 400
# 剩下的多半是连接断了、SQL 语法错误、表不存在 —— 这才是真正的 500
return 50001, "数据库暂时不可用,请稍后重试", 500
# ============================ 注册处理器 ============================
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppException)
async def _app_exception_handler(request: Request, exc: AppException):
logger.warning("业务异常 %s %s -> [%s] %s", request.method, request.url.path, exc.code, exc.message)
return fail(exc.code, exc.message, exc.http_status)
@app.exception_handler(RequestValidationError)
async def _validation_handler(request: Request, exc: RequestValidationError):
# 把 Pydantic 的错误压成一行给人看的中文提示
first = exc.errors()[0] if exc.errors() else {}
loc = ".".join(str(x) for x in first.get("loc", []) if x not in ("body", "query", "path"))
msg = first.get("msg", "参数不合法")
detail = f"参数校验失败:{loc} {msg}" if loc else f"参数校验失败:{msg}"
logger.info("参数校验失败 %s %s -> %s", request.method, request.url.path, detail)
return fail(40001, detail, status.HTTP_400_BAD_REQUEST)
@app.exception_handler(IntegrityError)
async def _integrity_handler(request: Request, exc: IntegrityError):
# 唯一键 / 外键冲突:MySQL 报 1062 / 1451 / 1452,统一翻成 409
code, message, http_status = classify_sqlalchemy_error(exc)
logger.warning("数据库约束冲突 %s %s -> %s", request.method, request.url.path, exc.orig)
return fail(code, message, http_status)
@app.exception_handler(DataError)
async def _data_error_handler(request: Request, exc: DataError):
# 字段值不合法:MySQL 1265(枚举取值非法/数据被截断)、1406(超长) 都走这里。
# 正常情况这些会在 Pydantic 层就被拦掉,这里是最后一道防线。
code, message, http_status = classify_sqlalchemy_error(exc)
logger.warning("字段值非法 %s %s -> %s", request.method, request.url.path, exc.orig)
return fail(code, message, http_status)
@app.exception_handler(OperationalError)
async def _operational_handler(request: Request, exc: OperationalError):
"""注意:MySQL 的 CHECK 约束违反(3819) 抛的是 OperationalError,不是 IntegrityError。
这是只有拿真库跑才会发现的事 —— 用 SQLite 测永远测不出来。
如果不单独处理,用户把手机号填成 "123" 会收到 500「服务器内部错误」,
而真正的原因只是一个字段格式问题。
"""
code, message, http_status = classify_sqlalchemy_error(exc)
if http_status < 500:
logger.warning("字段约束违反 %s %s -> %s", request.method, request.url.path, exc.orig)
else:
logger.exception("数据库连接或执行异常")
return fail(code, message, http_status)
@app.exception_handler(SQLAlchemyError)
async def _sqlalchemy_handler(request: Request, exc: SQLAlchemyError):
code, message, http_status = classify_sqlalchemy_error(exc)
logger.exception("数据库异常")
return fail(code, message, http_status)
@app.exception_handler(Exception)
async def _fallback_handler(request: Request, exc: Exception):
logger.exception("未捕获异常")
return fail(50000, "服务器内部错误", status.HTTP_500_INTERNAL_SERVER_ERROR)