184 lines
8.1 KiB
Python
184 lines
8.1 KiB
Python
# 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)
|