From dfe7cca5a48867853ddd62b28609c6d9e4a5b0d9 Mon Sep 17 00:00:00 2001 From: zk Date: Wed, 1 Jul 2026 17:47:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BC=82=E5=B8=B8=E7=9B=B8?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/代码开发风格文档.md | 56 +++++++++++- .kiro/steering/项目结构说明.md | 5 +- app/core/asserts.py | 90 ++++++++++++++++++ app/core/exceptions.py | 141 ++++++++++++++++++++++++----- 4 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 app/core/asserts.py diff --git a/.kiro/steering/代码开发风格文档.md b/.kiro/steering/代码开发风格文档.md index 2403389..e7218f5 100644 --- a/.kiro/steering/代码开发风格文档.md +++ b/.kiro/steering/代码开发风格文档.md @@ -102,10 +102,58 @@ inclusion: manual ## 异常处理 -- HTTP 异常使用 `raise HTTPException(status_code=xxx, detail="描述")` -- 简单断言直接使用 Python `assert` 或 `if not ... raise` -- 不要 catch 后吞掉异常,交由全局异常处理器(`exceptions.py`)统一处理 -- 全局异常处理器已注册:HTTP异常、验证异常、断言异常、未知异常 +### 核心约定 +- 统一异常与全局处理器都集中在 `app/core/exceptions.py`,`main.py` 只调用一次 `register_exception_handlers(app)` +- **自定义异常一律 HTTP 500**,真实业务含义靠响应体里的 `code` 区分(前端拦截器读 `code`) +- 抛异常时传一句具体原因(detail)即可,不用记错误码;不传就用类别名兜底 + - `raise BizError("不支持的商品")` → msg = `业务异常[不支持的商品]` + - `raise BizError()` → msg = `业务异常` +- **不要** catch 后吞掉异常,交由全局异常处理器统一处理 + +### 自定义异常类(`app/core/exceptions.py`) +业务代码优先抛下列语义化异常,不要直接 `raise HTTPException`: + +| 异常类 | code | 类别名 | 典型场景 | +|--------|------|--------|----------| +| `ParamError` | 4000 | 参数异常 | 入参缺失/非法、模型档位不支持 | +| `AssertError` | 4100 | 断言异常 | 业务前置条件不满足(由 `Assert` 工具抛出) | +| `BizError` | 4200 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 | +| `CreditError` | 4300 | 扣费异常 | 余额不足、扣费失败(前端引导充值) | +| `SysError` | 5000 | 系统异常 | 已知内部错误 / 上游模型调用失败(主动抛) | + +- 所有自定义异常继承 `AppError`,处理器只在基类上注册,靠继承链自动覆盖全部子类 +- 流式(SSE)场景用 `exc.to_event()` 输出 error 事件;上游异常包装用 `SysError.from_exc(exc)` +- 兜底:未建模的异常由 `global_exception_handler` 统一按系统异常(code=5000)返回,dev 环境暴露细节,生产只回通用提示 + +```python +from app.core.exceptions import BizError, CreditError, ParamError + +if not resume: + raise BizError("简历不存在") +if balance < cost: + raise CreditError("积分不足") +if model not in ALLOWED: + raise ParamError(f"不支持的模型档位: {model}") +``` + +### 业务断言工具(`app/core/asserts.py`) +前置条件校验优先用 `Assert` 工具类(风格参考 Spring `Assert`),条件不满足时抛 `AssertError`(code=4100),**不要**再手写 `if not ... raise` 或裸 `assert`: + +```python +from app.core.asserts import Assert + +Assert.not_none(user, "用户不存在") +Assert.has_text(func_code, "功能编码不能为空") +Assert.not_empty(items, "列表不能为空") +Assert.gt(days, 0, "天数必须大于0") +Assert.eq(status, 1, "状态不可用") +``` + +常用方法:`is_true` / `is_false` / `not_none` / `is_none` / `has_text` / `not_empty` / `gt` / `gte` / `lt` / `lte` / `eq` / `ne` + +### HTTPException +- 仅在需要返回特定 HTTP 状态码的场景(如框架层、鉴权 401/403)使用 `raise HTTPException(status_code=xxx, detail="描述")` +- 业务拒绝一律用上面的自定义异常,不要用 `HTTPException` 表达业务错误 ## Redis 使用规范 diff --git a/.kiro/steering/项目结构说明.md b/.kiro/steering/项目结构说明.md index 688f8d6..c2b600d 100644 --- a/.kiro/steering/项目结构说明.md +++ b/.kiro/steering/项目结构说明.md @@ -26,7 +26,8 @@ offerpie_python_ai/ │ ├─ lifespan.py # FastAPI 生命周期管理(启动初始化 DB/Redis,关闭释放资源) │ ├─ logger.py # Loguru 日志配置(控制台+文件,自动注入 request_id/user_id) │ ├─ middleware.py # 中间件注册(RequestID、JWT鉴权、登录拦截、请求日志、响应统一包装) - │ ├─ exceptions.py # 全局异常处理器(HTTP异常、验证异常、断言异常、未知异常) + │ ├─ exceptions.py # 统一异常定义 + 全局异常处理器(AppError/ParamError/BizError/CreditError/AssertError/SysError + HTTP/验证/断言/未知兜底) + │ ├─ asserts.py # 业务断言工具类 Assert(风格参考 Spring Assert,条件不满足抛 AssertError code=4100) │ └─ schemas/ │ └─ responses.py # 统一响应模型 StandardResponse(code/msg/data/timestamp/uuid) │ @@ -103,7 +104,7 @@ offerpie_python_ai/ | 层级 | 主要职责 | 关键类/文件 | |------|----------|-------------| | **config** | 统一配置管理,基于 Pydantic Settings,支持 .env 文件加载 | `Settings`(数据库、Redis、LLM供应商、JWT、CORS、日志等全部配置项) | -| **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py`、`redis.py`、`auth.py`、`middleware.py`、`exceptions.py`、`logger.py`、`StandardResponse` | +| **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、断言工具、统一响应 | `database.py`、`redis.py`、`auth.py`、`middleware.py`、`exceptions.py`(统一异常+全局处理器)、`asserts.py`(业务断言工具 `Assert`)、`logger.py`、`StandardResponse` | | **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举(models.py)、`model_config.py`(场景模型配置)、`resume_extractor/`(简历并行提取)、`resume_polisher/`(简历段落润色)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手岗位简历优化)、`nova_chat/`(Nova 对话助手,纯对话) | | **api** | REST API 路由定义 | `health.py`(健康检查)、`resume.py`(简历上传解析 + 段落润色)、`resume_diagnose.py`(简历诊断)、`skill_gap.py`(技能差距分析 + 生成定制简历 + AI对话编辑)、`customize_resume.py`(定制简历查询/修改/回滚)、`job_agent_chat.py`(求职助手岗位简历优化)、`nova_chat.py`(Nova 对话助手) | | **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`UserResume`、`UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition`、`ResumeDiagnosisReport`、`ResumeDiagnosisIssue`、`Job`(只读)、`JobAgentConfig`、`UserJobCustomizeResume` | diff --git a/app/core/asserts.py b/app/core/asserts.py new file mode 100644 index 0000000..3128e66 --- /dev/null +++ b/app/core/asserts.py @@ -0,0 +1,90 @@ +"""业务断言工具类(风格参考 Spring Assert) + +使用方式: + from app.core.asserts import Assert + + Assert.is_true(condition, "条件不满足的提示") + Assert.not_none(obj, "对象不能为空") + Assert.gt(days, 0, "天数必须大于0") +""" + +from app.core.exceptions import AssertError + + +class Assert: + """业务断言工具类 + + 所有方法为静态方法,条件不满足时抛 AssertError(code=4100)。 + """ + + @staticmethod + def is_true(condition: bool, msg: str = "") -> None: + """condition 为 False 时抛异常""" + if not condition: + raise AssertError(msg) + + @staticmethod + def is_false(condition: bool, msg: str = "") -> None: + """condition 为 True 时抛异常""" + if condition: + raise AssertError(msg) + + @staticmethod + def not_none(obj, msg: str = "") -> None: + """obj 为 None 时抛异常""" + if obj is None: + raise AssertError(msg) + + @staticmethod + def is_none(obj, msg: str = "") -> None: + """obj 不为 None 时抛异常""" + if obj is not None: + raise AssertError(msg) + + @staticmethod + def has_text(text: str | None, msg: str = "") -> None: + """text 为 None 或空字符串或纯空白时抛异常""" + if not text or not text.strip(): + raise AssertError(msg) + + @staticmethod + def not_empty(collection, msg: str = "") -> None: + """集合/列表为 None 或空时抛异常""" + if not collection: + raise AssertError(msg) + + @staticmethod + def gt(value, target, msg: str = "") -> None: + """value <= target 时抛异常""" + if value <= target: + raise AssertError(msg) + + @staticmethod + def gte(value, target, msg: str = "") -> None: + """value < target 时抛异常""" + if value < target: + raise AssertError(msg) + + @staticmethod + def lt(value, target, msg: str = "") -> None: + """value >= target 时抛异常""" + if value >= target: + raise AssertError(msg) + + @staticmethod + def lte(value, target, msg: str = "") -> None: + """value > target 时抛异常""" + if value > target: + raise AssertError(msg) + + @staticmethod + def eq(a, b, msg: str = "") -> None: + """a != b 时抛异常""" + if a != b: + raise AssertError(msg) + + @staticmethod + def ne(a, b, msg: str = "") -> None: + """a == b 时抛异常""" + if a == b: + raise AssertError(msg) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 9a6e365..080e665 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,3 +1,24 @@ +"""统一异常 + 全局异常处理(单一入口) + +设计原则(看一眼就懂): +- 每个异常类把 code / http_status 写死,调用处不用记码。 +- 抛的时候传一句具体原因(detail);不传就用类别名兜底。 + 例:raise BizError("不支持的商品") → msg = "业务异常[不支持的商品]" + raise BizError() → msg = "业务异常" +- 所有 handler(自定义异常 / 参数校验 / HTTP / 断言 / 未知兜底)都注册在本文件, + main.py 只调用 register_exception_handlers(app)。 + +约定:自定义异常一律 HTTP 500,真实含义靠 body 里的 code 区分(前端拦截器读 code)。 + +| 异常类 | code | HTTP | 类别名 | 典型场景 | +|-------------|------|------|-----------|--------------------------------------| +| ParamError | 4000 | 500 | 参数异常 | 入参缺失/非法、模型档位不支持 | +| BizError | 4200 | 500 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 | +| CreditError | 4300 | 500 | 扣费异常 | 余额不足、扣费失败(前端引导充值) | +| SysError | 5000 | 500 | 系统异常 | 已知的内部错误 / 上游模型失败(主动抛) | +| (未捕获) | 5000 | 500 | 系统异常 | 没被建模的异常,兜底接住 | +""" + import traceback from fastapi import Request @@ -9,7 +30,71 @@ from app.config import settings from app.core.logger import log from app.core.schemas.responses import StandardResponse -# 友好的 HTTP 状态码消息映射 + +# ==================== 异常定义 ==================== + +class AppError(Exception): + """自定义异常基类(不直接抛,统一捕获时用它兜底) + + 子类只需覆盖 code / label 两个类属性(http_status 一律 500)。 + """ + + code: int = 5000 # 业务错误码(固定) + http_status: int = 500 # 自定义异常一律 HTTP 500,真实含义靠 code 区分 + label: str = "系统异常" # 类别名,detail 为空时作默认描述 + + def __init__(self, detail: str = ""): + # 传了原因 → "业务异常[不支持的商品]";没传 → "业务异常" + self.msg = f"{self.label}[{detail}]" if detail else self.label + super().__init__(self.msg) + + def to_dict(self) -> dict: + """HTTP 统一响应体字段""" + return {"code": self.code, "msg": self.msg} + + def to_event(self) -> dict: + """SSE error 事件数据体(前端流式解析用)""" + return {"code": self.code, "message": self.msg} + + +class ParamError(AppError): + """参数异常:入参缺失 / 非法 / 不在允许范围""" + code = 4000 + label = "参数异常" + + +class BizError(AppError): + """业务异常:可预期的业务拒绝(资源不存在 / 限频 / 验证码 / 越权 / 会员校验…)""" + code = 4200 + label = "业务异常" + + +class CreditError(AppError): + """扣费异常:余额不足 / 扣费失败等积分相关问题,前端引导充值""" + code = 4300 + label = "扣费异常" + + +class AssertError(AppError): + """断言异常:业务前置条件不满足""" + code = 4100 + label = "断言异常" + + +class SysError(AppError): + """系统异常:已知的内部错误、上游模型调用失败等(主动抛)""" + code = 5000 + label = "系统异常" + + @classmethod + def from_exc(cls, exc: Exception) -> "SysError": + """从任意异常包装成系统异常(流式兜底用)""" + return cls(f"{type(exc).__name__}: {exc}") + + +# ==================== 异常处理器 ==================== + +# HTTP 状态码 → 友好提示(框架抛出的 HTTPException 用) _FRIENDLY_MESSAGES = { 400: "请求参数错误", 401: "未经授权,请登录", @@ -23,50 +108,64 @@ _FRIENDLY_MESSAGES = { def _get_uuid(request: Request) -> str | None: + """从请求上下文中获取请求唯一标识""" return getattr(request.state, "uuid", None) -async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: - uuid = _get_uuid(request) - log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | msg: {exc.detail}") - message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail)) +def _resp(code: int, msg: str, http_status: int, uuid: str | None, data=None) -> JSONResponse: return JSONResponse( - status_code=exc.status_code, - content=StandardResponse.fail(msg=message, code=exc.status_code, uuid=uuid).model_dump(), + status_code=http_status, + content=StandardResponse.fail(msg=msg, code=code, data=data, uuid=uuid).model_dump(), ) +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + """统一处理所有自定义异常(参数 / 业务 / 扣费 / 系统) + + 只在基类 AppError 上注册,Starlette 按继承链匹配,自动覆盖全部子类。 + """ + uuid = _get_uuid(request) + # 系统异常(5xxx)记 error,其余可预期拒绝记 warning + level = log.error if exc.code >= 5000 else log.warning + level(f"{type(exc).__name__} -- uuid: {uuid} | code: {exc.code} | msg: {exc.msg}") + return _resp(exc.code, exc.msg, exc.http_status, uuid) + + +async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + """处理框架 / 手动抛出的 HTTPException""" + uuid = _get_uuid(request) + log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | detail: {exc.detail}") + message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail)) + return _resp(exc.status_code, message, exc.status_code, uuid) + + async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """处理 Pydantic 参数校验异常""" uuid = _get_uuid(request) errors = exc.errors() log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}") - return JSONResponse( - status_code=422, - content=StandardResponse.fail(msg="数据验证失败", code=422, data=errors, uuid=uuid).model_dump(), - ) + return _resp(ParamError.code, "数据验证失败", 422, uuid, data=errors) async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse: + """处理 assert 断言异常""" uuid = _get_uuid(request) log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") - return JSONResponse( - status_code=500, - content=StandardResponse.fail(msg=f"断言错误: {exc}", uuid=uuid).model_dump(), - ) + return _resp(SysError.code, f"断言错误: {exc}", 500, uuid) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """兜底:所有未捕获异常统一按系统异常返回""" uuid = _get_uuid(request) log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") - msg = str(exc) if settings.env == "dev" else "服务器内部错误" - return JSONResponse( - status_code=500, - content=StandardResponse.fail(msg=msg, uuid=uuid).model_dump(), - ) + # 开发环境暴露细节,生产环境只给通用提示 + msg = f"{type(exc).__name__}: {exc}" if settings.env == "dev" else SysError.label + return _resp(SysError.code, msg, 500, uuid) def register_exception_handlers(app) -> None: - """将异常处理器挂载到 FastAPI 应用""" + """将所有异常处理器挂载到 FastAPI 应用(main.py 只调这一个)""" + app.add_exception_handler(AppError, app_error_handler) app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(AssertionError, assertion_error_handler)